Modernise Romulus-M implementation and improve error handling

This commit is contained in:
2025-09-02 20:44:07 +01:00
parent b12c05f3b6
commit df9c9a708b
14 changed files with 4823 additions and 8224 deletions

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
coverage/
*.min.js
package-lock.json

49
CLAUDE.md Normal file
View File

@@ -0,0 +1,49 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is romulus-js, a TypeScript implementation of the Romulus-M cryptography specification. It provides authenticated encryption with associated data (AEAD) functionality.
## Development Commands
- `npm run build` - Compile TypeScript to JavaScript (outputs to `dist/`)
- `npm run test` - Run Jest test suite
- `npm run lint` - Run ts-standard linter
- `npm install` - Install dependencies (automatically runs `tsc` via postinstall)
## Architecture
The codebase implements the Romulus-M AEAD cipher with the following structure:
### Core Files
- `src/romulus-m.ts` - Main cryptographic implementation containing `cryptoAeadEncrypt` and `cryptoAeadDecrypt` functions
- `src/skinny-128-384-plus.ts` - SKINNY-128-384+ block cipher implementation used by Romulus-M
- `src/constants.ts` - Cryptographic constants
### Public API
- `src/encrypt.ts` - High-level encrypt function that auto-generates nonces using UUID v4
- `src/decrypt.ts` - High-level decrypt function that handles nonce extraction
- `src/index.ts` - Main entry point exporting encrypt/decrypt functions
### Key Implementation Details
- Uses 128-bit keys, nonces, and block sizes
- Implements LFSR-based counter with 56-bit precision
- Uses domain separation for different encryption phases
- Nonces are automatically prepended to ciphertext in the high-level API
- Low-level API (`romulus-m.ts`) works with `number[]` arrays
- High-level API works with `Uint8Array` for better developer experience
### Testing
Tests are located in `tests/` directory using Jest framework. Test files include:
- `romulus-m.test.ts` - Tests for core cryptographic functions
- `encrypt.test.ts` and `decrypt.test.ts` - Tests for high-level API
- `romulus-m-reference.test.ts` - Reference implementation tests
The TypeScript configuration automatically compiles on install and uses ts-jest for testing.

11577
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"lint": "ts-standard",
"lint": "prettier --check .",
"format": "prettier --write .",
"test": "jest",
"build": "tsc",
"postinstall": "tsc"
@@ -17,12 +18,11 @@
"author": "Butlersaurus",
"license": "ISC",
"devDependencies": {
"@types/jest": "^27.4.0",
"jest": "^27.4.7",
"@types/jest": "^30.0.0",
"jest": "^30.1.3",
"prettier": "^3.6.2",
"ts-jest": "^27.1.3",
"ts-standard": "^11.0.0",
"typescript": "^4.5.5"
"ts-jest": "^29.4.1",
"typescript": "^5.9.2"
},
"jest": {
"verbose": true,
@@ -31,7 +31,7 @@
}
},
"dependencies": {
"@types/uuid": "^8.3.4",
"uuid": "^8.3.2"
"@types/uuid": "^10.0.0",
"uuid": "^11.1.0"
}
}

View File

@@ -69,5 +69,8 @@ export const C = [
];
// Romulus-M cryptography specification constants.
export const T_LENGTH = 16;
export const BLOCK_SIZE = 16;
export const KEY_SIZE = 16;
export const NONCE_SIZE = 16;
export const AUTH_TAG_SIZE = 16;
export const COUNTER_LENGTH = 7;

View File

@@ -1,38 +1,60 @@
import { cryptoAeadDecrypt } from "./romulus-m";
interface DecryptResult {
success: boolean;
plaintext: Uint8Array;
}
import {
validateKey,
validateAssociatedData,
validateCiphertext,
DecryptResult,
NONCE_SIZE,
} from "./types";
/**
* Decrypt a Romulus-M encrypted message.
* N.B. Nonces are handled automatically by this function.
* @param buffer The nonce-prepended data to be decrypted.
* @param associatedData The associated data.
* @param key The encryption key.
* @returns A decrypted DecryptResult object.
* Decrypt a Romulus-M encrypted message with automatic nonce handling.
* The nonce is automatically extracted from the beginning of the input.
* @param buffer The nonce-prepended ciphertext to decrypt (nonce || ciphertext || tag).
* @param associatedData The associated data used for authentication.
* @param key The 16-byte decryption key.
* @returns A DecryptResult object containing success status and plaintext.
* @throws {InvalidKeyError} If the key is invalid.
* @throws {InvalidInputError} If buffer or associated data is invalid.
* @example
* ```typescript
* const key = new Uint8Array(16); // Same key used for encryption
* const associatedData = new TextEncoder().encode("metadata");
* const result = decrypt(encryptedBuffer, associatedData, key);
* if (result.success) {
* const message = new TextDecoder().decode(result.plaintext);
* }
* ```
*/
export function decrypt(
buffer: Uint8Array,
associatedData: Uint8Array,
key: Uint8Array,
): DecryptResult {
// Split nonce from ciphertext.
const nonce = Array.from(buffer.slice(0, 16));
const ciphertext = Array.from(buffer.slice(16));
// Validate inputs
const validatedBuffer = validateCiphertext(buffer);
const validatedAssociatedData = validateAssociatedData(associatedData);
const validatedKey = validateKey(key);
// Decrypt ciphertext using the associated data, nonce and encryption key.
// Ensure buffer is long enough to contain a nonce
if (validatedBuffer.length < NONCE_SIZE) {
return {
success: false,
plaintext: new Uint8Array(),
};
}
// Extract nonce and ciphertext
const nonce = validatedBuffer.slice(0, NONCE_SIZE);
const ciphertext = validatedBuffer.slice(NONCE_SIZE);
// Decrypt using the low-level AEAD function
const result = cryptoAeadDecrypt(
ciphertext,
Array.from(associatedData),
validatedAssociatedData,
nonce,
Array.from(key),
validatedKey,
);
// Return the ciphertext and decryption status.
return {
success: result.success,
plaintext: Uint8Array.from(result.plaintext),
};
return result;
}

View File

@@ -1,33 +1,50 @@
import { cryptoAeadEncrypt } from "./romulus-m";
import { validateKey, validateAssociatedData, validateMessage } from "./types";
import { v4 as uuidv4 } from "uuid";
/**
* Encrypt a message using the Romulus-M encryption algorithm.
* N.B. A nonce is automatically prepended to the ciphertext using this function.
* Encrypt a message using the Romulus-M AEAD algorithm with auto-generated nonce.
* The nonce is automatically prepended to the ciphertext.
* @param message The plaintext message to encrypt.
* @param associatedData The associated data.
* @param key The encryption key.
* @returns The nonce-prepended ciphertext.
* @param associatedData The associated data for authentication.
* @param key The 16-byte encryption key.
* @returns The nonce-prepended ciphertext (nonce || ciphertext || tag).
* @throws {InvalidKeyError} If the key is invalid.
* @throws {InvalidInputError} If message or associated data is invalid.
* @example
* ```typescript
* const key = new Uint8Array(16); // 16 zero bytes for example
* const message = new TextEncoder().encode("Hello, world!");
* const associatedData = new TextEncoder().encode("metadata");
* const encrypted = encrypt(message, associatedData, key);
* ```
*/
export function encrypt(
message: Uint8Array,
associatedData: Uint8Array,
key: Uint8Array,
): Uint8Array {
// Generate a nonce.
// Validate inputs
const validatedMessage = validateMessage(message);
const validatedAssociatedData = validateAssociatedData(associatedData);
const validatedKey = validateKey(key);
// Generate a random nonce using UUID v4
const nonce = new Uint8Array(16);
uuidv4({}, nonce);
// Encrypt the data using the associated data, newly generated nonce and encryption key.
const ciphertext = Uint8Array.from(
cryptoAeadEncrypt(
Array.from(message),
Array.from(associatedData),
Array.from(nonce),
Array.from(key),
),
// Encrypt using the low-level AEAD function
const ciphertext = cryptoAeadEncrypt(
validatedMessage,
validatedAssociatedData,
nonce,
validatedKey,
);
// Return the nonce-prepended ciphertext.
return new Uint8Array([...nonce, ...ciphertext]);
// Return nonce prepended to ciphertext
const result = new Uint8Array(nonce.length + ciphertext.length);
result.set(nonce, 0);
result.set(ciphertext, nonce.length);
return result;
}

View File

@@ -1,458 +1,615 @@
import { COUNTER_LENGTH } from "./constants";
import { COUNTER_LENGTH, BLOCK_SIZE, AUTH_TAG_SIZE } from "./constants";
import { tweakeyEncode, skinnyEncrypt } from "./skinny-128-384-plus";
import {
validateKey,
validateNonce,
validateAssociatedData,
validateMessage,
validateCiphertext,
createByteArray,
xorBytes,
copyBytes,
DecryptResult,
InvalidInputError,
RomulusError,
Key,
Nonce,
} from "./types";
// Re-export DecryptResult for backward compatibility
export type { DecryptResult };
/**
* Parse message into blocks.
* @param message The message to parse.
* @param blockLength The block length.
* @returns An array of blocks.
* Parse message into blocks of specified length.
* @param message The message to parse as a Uint8Array.
* @param blockLength The block length in bytes (typically 16).
* @returns An array of message blocks, with empty block at index 0.
*/
function parse(message: number[], blockLength: number): number[][] {
// Keep track of position in message currently parsed into blocks.
function parseMessage(message: Uint8Array, blockLength: number): Uint8Array[] {
const blocks: Uint8Array[] = [new Uint8Array(0)]; // Empty block at index 0
let cursor = 0;
// Slice message into blocks.
let ret: number[][] = [];
// Create full blocks
while (message.length - cursor >= blockLength) {
ret.push(message.slice(cursor, cursor + blockLength));
cursor = cursor + blockLength;
blocks.push(message.slice(cursor, cursor + blockLength));
cursor += blockLength;
}
// Append any remaining blocks regardless of block length. These will be padded later.
// Add final partial block if any bytes remain
if (message.length - cursor > 0) {
ret.push(message.slice(cursor));
blocks.push(message.slice(cursor));
}
// If no message, return a single block.
// If message is empty, add one empty block (in addition to index 0)
if (message.length === 0) {
ret = [[]];
blocks.push(new Uint8Array(0));
}
// Insert empty array at position 0.
ret.splice(0, 0, []);
return ret;
return blocks;
}
/**
* Pads the byte length of message to padLength. The final byte (when padded) contains the original message length.
* @param message The message to pad.
* @param padLength The length to pad the message to.
* @returns A padded block.
* Pads a message block to the specified length using Romulus-M padding scheme.
* The final byte contains the original message length.
* @param message The message block to pad.
* @param padLength The target length after padding (typically 16).
* @returns A padded block of the specified length.
*/
function pad(message: number[], padLength: number): number[] {
// If there is no message, return a fully padded block.
if (message.length === 0) {
return Array(16);
function padBlock(message: Uint8Array, padLength: number): Uint8Array {
if (padLength <= 0 || padLength > 255) {
throw new InvalidInputError(`Invalid pad length: ${padLength}`);
}
// Return a copy of the message if no padding is required.
// If message is empty, return zero-filled block
if (message.length === 0) {
return createByteArray(padLength, 0);
}
// If message is already the correct length, return a copy
if (message.length === padLength) {
return Array.from(message);
return copyBytes(message);
}
// Pad a copy of the message to padLength.
const ret = Array.from(message);
const requiredPadding = padLength - message.length - 1;
ret.push(...Array(requiredPadding));
// If message is too long, this is an error
if (message.length > padLength) {
throw new InvalidInputError(
`Message too long for padding: ${message.length} > ${padLength}`,
);
}
// Set the final byte of the padded blocked to the length of the original message.
ret[padLength - 1] = message.length;
// Create padded block
const padded = createByteArray(padLength, 0);
padded.set(message, 0);
return ret;
// Set the final byte to the original message length
padded[padLength - 1] = message.length;
return padded;
}
/**
* Generate the key stream from the internal state by multiplying the state S and the constant matrix G.
* @param state The state from which the key stream will be generated.
* @returns The key stream.
* Generate the keystream from the internal state using the linear transformation G.
* Implements the transformation: G(x) = (x >> 1) ⊕ (x & 0x80) ⊕ ((x & 0x01) << 7)
* @param state The 16-byte internal state.
* @returns The 16-byte keystream.
*/
function g(state: number[]): number[] {
return state.map((x) => {
return (x >> 1) ^ (x & 0x80) ^ ((x & 0x01) << 7);
});
function generateKeystream(state: Uint8Array): Uint8Array {
if (state.length !== BLOCK_SIZE) {
throw new InvalidInputError(
`State must be ${BLOCK_SIZE} bytes, got ${state.length}`,
);
}
const keystream = new Uint8Array(BLOCK_SIZE);
for (let i = 0; i < BLOCK_SIZE; i++) {
const x = state[i];
keystream[i] = (x >> 1) ^ (x & 0x80) ^ ((x & 0x01) << 7);
}
return keystream;
}
/**
* The state update function. Pads an M block.
* @param state The internal state, S.
* @param mBlock An M block.
* @returns [S', C] where S' = MS and C = M ⊕ G(S)
* State update function for encryption: ρ(S, M) = (S ⊕ M, M ⊕ G(S))
* @param state The current 16-byte internal state S.
* @param messageBlock The 16-byte message block M.
* @returns [nextState, ciphertextBlock] where nextState = SM and ciphertextBlock = M ⊕ G(S)
*/
function rho(state: number[], mBlock: number[]): [number[], number[]] {
// G(S)
const gOfS = g(state);
function stateUpdateEncrypt(
state: Uint8Array,
messageBlock: Uint8Array,
): [Uint8Array, Uint8Array] {
if (state.length !== BLOCK_SIZE) {
throw new InvalidInputError(
`State must be ${BLOCK_SIZE} bytes, got ${state.length}`,
);
}
if (messageBlock.length !== BLOCK_SIZE) {
throw new InvalidInputError(
`Message block must be ${BLOCK_SIZE} bytes, got ${messageBlock.length}`,
);
}
// Generate keystream G(S)
const keystream = generateKeystream(state);
// C = M ⊕ G(S)
const cBlock = Array.from(Array(16).keys()).map((i) => mBlock[i] ^ gOfS[i]);
// S' = M ⊕ S
const nextState = Array.from(Array(16).keys()).map(
(i) => state[i] ^ mBlock[i],
);
return [nextState, cBlock];
}
/**
* The state update function. Pads a C block.
* @param state The internal state, S.
* @param cBlock A C block.
* @returns [S', M] where M = C ⊕ G(S) and S' = C ⊕ M.
*/
function inverseRoh(state: number[], cBlock: number[]): [number[], number[]] {
// G(S)
const gOfS = g(state);
// M = C ⊕ G(S)
const mBlock = Array.from(Array(16).keys()).map((i) => cBlock[i] ^ gOfS[i]);
const ciphertextBlock = xorBytes(messageBlock, keystream);
// S' = S ⊕ M
const nextState = Array.from(Array(16).keys()).map(
(i) => state[i] ^ mBlock[i],
);
return [nextState, mBlock];
const nextState = xorBytes(state, messageBlock);
return [nextState, ciphertextBlock];
}
/**
* Increments the 56 bit LFSR-based counter.
* @param counter The old counter.
* @returns An incremented counter.
* State update function for decryption: ρ⁻¹(S, C) = (S ⊕ M, C ⊕ G(S)) where M = C ⊕ G(S)
* @param state The current 16-byte internal state S.
* @param ciphertextBlock The 16-byte ciphertext block C.
* @returns [nextState, messageBlock] where M = C ⊕ G(S) and nextState = S ⊕ M
*/
function increaseCounter(counter: number[]): number[] {
const fb0 = counter[6] >> 7;
counter[6] = (counter[6] << 1) | (counter[5] >> 7);
counter[5] = (counter[5] << 1) | (counter[4] >> 7);
counter[4] = (counter[4] << 1) | (counter[3] >> 7);
counter[3] = (counter[3] << 1) | (counter[2] >> 7);
counter[2] = (counter[2] << 1) | (counter[1] >> 7);
counter[1] = (counter[1] << 1) | (counter[0] >> 7);
if (fb0 === 1) {
counter[0] = (counter[0] << 1) ^ 0x95;
} else {
counter[0] = counter[0] << 1;
function stateUpdateDecrypt(
state: Uint8Array,
ciphertextBlock: Uint8Array,
): [Uint8Array, Uint8Array] {
if (state.length !== BLOCK_SIZE) {
throw new InvalidInputError(
`State must be ${BLOCK_SIZE} bytes, got ${state.length}`,
);
}
if (ciphertextBlock.length !== BLOCK_SIZE) {
throw new InvalidInputError(
`Ciphertext block must be ${BLOCK_SIZE} bytes, got ${ciphertextBlock.length}`,
);
}
return counter;
// Generate keystream G(S)
const keystream = generateKeystream(state);
// M = C ⊕ G(S)
const messageBlock = xorBytes(ciphertextBlock, keystream);
// S' = S ⊕ M
const nextState = xorBytes(state, messageBlock);
return [nextState, messageBlock];
}
/**
* Returns a reset counter.
* @returns A reset counter.
* Increments the 56-bit LFSR-based counter (pure function).
* @param counter The current 7-byte counter state.
* @returns A new incremented counter (does not modify input).
*/
function resetCounter(): number[] {
const counter = Array(COUNTER_LENGTH);
function incrementCounter(counter: Uint8Array): Uint8Array {
if (counter.length !== COUNTER_LENGTH) {
throw new InvalidInputError(
`Counter must be ${COUNTER_LENGTH} bytes, got ${counter.length}`,
);
}
// Create a copy to avoid mutation
const newCounter = copyBytes(counter);
// Extract feedback bit from MSB of byte 6
const fb0 = newCounter[6] >> 7;
// Shift left by 1 bit across all bytes (big-endian)
newCounter[6] = (newCounter[6] << 1) | (newCounter[5] >> 7);
newCounter[5] = (newCounter[5] << 1) | (newCounter[4] >> 7);
newCounter[4] = (newCounter[4] << 1) | (newCounter[3] >> 7);
newCounter[3] = (newCounter[3] << 1) | (newCounter[2] >> 7);
newCounter[2] = (newCounter[2] << 1) | (newCounter[1] >> 7);
newCounter[1] = (newCounter[1] << 1) | (newCounter[0] >> 7);
// Apply LFSR feedback polynomial if feedback bit is set
if (fb0 === 1) {
newCounter[0] = (newCounter[0] << 1) ^ 0x95;
} else {
newCounter[0] = newCounter[0] << 1;
}
// Ensure all bytes remain in valid range
for (let i = 0; i < COUNTER_LENGTH; i++) {
newCounter[i] &= 0xff;
}
return newCounter;
}
/**
* Creates a reset counter initialized to the starting state.
* @returns A new 7-byte counter initialized to [1, 0, 0, 0, 0, 0, 0].
*/
function createResetCounter(): Uint8Array {
const counter = createByteArray(COUNTER_LENGTH, 0);
counter[0] = 1;
return counter;
}
/**
* Calculate the domain separation.
* @param combinedData The parsed and concatenated message and associated data,
* @param parsedMessageLength The length of the parsed message.
* @param parsedAssociatedDataLength The length of the parsed associated data.
* Calculate the domain separation value based on message and associated data characteristics.
* @param combinedBlocks The parsed and concatenated message and associated data blocks.
* @param messageBlockCount The number of message blocks.
* @param associatedDataBlockCount The number of associated data blocks.
* @returns The domain separation value for the final encryption step.
*/
function calculateDomainSeparation(
combinedData: number[][],
parsedMessageLength: number,
parsedAssociatedDataLength: number,
combinedBlocks: Uint8Array[],
messageBlockCount: number,
associatedDataBlockCount: number,
): number {
let domainSeparation = 16;
if (combinedData[parsedAssociatedDataLength].length < 16) {
domainSeparation = domainSeparation ^ 2;
// Check if the final associated data block is incomplete
if (combinedBlocks[associatedDataBlockCount].length < BLOCK_SIZE) {
domainSeparation ^= 2;
}
// Check if the final message block is incomplete
if (
combinedData[parsedAssociatedDataLength + parsedMessageLength].length < 16
combinedBlocks[associatedDataBlockCount + messageBlockCount].length <
BLOCK_SIZE
) {
domainSeparation = domainSeparation ^ 1;
domainSeparation ^= 1;
}
if (parsedAssociatedDataLength % 2 === 0) {
domainSeparation = domainSeparation ^ 8;
// Check if associated data block count is even
if (associatedDataBlockCount % 2 === 0) {
domainSeparation ^= 8;
}
if (parsedMessageLength % 2 === 0) {
domainSeparation = domainSeparation ^ 4;
// Check if message block count is even
if (messageBlockCount % 2 === 0) {
domainSeparation ^= 4;
}
return domainSeparation;
}
/**
* Encrypt a message using the Romulus-M cryptography specification.
* Encrypt a message using the Romulus-M AEAD cryptography specification.
* See https://romulusae.github.io/romulus/docs/Romulusv1.3.pdf for more information.
* @param message The message to encrypt.
* @param associatedData The associated data to encrypt.
* @param nonce A 128 bit nonce.
* @param key A 128 bit encryption key.
* @returns The encrypted ciphertext.
* @param message The message to encrypt as a byte array.
* @param associatedData The associated data as a byte array.
* @param nonce A 16-byte nonce.
* @param key A 16-byte encryption key.
* @returns The encrypted ciphertext with authentication tag appended.
* @throws {InvalidInputError} If inputs have incorrect lengths or invalid values.
*/
export function cryptoAeadEncrypt(
message: number[],
associatedData: number[],
nonce: number[],
key: number[],
): number[] {
// Buffer for ciphertext.
const ciphertext = [];
message: Uint8Array,
associatedData: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array {
// Validate all inputs
const validatedMessage = validateMessage(message);
const validatedAssociatedData = validateAssociatedData(associatedData);
const validatedNonce = validateNonce(nonce);
const validatedKey = validateKey(key);
// Reset state and counter.
let state = Array(16);
let counter = resetCounter();
try {
// Initialize state and counter
let state = createByteArray(BLOCK_SIZE, 0);
let counter = createResetCounter();
// Carve message and associated data into blocks.
const messageBlocks = parse(message, 16);
// Parse message and associated data into blocks
const messageBlocks = parseMessage(validatedMessage, BLOCK_SIZE);
const messageBlockCount = messageBlocks.length - 1;
const associatedDataBlocks = parse(associatedData, 16);
const associatedDataBlocks = parseMessage(
validatedAssociatedData,
BLOCK_SIZE,
);
const associatedDataBlockCount = associatedDataBlocks.length - 1;
// Concatenate the message and associated data blocks, excluding each array's first element.
const combinedDataBlocks = associatedDataBlocks
.slice(1)
.concat(messageBlocks.slice(1));
// Concatenate blocks (excluding empty block at index 0)
const combinedBlocks = [
createByteArray(0), // Empty block at index 0
...associatedDataBlocks.slice(1),
...messageBlocks.slice(1),
];
// Insert empty array at position 0.
combinedDataBlocks.splice(0, 0, []);
// Calculate domain separation for final encryption stage.
// Calculate domain separation for final encryption stage
const domainSeparation = calculateDomainSeparation(
combinedDataBlocks,
combinedBlocks,
messageBlockCount,
associatedDataBlockCount,
);
// Pad combined data.
combinedDataBlocks[associatedDataBlockCount] = pad(
combinedDataBlocks[associatedDataBlockCount],
16,
// Pad the final blocks
if (associatedDataBlockCount > 0) {
combinedBlocks[associatedDataBlockCount] = padBlock(
combinedBlocks[associatedDataBlockCount],
BLOCK_SIZE,
);
combinedDataBlocks[associatedDataBlockCount + messageBlockCount] = pad(
combinedDataBlocks[associatedDataBlockCount + messageBlockCount],
16,
);
// Process the associated data.
let x = 8;
for (
let i = 1;
i < Math.floor((associatedDataBlockCount + messageBlockCount) / 2) + 1;
i++
) {
[state] = rho(state, combinedDataBlocks[2 * i - 1]);
counter = increaseCounter(counter);
if (i === Math.floor(associatedDataBlockCount / 2) + 1) {
x = x ^ 4;
}
if (messageBlockCount > 0) {
combinedBlocks[associatedDataBlockCount + messageBlockCount] = padBlock(
combinedBlocks[associatedDataBlockCount + messageBlockCount],
BLOCK_SIZE,
);
}
// Process associated data and message blocks
let x = 8;
const totalRounds =
Math.floor((associatedDataBlockCount + messageBlockCount) / 2) + 1;
for (let i = 1; i < totalRounds; i++) {
// State update with odd-indexed block
[state] = stateUpdateEncrypt(state, combinedBlocks[2 * i - 1]);
counter = incrementCounter(counter);
// Switch domain constant at transition point
if (i === Math.floor(associatedDataBlockCount / 2) + 1) {
x ^= 4;
}
// SKINNY encryption with even-indexed block
state = skinnyEncrypt(
state,
tweakeyEncode(counter, x, combinedDataBlocks[2 * i], key),
tweakeyEncode(counter, x, combinedBlocks[2 * i], validatedKey),
);
counter = increaseCounter(counter);
counter = incrementCounter(counter);
}
// Handle final block based on parity
const emptyBlock = createByteArray(BLOCK_SIZE, 0);
if (associatedDataBlockCount % 2 === messageBlockCount % 2) {
[state] = rho(state, Array(16));
[state] = stateUpdateEncrypt(state, emptyBlock);
} else {
[state] = rho(
[state] = stateUpdateEncrypt(
state,
combinedDataBlocks[associatedDataBlockCount + messageBlockCount],
combinedBlocks[associatedDataBlockCount + messageBlockCount],
);
counter = increaseCounter(counter);
counter = incrementCounter(counter);
}
// Generate authentication tag.
const [, authenticationTag] = rho(
skinnyEncrypt(state, tweakeyEncode(counter, domainSeparation, nonce, key)),
Array(16),
// Generate authentication tag
const encryptedState = skinnyEncrypt(
state,
tweakeyEncode(counter, domainSeparation, validatedNonce, validatedKey),
);
const [, authenticationTag] = stateUpdateEncrypt(
encryptedState,
emptyBlock,
);
if (message.length === 0) {
// If message is empty, return only the authentication tag
if (validatedMessage.length === 0) {
return authenticationTag;
}
state = Array.from(authenticationTag);
counter = resetCounter();
// Encrypt the message blocks
state = copyBytes(authenticationTag);
counter = createResetCounter();
// Encrypt the message.
const originalFinalMessageBlockLength =
messageBlocks[messageBlockCount].length;
messageBlocks[messageBlockCount] = pad(messageBlocks[messageBlockCount], 16);
for (let i = 1; i < messageBlockCount + 1; i++) {
state = skinnyEncrypt(state, tweakeyEncode(counter, 4, nonce, key));
let cBlock;
[state, cBlock] = rho(state, messageBlocks[i]);
counter = increaseCounter(counter);
if (i < messageBlockCount) {
ciphertext.push(...cBlock);
} else {
ciphertext.push(...cBlock.slice(0, originalFinalMessageBlockLength));
}
}
// Store the authentication tag in the final 16 bytes of the ciphertext.
ciphertext.push(...authenticationTag);
return ciphertext;
}
/**
* Return interface for decrypting a message.
*/
export interface DecryptResult {
success: boolean;
plaintext: number[];
}
/**
* Decrypt a message using the Romulus-M cryptography specification.
* See https://romulusae.github.io/romulus/docs/Romulusv1.3.pdf for more information.
* @param ciphertext The ciphertext to decrypt.
* @param associatedData The associated data.
* @param nonce The nonce.
* @param key The key.
* @returns The decrypted plaintext.
*/
export function cryptoAeadDecrypt(
ciphertext: number[],
associatedData: number[],
nonce: number[],
key: number[],
): DecryptResult {
// Buffer for decrypted message.
const cleartext = [];
// The authentication tag is represented by the final 16 bytes of the ciphertext.
const authenticationTag = ciphertext.slice(-16);
ciphertext.length -= 16;
// Reset state and counter.
let state = Array(16);
let counter = resetCounter();
if (ciphertext.length !== 0) {
// Combine the ciphertext.
state = Array.from(authenticationTag);
const ciphertextBlocks = parse(ciphertext, 16);
const ciphertextBlockCount = ciphertextBlocks.length - 1;
const finalCiphertextBlockLength =
ciphertextBlocks[ciphertextBlockCount].length;
ciphertextBlocks[ciphertextBlockCount] = pad(
ciphertextBlocks[ciphertextBlockCount],
16,
const ciphertext: number[] = [];
const originalFinalBlockLength = messageBlocks[messageBlockCount].length;
messageBlocks[messageBlockCount] = padBlock(
messageBlocks[messageBlockCount],
BLOCK_SIZE,
);
for (let i = 1; i < ciphertextBlockCount + 1; i++) {
state = skinnyEncrypt(state, tweakeyEncode(counter, 4, nonce, key));
for (let i = 1; i <= messageBlockCount; i++) {
state = skinnyEncrypt(
state,
tweakeyEncode(counter, 4, validatedNonce, validatedKey),
);
let mBlock;
[state, mBlock] = inverseRoh(state, ciphertextBlocks[i]);
counter = increaseCounter(counter);
const [nextState, ciphertextBlock] = stateUpdateEncrypt(
state,
messageBlocks[i],
);
state = nextState;
counter = incrementCounter(counter);
// Add ciphertext to output (truncate final block to original length)
if (i < messageBlockCount) {
ciphertext.push(...Array.from(ciphertextBlock));
} else {
ciphertext.push(
...Array.from(ciphertextBlock.slice(0, originalFinalBlockLength)),
);
}
}
// Append authentication tag to ciphertext
ciphertext.push(...Array.from(authenticationTag));
return new Uint8Array(ciphertext);
} catch (error) {
if (error instanceof InvalidInputError) {
throw error;
}
throw new RomulusError(
`Encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Decrypt a message using the Romulus-M AEAD cryptography specification.
* See https://romulusae.github.io/romulus/docs/Romulusv1.3.pdf for more information.
* @param ciphertext The ciphertext to decrypt (includes authentication tag).
* @param associatedData The associated data as a byte array.
* @param nonce The 16-byte nonce.
* @param key The 16-byte decryption key.
* @returns The decryption result with success status and plaintext.
* @throws {InvalidInputError} If inputs have incorrect lengths or invalid values.
*/
export function cryptoAeadDecrypt(
ciphertext: Uint8Array,
associatedData: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): DecryptResult {
// Validate all inputs
const validatedCiphertext = validateCiphertext(ciphertext);
const validatedAssociatedData = validateAssociatedData(associatedData);
const validatedNonce = validateNonce(nonce);
const validatedKey = validateKey(key);
try {
// Extract authentication tag from the end of ciphertext
const authenticationTag = validatedCiphertext.slice(-AUTH_TAG_SIZE);
const actualCiphertext = validatedCiphertext.slice(0, -AUTH_TAG_SIZE);
let plaintextBytes: number[] = [];
// Decrypt message blocks if ciphertext is not empty
if (actualCiphertext.length > 0) {
let state = copyBytes(authenticationTag);
let counter = createResetCounter();
// Parse ciphertext into blocks
const ciphertextBlocks = parseMessage(actualCiphertext, BLOCK_SIZE);
const ciphertextBlockCount = ciphertextBlocks.length - 1;
const finalBlockLength = ciphertextBlocks[ciphertextBlockCount].length;
// Pad final block
ciphertextBlocks[ciphertextBlockCount] = padBlock(
ciphertextBlocks[ciphertextBlockCount],
BLOCK_SIZE,
);
// Decrypt each block
for (let i = 1; i <= ciphertextBlockCount; i++) {
state = skinnyEncrypt(
state,
tweakeyEncode(counter, 4, validatedNonce, validatedKey),
);
const [nextState, messageBlock] = stateUpdateDecrypt(
state,
ciphertextBlocks[i],
);
state = nextState;
counter = incrementCounter(counter);
// Add plaintext to output (truncate final block to original length)
if (i < ciphertextBlockCount) {
cleartext.push(...mBlock);
plaintextBytes.push(...Array.from(messageBlock));
} else {
cleartext.push(...mBlock.slice(0, finalCiphertextBlockLength));
plaintextBytes.push(
...Array.from(messageBlock.slice(0, finalBlockLength)),
);
}
}
} else {
state = [];
}
// Reset state and counter.
state = Array(16);
counter = resetCounter();
// Verify authentication by recomputing the tag
const recoveredPlaintext = new Uint8Array(plaintextBytes);
// Carve the message and associated data into blocks.
const messageBlocks = parse(cleartext, 16);
const messageBlockLength = messageBlocks.length - 1;
// Initialize for verification
let state = createByteArray(BLOCK_SIZE, 0);
let counter = createResetCounter();
const associatedDataBlocks = parse(associatedData, 16);
// Parse recovered message and associated data
const messageBlocks = parseMessage(recoveredPlaintext, BLOCK_SIZE);
const messageBlockCount = messageBlocks.length - 1;
const associatedDataBlocks = parseMessage(
validatedAssociatedData,
BLOCK_SIZE,
);
const associatedDataBlockCount = associatedDataBlocks.length - 1;
// Concatenate the message and associated data blocks, excluding each array's first element.
const combinedData = associatedDataBlocks
.slice(1)
.concat(messageBlocks.slice(1));
// Combine blocks for verification
const combinedBlocks = [
createByteArray(0),
...associatedDataBlocks.slice(1),
...messageBlocks.slice(1),
];
// Insert empty array at position 0.
combinedData.splice(0, 0, []);
// Calculate domain separation for final decryption stage.
// Calculate domain separation
const domainSeparation = calculateDomainSeparation(
combinedData,
messageBlockLength,
combinedBlocks,
messageBlockCount,
associatedDataBlockCount,
);
// Pad combined data.
combinedData[associatedDataBlockCount] = pad(
combinedData[associatedDataBlockCount],
16,
// Pad final blocks
if (associatedDataBlockCount > 0) {
combinedBlocks[associatedDataBlockCount] = padBlock(
combinedBlocks[associatedDataBlockCount],
BLOCK_SIZE,
);
combinedData[associatedDataBlockCount + messageBlockLength] = pad(
combinedData[associatedDataBlockCount + messageBlockLength],
16,
);
// Verifiy associated data.
let x = 8;
for (
let i = 1;
i < Math.floor((associatedDataBlockCount + messageBlockLength) / 2) + 1;
i++
) {
[state] = rho(state, combinedData[2 * i - 1]);
counter = increaseCounter(counter);
if (i === Math.floor(associatedDataBlockCount / 2) + 1) {
x = x ^ 4;
}
if (messageBlockCount > 0) {
combinedBlocks[associatedDataBlockCount + messageBlockCount] = padBlock(
combinedBlocks[associatedDataBlockCount + messageBlockCount],
BLOCK_SIZE,
);
}
// Process blocks for verification
let x = 8;
const totalRounds =
Math.floor((associatedDataBlockCount + messageBlockCount) / 2) + 1;
for (let i = 1; i < totalRounds; i++) {
[state] = stateUpdateEncrypt(state, combinedBlocks[2 * i - 1]);
counter = incrementCounter(counter);
if (i === Math.floor(associatedDataBlockCount / 2) + 1) {
x ^= 4;
}
state = skinnyEncrypt(
state,
tweakeyEncode(counter, x, combinedData[2 * i], key),
tweakeyEncode(counter, x, combinedBlocks[2 * i], validatedKey),
);
counter = increaseCounter(counter);
counter = incrementCounter(counter);
}
if (associatedDataBlockCount % 2 === messageBlockLength % 2) {
[state] = rho(state, Array(16));
// Handle final block
const emptyBlock = createByteArray(BLOCK_SIZE, 0);
if (associatedDataBlockCount % 2 === messageBlockCount % 2) {
[state] = stateUpdateEncrypt(state, emptyBlock);
} else {
[state] = rho(
[state] = stateUpdateEncrypt(
state,
combinedData[associatedDataBlockCount + messageBlockLength],
combinedBlocks[associatedDataBlockCount + messageBlockCount],
);
counter = increaseCounter(counter);
counter = incrementCounter(counter);
}
// Calculate authentication tag.
const [, computedTag] = rho(
skinnyEncrypt(state, tweakeyEncode(counter, domainSeparation, nonce, key)),
Array(16),
// Compute authentication tag
const encryptedState = skinnyEncrypt(
state,
tweakeyEncode(counter, domainSeparation, validatedNonce, validatedKey),
);
const [, computedTag] = stateUpdateEncrypt(encryptedState, emptyBlock);
// Validate authentication tag.
// Constant-time comparison of authentication tags
let compare = 0;
for (let i = 0; i < 16; i++) {
for (let i = 0; i < AUTH_TAG_SIZE; i++) {
compare |= authenticationTag[i] ^ computedTag[i];
}
if (compare !== 0) {
// Authentication failed.
// Authentication failed
return {
success: false,
plaintext: [],
plaintext: new Uint8Array(),
};
} else {
// Decrypted successfully.
// Decryption successful
return {
success: true,
plaintext: cleartext,
plaintext: recoveredPlaintext,
};
}
} catch (error) {
if (error instanceof InvalidInputError) {
throw error;
}
// Authentication failure or other error - return failure without throwing
return {
success: false,
plaintext: new Uint8Array(),
};
}
}

View File

@@ -7,93 +7,170 @@ import {
LFSR_8_TK3,
S8,
C,
BLOCK_SIZE,
} from "./constants";
import {
validateKey,
validateNonce,
InvalidInputError,
copyBytes,
} from "./types";
/**
* Create a tweakey based on the specified domain separation, nonce, key and current counter state.
* @param counter The counter.
* @param domainSeparation The domain separation.
* @param nonce The nonce.
* @param key The encryption key.
* @returns The tweakey.
* @param counter The counter state as a byte array.
* @param domainSeparation The domain separation value.
* @param nonce The 16-byte nonce.
* @param key The 16-byte encryption key.
* @returns The tweakey as a byte array.
* @throws {InvalidInputError} If inputs have incorrect lengths or invalid values.
*/
export function tweakeyEncode(
counter: number[],
counter: Uint8Array,
domainSeparation: number,
nonce: number[],
key: number[],
): number[] {
return counter.concat([domainSeparation ^ MEMBER_MASK], Array(8), nonce, key);
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array {
if (counter.length !== 7) {
throw new InvalidInputError(
`Counter must be 7 bytes, got ${counter.length}`,
);
}
if (
!Number.isInteger(domainSeparation) ||
domainSeparation < 0 ||
domainSeparation > 255
) {
throw new InvalidInputError(
`Domain separation must be a valid byte (0-255), got ${domainSeparation}`,
);
}
const validatedNonce = validateNonce(nonce);
const validatedKey = validateKey(key);
// Create tweakey: counter (7) + domain_sep^MEMBER_MASK (1) + padding (8) + nonce (16) + key (16) = 48 bytes
const tweakey = new Uint8Array(TWEAK_LENGTH);
let offset = 0;
// Copy counter
tweakey.set(counter, offset);
offset += counter.length;
// Add domain separation XORed with member mask
tweakey[offset] = domainSeparation ^ MEMBER_MASK;
offset += 1;
// Skip 8 bytes of padding (already zero-filled)
offset += 8;
// Copy nonce
tweakey.set(validatedNonce, offset);
offset += BLOCK_SIZE;
// Copy key
tweakey.set(validatedKey, offset);
return tweakey;
}
/**
* Perform a round of SKINNY-188/384+ encryption.
* @param plaintext The plaintext to encrypt.
* @param tweakey The tweakey to use for encryption.
* @returns The ciphertext.
* Perform SKINNY-128/384+ block cipher encryption.
* @param plaintext The 16-byte plaintext block to encrypt.
* @param tweakey The 48-byte tweakey for encryption.
* @returns The 16-byte ciphertext block.
* @throws {InvalidInputError} If inputs have incorrect lengths.
*/
export function skinnyEncrypt(
plaintext: number[],
tweakey: number[],
): number[] {
const tk = Array(NB_ROUNDS + 1).fill(Array(TWEAK_LENGTH).fill(0));
plaintext: Uint8Array,
tweakey: Uint8Array,
): Uint8Array {
if (plaintext.length !== BLOCK_SIZE) {
throw new InvalidInputError(
`Plaintext must be ${BLOCK_SIZE} bytes, got ${plaintext.length}`,
);
}
if (tweakey.length !== TWEAK_LENGTH) {
throw new InvalidInputError(
`Tweakey must be ${TWEAK_LENGTH} bytes, got ${tweakey.length}`,
);
}
tk[0] = Array.from(Array(TWEAK_LENGTH).keys()).map((i) => tweakey[i]);
// Pre-compute all round tweakeys (fix the array sharing bug)
const tk: Uint8Array[] = new Array(NB_ROUNDS + 1);
// Initialize first round tweakey
tk[0] = copyBytes(tweakey);
// Generate remaining round tweakeys
for (let i = 0; i < NB_ROUNDS - 1; i++) {
tk[i + 1] = Array.from(tk[i]);
tk[i + 1] = copyBytes(tk[i]);
// Apply permutation table
for (let j = 0; j < TWEAK_LENGTH; j++) {
tk[i + 1][j] = tk[i][j - (j % 16) + PT[j % 16]];
}
// Apply LFSR to TK2 and TK3 parts
for (let j = 0; j < 8; j++) {
tk[i + 1][j + 16] = LFSR_8_TK2[tk[i + 1][j + 16]];
tk[i + 1][j + 32] = LFSR_8_TK3[tk[i + 1][j + 32]];
}
}
let s = Array.from(Array(16).keys()).map((i) => plaintext[i]);
for (let i = 0; i < NB_ROUNDS; i++) {
for (let j = 0; j < 16; j++) {
// Initialize state with plaintext (fix inefficient array creation)
const s = copyBytes(plaintext);
// Perform encryption rounds
for (let round = 0; round < NB_ROUNDS; round++) {
// SubCells: apply S-box to all bytes
for (let j = 0; j < BLOCK_SIZE; j++) {
s[j] = S8[s[j]];
}
s[0] ^= C[i] & 0xf;
s[4] ^= (C[i] >> 4) & 0xf;
// AddConstants
s[0] ^= C[round] & 0xf;
s[4] ^= (C[round] >> 4) & 0xf;
s[8] ^= 0x2;
// AddRoundTweakey: XOR first 8 bytes of state with tweakey
for (let j = 0; j < 8; j++) {
s[j] ^= tk[i][j] ^ tk[i][j + 16] ^ tk[i][j + 32];
s[j] ^= tk[round][j] ^ tk[round][j + 16] ^ tk[round][j + 32];
}
s = [
s[0],
s[1],
s[2],
s[3],
s[7],
s[4],
s[5],
s[6],
s[10],
s[11],
s[8],
s[9],
s[13],
s[14],
s[15],
s[12],
];
// ShiftRows: apply shift row transformation
const temp = new Uint8Array(BLOCK_SIZE);
temp[0] = s[0];
temp[1] = s[1];
temp[2] = s[2];
temp[3] = s[3];
temp[4] = s[7];
temp[5] = s[4];
temp[6] = s[5];
temp[7] = s[6];
temp[8] = s[10];
temp[9] = s[11];
temp[10] = s[8];
temp[11] = s[9];
temp[12] = s[13];
temp[13] = s[14];
temp[14] = s[15];
temp[15] = s[12];
s.set(temp);
for (let j = 0; j < 4; j++) {
const tmp = Array.from(s);
s[j] = tmp[j] ^ tmp[8 + j] ^ tmp[12 + j];
s[4 + j] = tmp[j];
s[8 + j] = tmp[4 + j] ^ tmp[8 + j];
s[12 + j] = tmp[0 + j] ^ tmp[8 + j];
// MixColumns: apply linear transformation to columns
for (let col = 0; col < 4; col++) {
const c0 = s[col];
const c1 = s[4 + col];
const c2 = s[8 + col];
const c3 = s[12 + col];
s[col] = c0 ^ c2 ^ c3;
s[4 + col] = c0;
s[8 + col] = c1 ^ c2;
s[12 + col] = c0 ^ c2;
}
}
return Array.from(Array(16).keys()).map((i) => s[i]);
return s;
}

161
src/types.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* Type definitions and validation utilities for the Romulus-M cryptographic library.
*/
// Branded types for type safety
declare const KeyBrand: unique symbol;
declare const NonceBrand: unique symbol;
declare const BlockBrand: unique symbol;
declare const CounterBrand: unique symbol;
export type Key = Uint8Array & { readonly [KeyBrand]: true };
export type Nonce = Uint8Array & { readonly [NonceBrand]: true };
export type Block = Uint8Array & { readonly [BlockBrand]: true };
export type Counter = Uint8Array & { readonly [CounterBrand]: true };
// Constants
export const BLOCK_SIZE = 16;
export const KEY_SIZE = 16;
export const NONCE_SIZE = 16;
export const COUNTER_SIZE = 7;
export const AUTH_TAG_SIZE = 16;
// Custom error classes
export class RomulusError extends Error {
constructor(message: string) {
super(message);
this.name = "RomulusError";
}
}
export class InvalidInputError extends RomulusError {
constructor(message: string) {
super(`Invalid input: ${message}`);
this.name = "InvalidInputError";
}
}
export class InvalidKeyError extends InvalidInputError {
constructor(message: string = "Key must be exactly 16 bytes") {
super(`Key error: ${message}`);
this.name = "InvalidKeyError";
}
}
export class InvalidNonceError extends InvalidInputError {
constructor(message: string = "Nonce must be exactly 16 bytes") {
super(`Nonce error: ${message}`);
this.name = "InvalidNonceError";
}
}
// Validation functions
export function isValidByteArray(arr: Uint8Array): boolean {
return arr.every(
(byte) => byte >= 0 && byte <= 255 && Number.isInteger(byte),
);
}
export function validateKey(key: Uint8Array): Key {
if (!key || !(key instanceof Uint8Array)) {
throw new InvalidKeyError("Key must be a Uint8Array");
}
if (key.length !== KEY_SIZE) {
throw new InvalidKeyError(
`Key must be exactly ${KEY_SIZE} bytes, got ${key.length}`,
);
}
if (!isValidByteArray(key)) {
throw new InvalidKeyError("Key must contain only valid bytes (0-255)");
}
return key as Key;
}
export function validateNonce(nonce: Uint8Array): Nonce {
if (!nonce || !(nonce instanceof Uint8Array)) {
throw new InvalidNonceError("Nonce must be a Uint8Array");
}
if (nonce.length !== NONCE_SIZE) {
throw new InvalidNonceError(
`Nonce must be exactly ${NONCE_SIZE} bytes, got ${nonce.length}`,
);
}
if (!isValidByteArray(nonce)) {
throw new InvalidNonceError("Nonce must contain only valid bytes (0-255)");
}
return nonce as Nonce;
}
export function validateAssociatedData(data: Uint8Array): Uint8Array {
if (!data || !(data instanceof Uint8Array)) {
throw new InvalidInputError("Associated data must be a Uint8Array");
}
if (!isValidByteArray(data)) {
throw new InvalidInputError(
"Associated data must contain only valid bytes (0-255)",
);
}
return data;
}
export function validateMessage(message: Uint8Array): Uint8Array {
if (!message || !(message instanceof Uint8Array)) {
throw new InvalidInputError("Message must be a Uint8Array");
}
if (!isValidByteArray(message)) {
throw new InvalidInputError(
"Message must contain only valid bytes (0-255)",
);
}
return message;
}
export function validateCiphertext(ciphertext: Uint8Array): Uint8Array {
if (!ciphertext || !(ciphertext instanceof Uint8Array)) {
throw new InvalidInputError("Ciphertext must be a Uint8Array");
}
if (ciphertext.length < AUTH_TAG_SIZE) {
throw new InvalidInputError(
`Ciphertext must be at least ${AUTH_TAG_SIZE} bytes (authentication tag size)`,
);
}
if (!isValidByteArray(ciphertext)) {
throw new InvalidInputError(
"Ciphertext must contain only valid bytes (0-255)",
);
}
return ciphertext;
}
// Utility functions for efficient array operations
export function createByteArray(size: number, fill: number = 0): Uint8Array {
if (fill < 0 || fill > 255 || !Number.isInteger(fill)) {
throw new InvalidInputError(
`Fill value must be a valid byte (0-255), got ${fill}`,
);
}
return new Uint8Array(size).fill(fill);
}
export function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
if (a.length !== b.length) {
throw new InvalidInputError(
`Array lengths must match: ${a.length} !== ${b.length}`,
);
}
const result = new Uint8Array(a.length);
for (let i = 0; i < a.length; i++) {
result[i] = a[i] ^ b[i];
}
return result;
}
export function copyBytes(source: Uint8Array): Uint8Array {
return new Uint8Array(source);
}
// Result interface
export interface DecryptResult {
success: boolean;
plaintext: Uint8Array;
}

View File

@@ -5,13 +5,12 @@ import {
DecryptResult,
} from "../src/romulus-m";
function parseHexString(string: string): number[] {
const ret = [];
function parseHexString(string: string): Uint8Array {
const bytes: number[] = [];
for (let i = 0; i < string.length; i += 2) {
ret.push(parseInt(string.slice(i, i + 2), 16));
bytes.push(parseInt(string.slice(i, i + 2), 16));
}
return ret;
return new Uint8Array(bytes);
}
test.each(referenceTests)(
@@ -27,7 +26,7 @@ test.each(referenceTests)(
// Then
const expectedResult = parseHexString(ciphertext);
expect(result).toMatchObject(expectedResult);
expect(result).toEqual(expectedResult);
},
);
@@ -47,6 +46,6 @@ test.each(referenceTests)(
success: true,
plaintext: parseHexString(plaintext),
};
expect(result).toMatchObject(expectedResult);
expect(result).toEqual(expectedResult);
},
);

View File

@@ -1,18 +1,18 @@
import { cryptoAeadDecrypt, cryptoAeadEncrypt } from "../src/romulus-m";
function stringToArray(string: string): number[] {
function stringToUint8Array(string: string): Uint8Array {
const encoder = new TextEncoder();
return Array.from(encoder.encode(string));
return encoder.encode(string);
}
test("Encrypt a message with no associated data.", () => {
// Given
const message = stringToArray("Hello, World! This is a test message.");
const associatedData = stringToArray("");
const nonce = stringToArray(
const message = stringToUint8Array("Hello, World! This is a test message.");
const associatedData = stringToUint8Array("");
const nonce = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
const key = stringToArray(
const key = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
@@ -20,23 +20,23 @@ test("Encrypt a message with no associated data.", () => {
const result = cryptoAeadEncrypt(message, associatedData, nonce, key);
// Then
const expectedResult = [
const expectedResult = new Uint8Array([
85, 125, 23, 244, 73, 241, 140, 72, 166, 113, 114, 78, 239, 211, 84, 113,
222, 153, 207, 183, 69, 142, 174, 15, 38, 46, 112, 162, 229, 27, 136, 184,
163, 78, 132, 42, 107, 160, 74, 115, 28, 251, 209, 37, 48, 57, 184, 204,
199, 247, 93, 5, 208,
];
expect(result).toMatchObject(expectedResult);
]);
expect(result).toEqual(expectedResult);
});
test("Encrypt a message with associated data.", () => {
// Given
const message = stringToArray("Hello, World! This is a test message.");
const associatedData = stringToArray("Some associated data.");
const nonce = stringToArray(
const message = stringToUint8Array("Hello, World! This is a test message.");
const associatedData = stringToUint8Array("Some associated data.");
const nonce = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
const key = stringToArray(
const key = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
@@ -44,28 +44,28 @@ test("Encrypt a message with associated data.", () => {
const result = cryptoAeadEncrypt(message, associatedData, nonce, key);
// Then
const expectedResult = [
const expectedResult = new Uint8Array([
225, 53, 3, 212, 22, 112, 246, 194, 61, 171, 230, 187, 157, 102, 32, 76, 62,
65, 25, 202, 255, 201, 206, 49, 60, 58, 82, 216, 72, 116, 106, 129, 162,
142, 69, 40, 167, 88, 94, 195, 174, 217, 242, 149, 224, 125, 196, 237, 172,
165, 116, 119, 128,
];
expect(result).toMatchObject(expectedResult);
]);
expect(result).toEqual(expectedResult);
});
test("Decrypt a message with no associated data.", () => {
// Given
const ciphertext = [
const ciphertext = new Uint8Array([
85, 125, 23, 244, 73, 241, 140, 72, 166, 113, 114, 78, 239, 211, 84, 113,
222, 153, 207, 183, 69, 142, 174, 15, 38, 46, 112, 162, 229, 27, 136, 184,
163, 78, 132, 42, 107, 160, 74, 115, 28, 251, 209, 37, 48, 57, 184, 204,
199, 247, 93, 5, 208,
];
const associatedData = stringToArray("");
const nonce = stringToArray(
]);
const associatedData = stringToUint8Array("");
const nonce = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
const key = stringToArray(
const key = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
@@ -73,24 +73,26 @@ test("Decrypt a message with no associated data.", () => {
const result = cryptoAeadDecrypt(ciphertext, associatedData, nonce, key);
// Then
const expectedResult = stringToArray("Hello, World! This is a test message.");
const expectedResult = stringToUint8Array(
"Hello, World! This is a test message.",
);
expect(result.success).toBe(true);
expect(result.plaintext).toMatchObject(expectedResult);
expect(result.plaintext).toEqual(expectedResult);
});
test("Decrypt a message with associated data.", () => {
// Given
const ciphertext = [
const ciphertext = new Uint8Array([
225, 53, 3, 212, 22, 112, 246, 194, 61, 171, 230, 187, 157, 102, 32, 76, 62,
65, 25, 202, 255, 201, 206, 49, 60, 58, 82, 216, 72, 116, 106, 129, 162,
142, 69, 40, 167, 88, 94, 195, 174, 217, 242, 149, 224, 125, 196, 237, 172,
165, 116, 119, 128,
];
const associatedData = stringToArray("Some associated data.");
const nonce = stringToArray(
]);
const associatedData = stringToUint8Array("Some associated data.");
const nonce = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
const key = stringToArray(
const key = stringToUint8Array(
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
);
@@ -98,7 +100,9 @@ test("Decrypt a message with associated data.", () => {
const result = cryptoAeadDecrypt(ciphertext, associatedData, nonce, key);
// Then
const expectedResult = stringToArray("Hello, World! This is a test message.");
const expectedResult = stringToUint8Array(
"Hello, World! This is a test message.",
);
expect(result.success).toBe(true);
expect(result.plaintext).toMatchObject(expectedResult);
expect(result.plaintext).toEqual(expectedResult);
});

View File

@@ -1,8 +1,9 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "commonjs" /* Specify what module code is generated. */,
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "node16" /* Specify what module code is generated. */,
"moduleResolution": "node16" /* Specify how TypeScript looks up a file from a given module specifier. */,
"rootDir": "src" /* Specify the root folder within your source files. */,
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
"outDir": "dist" /* Specify an output folder for all emitted files. */,
@@ -10,6 +11,7 @@
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"declaration": true
},
"exclude": ["tests", "dist"]