Modernise Romulus-M implementation and improve error handling
This commit is contained in:
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
|
package-lock.json
|
||||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal 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
11577
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -5,7 +5,8 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "ts-standard",
|
"lint": "prettier --check .",
|
||||||
|
"format": "prettier --write .",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"postinstall": "tsc"
|
"postinstall": "tsc"
|
||||||
@@ -17,12 +18,11 @@
|
|||||||
"author": "Butlersaurus",
|
"author": "Butlersaurus",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^27.4.0",
|
"@types/jest": "^30.0.0",
|
||||||
"jest": "^27.4.7",
|
"jest": "^30.1.3",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"ts-jest": "^27.1.3",
|
"ts-jest": "^29.4.1",
|
||||||
"ts-standard": "^11.0.0",
|
"typescript": "^5.9.2"
|
||||||
"typescript": "^4.5.5"
|
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"verbose": true,
|
"verbose": true,
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^10.0.0",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^11.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,5 +69,8 @@ export const C = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Romulus-M cryptography specification constants.
|
// 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;
|
export const COUNTER_LENGTH = 7;
|
||||||
|
|||||||
@@ -1,38 +1,60 @@
|
|||||||
import { cryptoAeadDecrypt } from "./romulus-m";
|
import { cryptoAeadDecrypt } from "./romulus-m";
|
||||||
|
import {
|
||||||
interface DecryptResult {
|
validateKey,
|
||||||
success: boolean;
|
validateAssociatedData,
|
||||||
plaintext: Uint8Array;
|
validateCiphertext,
|
||||||
}
|
DecryptResult,
|
||||||
|
NONCE_SIZE,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt a Romulus-M encrypted message.
|
* Decrypt a Romulus-M encrypted message with automatic nonce handling.
|
||||||
* N.B. Nonces are handled automatically by this function.
|
* The nonce is automatically extracted from the beginning of the input.
|
||||||
* @param buffer The nonce-prepended data to be decrypted.
|
* @param buffer The nonce-prepended ciphertext to decrypt (nonce || ciphertext || tag).
|
||||||
* @param associatedData The associated data.
|
* @param associatedData The associated data used for authentication.
|
||||||
* @param key The encryption key.
|
* @param key The 16-byte decryption key.
|
||||||
* @returns A decrypted DecryptResult object.
|
* @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(
|
export function decrypt(
|
||||||
buffer: Uint8Array,
|
buffer: Uint8Array,
|
||||||
associatedData: Uint8Array,
|
associatedData: Uint8Array,
|
||||||
key: Uint8Array,
|
key: Uint8Array,
|
||||||
): DecryptResult {
|
): DecryptResult {
|
||||||
// Split nonce from ciphertext.
|
// Validate inputs
|
||||||
const nonce = Array.from(buffer.slice(0, 16));
|
const validatedBuffer = validateCiphertext(buffer);
|
||||||
const ciphertext = Array.from(buffer.slice(16));
|
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
|
||||||
const result = cryptoAeadDecrypt(
|
if (validatedBuffer.length < NONCE_SIZE) {
|
||||||
ciphertext,
|
|
||||||
Array.from(associatedData),
|
|
||||||
nonce,
|
|
||||||
Array.from(key),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return the ciphertext and decryption status.
|
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
success: false,
|
||||||
plaintext: Uint8Array.from(result.plaintext),
|
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,
|
||||||
|
validatedAssociatedData,
|
||||||
|
nonce,
|
||||||
|
validatedKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,50 @@
|
|||||||
import { cryptoAeadEncrypt } from "./romulus-m";
|
import { cryptoAeadEncrypt } from "./romulus-m";
|
||||||
|
import { validateKey, validateAssociatedData, validateMessage } from "./types";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt a message using the Romulus-M encryption algorithm.
|
* Encrypt a message using the Romulus-M AEAD algorithm with auto-generated nonce.
|
||||||
* N.B. A nonce is automatically prepended to the ciphertext using this function.
|
* The nonce is automatically prepended to the ciphertext.
|
||||||
* @param message The plaintext message to encrypt.
|
* @param message The plaintext message to encrypt.
|
||||||
* @param associatedData The associated data.
|
* @param associatedData The associated data for authentication.
|
||||||
* @param key The encryption key.
|
* @param key The 16-byte encryption key.
|
||||||
* @returns The nonce-prepended ciphertext.
|
* @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(
|
export function encrypt(
|
||||||
message: Uint8Array,
|
message: Uint8Array,
|
||||||
associatedData: Uint8Array,
|
associatedData: Uint8Array,
|
||||||
key: Uint8Array,
|
key: Uint8Array,
|
||||||
): 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);
|
const nonce = new Uint8Array(16);
|
||||||
uuidv4({}, nonce);
|
uuidv4({}, nonce);
|
||||||
|
|
||||||
// Encrypt the data using the associated data, newly generated nonce and encryption key.
|
// Encrypt using the low-level AEAD function
|
||||||
const ciphertext = Uint8Array.from(
|
const ciphertext = cryptoAeadEncrypt(
|
||||||
cryptoAeadEncrypt(
|
validatedMessage,
|
||||||
Array.from(message),
|
validatedAssociatedData,
|
||||||
Array.from(associatedData),
|
nonce,
|
||||||
Array.from(nonce),
|
validatedKey,
|
||||||
Array.from(key),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return the nonce-prepended ciphertext.
|
// Return nonce prepended to ciphertext
|
||||||
return new Uint8Array([...nonce, ...ciphertext]);
|
const result = new Uint8Array(nonce.length + ciphertext.length);
|
||||||
|
result.set(nonce, 0);
|
||||||
|
result.set(ciphertext, nonce.length);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
757
src/romulus-m.ts
757
src/romulus-m.ts
@@ -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 { 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.
|
* Parse message into blocks of specified length.
|
||||||
* @param message The message to parse.
|
* @param message The message to parse as a Uint8Array.
|
||||||
* @param blockLength The block length.
|
* @param blockLength The block length in bytes (typically 16).
|
||||||
* @returns An array of blocks.
|
* @returns An array of message blocks, with empty block at index 0.
|
||||||
*/
|
*/
|
||||||
function parse(message: number[], blockLength: number): number[][] {
|
function parseMessage(message: Uint8Array, blockLength: number): Uint8Array[] {
|
||||||
// Keep track of position in message currently parsed into blocks.
|
const blocks: Uint8Array[] = [new Uint8Array(0)]; // Empty block at index 0
|
||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
// Slice message into blocks.
|
// Create full blocks
|
||||||
let ret: number[][] = [];
|
|
||||||
while (message.length - cursor >= blockLength) {
|
while (message.length - cursor >= blockLength) {
|
||||||
ret.push(message.slice(cursor, cursor + blockLength));
|
blocks.push(message.slice(cursor, cursor + blockLength));
|
||||||
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) {
|
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) {
|
if (message.length === 0) {
|
||||||
ret = [[]];
|
blocks.push(new Uint8Array(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert empty array at position 0.
|
return blocks;
|
||||||
ret.splice(0, 0, []);
|
|
||||||
return ret;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pads the byte length of message to padLength. The final byte (when padded) contains the original message length.
|
* Pads a message block to the specified length using Romulus-M padding scheme.
|
||||||
* @param message The message to pad.
|
* The final byte contains the original message length.
|
||||||
* @param padLength The length to pad the message to.
|
* @param message The message block to pad.
|
||||||
* @returns A padded block.
|
* @param padLength The target length after padding (typically 16).
|
||||||
|
* @returns A padded block of the specified length.
|
||||||
*/
|
*/
|
||||||
function pad(message: number[], padLength: number): number[] {
|
function padBlock(message: Uint8Array, padLength: number): Uint8Array {
|
||||||
// If there is no message, return a fully padded block.
|
if (padLength <= 0 || padLength > 255) {
|
||||||
if (message.length === 0) {
|
throw new InvalidInputError(`Invalid pad length: ${padLength}`);
|
||||||
return Array(16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if (message.length === padLength) {
|
||||||
return Array.from(message);
|
return copyBytes(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pad a copy of the message to padLength.
|
// If message is too long, this is an error
|
||||||
const ret = Array.from(message);
|
if (message.length > padLength) {
|
||||||
const requiredPadding = padLength - message.length - 1;
|
throw new InvalidInputError(
|
||||||
ret.push(...Array(requiredPadding));
|
`Message too long for padding: ${message.length} > ${padLength}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set the final byte of the padded blocked to the length of the original message.
|
// Create padded block
|
||||||
ret[padLength - 1] = message.length;
|
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.
|
* Generate the keystream from the internal state using the linear transformation G.
|
||||||
* @param state The state from which the key stream will be generated.
|
* Implements the transformation: G(x) = (x >> 1) ⊕ (x & 0x80) ⊕ ((x & 0x01) << 7)
|
||||||
* @returns The key stream.
|
* @param state The 16-byte internal state.
|
||||||
|
* @returns The 16-byte keystream.
|
||||||
*/
|
*/
|
||||||
function g(state: number[]): number[] {
|
function generateKeystream(state: Uint8Array): Uint8Array {
|
||||||
return state.map((x) => {
|
if (state.length !== BLOCK_SIZE) {
|
||||||
return (x >> 1) ^ (x & 0x80) ^ ((x & 0x01) << 7);
|
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.
|
* State update function for encryption: ρ(S, M) = (S ⊕ M, M ⊕ G(S))
|
||||||
* @param state The internal state, S.
|
* @param state The current 16-byte internal state S.
|
||||||
* @param mBlock An M block.
|
* @param messageBlock The 16-byte message block M.
|
||||||
* @returns [S', C] where S' = M ⊕ S and C = M ⊕ G(S)
|
* @returns [nextState, ciphertextBlock] where nextState = S ⊕ M and ciphertextBlock = M ⊕ G(S)
|
||||||
*/
|
*/
|
||||||
function rho(state: number[], mBlock: number[]): [number[], number[]] {
|
function stateUpdateEncrypt(
|
||||||
// G(S)
|
state: Uint8Array,
|
||||||
const gOfS = g(state);
|
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)
|
// C = M ⊕ G(S)
|
||||||
const cBlock = Array.from(Array(16).keys()).map((i) => mBlock[i] ^ gOfS[i]);
|
const ciphertextBlock = xorBytes(messageBlock, keystream);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// S' = S ⊕ M
|
// S' = S ⊕ M
|
||||||
const nextState = Array.from(Array(16).keys()).map(
|
const nextState = xorBytes(state, messageBlock);
|
||||||
(i) => state[i] ^ mBlock[i],
|
|
||||||
|
return [nextState, ciphertextBlock];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 stateUpdateDecrypt(
|
||||||
|
state: Uint8Array,
|
||||||
|
ciphertextBlock: Uint8Array,
|
||||||
|
): [Uint8Array, Uint8Array] {
|
||||||
|
if (state.length !== BLOCK_SIZE) {
|
||||||
|
throw new InvalidInputError(
|
||||||
|
`State must be ${BLOCK_SIZE} bytes, got ${state.length}`,
|
||||||
);
|
);
|
||||||
return [nextState, mBlock];
|
}
|
||||||
|
if (ciphertextBlock.length !== BLOCK_SIZE) {
|
||||||
|
throw new InvalidInputError(
|
||||||
|
`Ciphertext block must be ${BLOCK_SIZE} bytes, got ${ciphertextBlock.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Increments the 56 bit LFSR-based counter.
|
* Increments the 56-bit LFSR-based counter (pure function).
|
||||||
* @param counter The old counter.
|
* @param counter The current 7-byte counter state.
|
||||||
* @returns An incremented counter.
|
* @returns A new incremented counter (does not modify input).
|
||||||
*/
|
*/
|
||||||
function increaseCounter(counter: number[]): number[] {
|
function incrementCounter(counter: Uint8Array): Uint8Array {
|
||||||
const fb0 = counter[6] >> 7;
|
if (counter.length !== COUNTER_LENGTH) {
|
||||||
|
throw new InvalidInputError(
|
||||||
|
`Counter must be ${COUNTER_LENGTH} bytes, got ${counter.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
counter[6] = (counter[6] << 1) | (counter[5] >> 7);
|
// Create a copy to avoid mutation
|
||||||
counter[5] = (counter[5] << 1) | (counter[4] >> 7);
|
const newCounter = copyBytes(counter);
|
||||||
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);
|
|
||||||
|
|
||||||
|
// 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) {
|
if (fb0 === 1) {
|
||||||
counter[0] = (counter[0] << 1) ^ 0x95;
|
newCounter[0] = (newCounter[0] << 1) ^ 0x95;
|
||||||
} else {
|
} else {
|
||||||
counter[0] = counter[0] << 1;
|
newCounter[0] = newCounter[0] << 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return counter;
|
// Ensure all bytes remain in valid range
|
||||||
|
for (let i = 0; i < COUNTER_LENGTH; i++) {
|
||||||
|
newCounter[i] &= 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCounter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a reset counter.
|
* Creates a reset counter initialized to the starting state.
|
||||||
* @returns A reset counter.
|
* @returns A new 7-byte counter initialized to [1, 0, 0, 0, 0, 0, 0].
|
||||||
*/
|
*/
|
||||||
function resetCounter(): number[] {
|
function createResetCounter(): Uint8Array {
|
||||||
const counter = Array(COUNTER_LENGTH);
|
const counter = createByteArray(COUNTER_LENGTH, 0);
|
||||||
counter[0] = 1;
|
counter[0] = 1;
|
||||||
return counter;
|
return counter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the domain separation.
|
* Calculate the domain separation value based on message and associated data characteristics.
|
||||||
* @param combinedData The parsed and concatenated message and associated data,
|
* @param combinedBlocks The parsed and concatenated message and associated data blocks.
|
||||||
* @param parsedMessageLength The length of the parsed message.
|
* @param messageBlockCount The number of message blocks.
|
||||||
* @param parsedAssociatedDataLength The length of the parsed associated data.
|
* @param associatedDataBlockCount The number of associated data blocks.
|
||||||
|
* @returns The domain separation value for the final encryption step.
|
||||||
*/
|
*/
|
||||||
function calculateDomainSeparation(
|
function calculateDomainSeparation(
|
||||||
combinedData: number[][],
|
combinedBlocks: Uint8Array[],
|
||||||
parsedMessageLength: number,
|
messageBlockCount: number,
|
||||||
parsedAssociatedDataLength: number,
|
associatedDataBlockCount: number,
|
||||||
): number {
|
): number {
|
||||||
let domainSeparation = 16;
|
let domainSeparation = 16;
|
||||||
|
|
||||||
if (combinedData[parsedAssociatedDataLength].length < 16) {
|
// Check if the final associated data block is incomplete
|
||||||
domainSeparation = domainSeparation ^ 2;
|
if (combinedBlocks[associatedDataBlockCount].length < BLOCK_SIZE) {
|
||||||
|
domainSeparation ^= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the final message block is incomplete
|
||||||
if (
|
if (
|
||||||
combinedData[parsedAssociatedDataLength + parsedMessageLength].length < 16
|
combinedBlocks[associatedDataBlockCount + messageBlockCount].length <
|
||||||
|
BLOCK_SIZE
|
||||||
) {
|
) {
|
||||||
domainSeparation = domainSeparation ^ 1;
|
domainSeparation ^= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedAssociatedDataLength % 2 === 0) {
|
// Check if associated data block count is even
|
||||||
domainSeparation = domainSeparation ^ 8;
|
if (associatedDataBlockCount % 2 === 0) {
|
||||||
|
domainSeparation ^= 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedMessageLength % 2 === 0) {
|
// Check if message block count is even
|
||||||
domainSeparation = domainSeparation ^ 4;
|
if (messageBlockCount % 2 === 0) {
|
||||||
|
domainSeparation ^= 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
return domainSeparation;
|
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.
|
* See https://romulusae.github.io/romulus/docs/Romulusv1.3.pdf for more information.
|
||||||
* @param message The message to encrypt.
|
* @param message The message to encrypt as a byte array.
|
||||||
* @param associatedData The associated data to encrypt.
|
* @param associatedData The associated data as a byte array.
|
||||||
* @param nonce A 128 bit nonce.
|
* @param nonce A 16-byte nonce.
|
||||||
* @param key A 128 bit encryption key.
|
* @param key A 16-byte encryption key.
|
||||||
* @returns The encrypted ciphertext.
|
* @returns The encrypted ciphertext with authentication tag appended.
|
||||||
|
* @throws {InvalidInputError} If inputs have incorrect lengths or invalid values.
|
||||||
*/
|
*/
|
||||||
export function cryptoAeadEncrypt(
|
export function cryptoAeadEncrypt(
|
||||||
message: number[],
|
message: Uint8Array,
|
||||||
associatedData: number[],
|
associatedData: Uint8Array,
|
||||||
nonce: number[],
|
nonce: Uint8Array,
|
||||||
key: number[],
|
key: Uint8Array,
|
||||||
): number[] {
|
): Uint8Array {
|
||||||
// Buffer for ciphertext.
|
// Validate all inputs
|
||||||
const ciphertext = [];
|
const validatedMessage = validateMessage(message);
|
||||||
|
const validatedAssociatedData = validateAssociatedData(associatedData);
|
||||||
|
const validatedNonce = validateNonce(nonce);
|
||||||
|
const validatedKey = validateKey(key);
|
||||||
|
|
||||||
// Reset state and counter.
|
try {
|
||||||
let state = Array(16);
|
// Initialize state and counter
|
||||||
let counter = resetCounter();
|
let state = createByteArray(BLOCK_SIZE, 0);
|
||||||
|
let counter = createResetCounter();
|
||||||
|
|
||||||
// Carve message and associated data into blocks.
|
// Parse message and associated data into blocks
|
||||||
const messageBlocks = parse(message, 16);
|
const messageBlocks = parseMessage(validatedMessage, BLOCK_SIZE);
|
||||||
const messageBlockCount = messageBlocks.length - 1;
|
const messageBlockCount = messageBlocks.length - 1;
|
||||||
|
|
||||||
const associatedDataBlocks = parse(associatedData, 16);
|
const associatedDataBlocks = parseMessage(
|
||||||
|
validatedAssociatedData,
|
||||||
|
BLOCK_SIZE,
|
||||||
|
);
|
||||||
const associatedDataBlockCount = associatedDataBlocks.length - 1;
|
const associatedDataBlockCount = associatedDataBlocks.length - 1;
|
||||||
|
|
||||||
// Concatenate the message and associated data blocks, excluding each array's first element.
|
// Concatenate blocks (excluding empty block at index 0)
|
||||||
const combinedDataBlocks = associatedDataBlocks
|
const combinedBlocks = [
|
||||||
.slice(1)
|
createByteArray(0), // Empty block at index 0
|
||||||
.concat(messageBlocks.slice(1));
|
...associatedDataBlocks.slice(1),
|
||||||
|
...messageBlocks.slice(1),
|
||||||
|
];
|
||||||
|
|
||||||
// Insert empty array at position 0.
|
// Calculate domain separation for final encryption stage
|
||||||
combinedDataBlocks.splice(0, 0, []);
|
|
||||||
|
|
||||||
// Calculate domain separation for final encryption stage.
|
|
||||||
const domainSeparation = calculateDomainSeparation(
|
const domainSeparation = calculateDomainSeparation(
|
||||||
combinedDataBlocks,
|
combinedBlocks,
|
||||||
messageBlockCount,
|
messageBlockCount,
|
||||||
associatedDataBlockCount,
|
associatedDataBlockCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pad combined data.
|
// Pad the final blocks
|
||||||
combinedDataBlocks[associatedDataBlockCount] = pad(
|
if (associatedDataBlockCount > 0) {
|
||||||
combinedDataBlocks[associatedDataBlockCount],
|
combinedBlocks[associatedDataBlockCount] = padBlock(
|
||||||
16,
|
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 = skinnyEncrypt(
|
||||||
state,
|
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) {
|
if (associatedDataBlockCount % 2 === messageBlockCount % 2) {
|
||||||
[state] = rho(state, Array(16));
|
[state] = stateUpdateEncrypt(state, emptyBlock);
|
||||||
} else {
|
} else {
|
||||||
[state] = rho(
|
[state] = stateUpdateEncrypt(
|
||||||
state,
|
state,
|
||||||
combinedDataBlocks[associatedDataBlockCount + messageBlockCount],
|
combinedBlocks[associatedDataBlockCount + messageBlockCount],
|
||||||
);
|
);
|
||||||
counter = increaseCounter(counter);
|
counter = incrementCounter(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate authentication tag.
|
// Generate authentication tag
|
||||||
const [, authenticationTag] = rho(
|
const encryptedState = skinnyEncrypt(
|
||||||
skinnyEncrypt(state, tweakeyEncode(counter, domainSeparation, nonce, key)),
|
state,
|
||||||
Array(16),
|
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;
|
return authenticationTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = Array.from(authenticationTag);
|
// Encrypt the message blocks
|
||||||
counter = resetCounter();
|
state = copyBytes(authenticationTag);
|
||||||
|
counter = createResetCounter();
|
||||||
|
|
||||||
// Encrypt the message.
|
const ciphertext: number[] = [];
|
||||||
const originalFinalMessageBlockLength =
|
const originalFinalBlockLength = messageBlocks[messageBlockCount].length;
|
||||||
messageBlocks[messageBlockCount].length;
|
messageBlocks[messageBlockCount] = padBlock(
|
||||||
messageBlocks[messageBlockCount] = pad(messageBlocks[messageBlockCount], 16);
|
messageBlocks[messageBlockCount],
|
||||||
|
BLOCK_SIZE,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 1; i < ciphertextBlockCount + 1; i++) {
|
for (let i = 1; i <= messageBlockCount; i++) {
|
||||||
state = skinnyEncrypt(state, tweakeyEncode(counter, 4, nonce, key));
|
state = skinnyEncrypt(
|
||||||
|
state,
|
||||||
|
tweakeyEncode(counter, 4, validatedNonce, validatedKey),
|
||||||
|
);
|
||||||
|
|
||||||
let mBlock;
|
const [nextState, ciphertextBlock] = stateUpdateEncrypt(
|
||||||
[state, mBlock] = inverseRoh(state, ciphertextBlocks[i]);
|
state,
|
||||||
counter = increaseCounter(counter);
|
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) {
|
if (i < ciphertextBlockCount) {
|
||||||
cleartext.push(...mBlock);
|
plaintextBytes.push(...Array.from(messageBlock));
|
||||||
} else {
|
} else {
|
||||||
cleartext.push(...mBlock.slice(0, finalCiphertextBlockLength));
|
plaintextBytes.push(
|
||||||
|
...Array.from(messageBlock.slice(0, finalBlockLength)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
state = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset state and counter.
|
// Verify authentication by recomputing the tag
|
||||||
state = Array(16);
|
const recoveredPlaintext = new Uint8Array(plaintextBytes);
|
||||||
counter = resetCounter();
|
|
||||||
|
|
||||||
// Carve the message and associated data into blocks.
|
// Initialize for verification
|
||||||
const messageBlocks = parse(cleartext, 16);
|
let state = createByteArray(BLOCK_SIZE, 0);
|
||||||
const messageBlockLength = messageBlocks.length - 1;
|
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;
|
const associatedDataBlockCount = associatedDataBlocks.length - 1;
|
||||||
|
|
||||||
// Concatenate the message and associated data blocks, excluding each array's first element.
|
// Combine blocks for verification
|
||||||
const combinedData = associatedDataBlocks
|
const combinedBlocks = [
|
||||||
.slice(1)
|
createByteArray(0),
|
||||||
.concat(messageBlocks.slice(1));
|
...associatedDataBlocks.slice(1),
|
||||||
|
...messageBlocks.slice(1),
|
||||||
|
];
|
||||||
|
|
||||||
// Insert empty array at position 0.
|
// Calculate domain separation
|
||||||
combinedData.splice(0, 0, []);
|
|
||||||
|
|
||||||
// Calculate domain separation for final decryption stage.
|
|
||||||
const domainSeparation = calculateDomainSeparation(
|
const domainSeparation = calculateDomainSeparation(
|
||||||
combinedData,
|
combinedBlocks,
|
||||||
messageBlockLength,
|
messageBlockCount,
|
||||||
associatedDataBlockCount,
|
associatedDataBlockCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pad combined data.
|
// Pad final blocks
|
||||||
combinedData[associatedDataBlockCount] = pad(
|
if (associatedDataBlockCount > 0) {
|
||||||
combinedData[associatedDataBlockCount],
|
combinedBlocks[associatedDataBlockCount] = padBlock(
|
||||||
16,
|
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 = skinnyEncrypt(
|
||||||
state,
|
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) {
|
// Handle final block
|
||||||
[state] = rho(state, Array(16));
|
const emptyBlock = createByteArray(BLOCK_SIZE, 0);
|
||||||
|
if (associatedDataBlockCount % 2 === messageBlockCount % 2) {
|
||||||
|
[state] = stateUpdateEncrypt(state, emptyBlock);
|
||||||
} else {
|
} else {
|
||||||
[state] = rho(
|
[state] = stateUpdateEncrypt(
|
||||||
state,
|
state,
|
||||||
combinedData[associatedDataBlockCount + messageBlockLength],
|
combinedBlocks[associatedDataBlockCount + messageBlockCount],
|
||||||
);
|
);
|
||||||
counter = increaseCounter(counter);
|
counter = incrementCounter(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate authentication tag.
|
// Compute authentication tag
|
||||||
const [, computedTag] = rho(
|
const encryptedState = skinnyEncrypt(
|
||||||
skinnyEncrypt(state, tweakeyEncode(counter, domainSeparation, nonce, key)),
|
state,
|
||||||
Array(16),
|
tweakeyEncode(counter, domainSeparation, validatedNonce, validatedKey),
|
||||||
);
|
);
|
||||||
|
const [, computedTag] = stateUpdateEncrypt(encryptedState, emptyBlock);
|
||||||
|
|
||||||
// Validate authentication tag.
|
// Constant-time comparison of authentication tags
|
||||||
let compare = 0;
|
let compare = 0;
|
||||||
for (let i = 0; i < 16; i++) {
|
for (let i = 0; i < AUTH_TAG_SIZE; i++) {
|
||||||
compare |= authenticationTag[i] ^ computedTag[i];
|
compare |= authenticationTag[i] ^ computedTag[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compare !== 0) {
|
if (compare !== 0) {
|
||||||
// Authentication failed.
|
// Authentication failed
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
plaintext: [],
|
plaintext: new Uint8Array(),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Decrypted successfully.
|
// Decryption successful
|
||||||
return {
|
return {
|
||||||
success: true,
|
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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,93 +7,170 @@ import {
|
|||||||
LFSR_8_TK3,
|
LFSR_8_TK3,
|
||||||
S8,
|
S8,
|
||||||
C,
|
C,
|
||||||
|
BLOCK_SIZE,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
import {
|
||||||
|
validateKey,
|
||||||
|
validateNonce,
|
||||||
|
InvalidInputError,
|
||||||
|
copyBytes,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a tweakey based on the specified domain separation, nonce, key and current counter state.
|
* Create a tweakey based on the specified domain separation, nonce, key and current counter state.
|
||||||
* @param counter The counter.
|
* @param counter The counter state as a byte array.
|
||||||
* @param domainSeparation The domain separation.
|
* @param domainSeparation The domain separation value.
|
||||||
* @param nonce The nonce.
|
* @param nonce The 16-byte nonce.
|
||||||
* @param key The encryption key.
|
* @param key The 16-byte encryption key.
|
||||||
* @returns The tweakey.
|
* @returns The tweakey as a byte array.
|
||||||
|
* @throws {InvalidInputError} If inputs have incorrect lengths or invalid values.
|
||||||
*/
|
*/
|
||||||
export function tweakeyEncode(
|
export function tweakeyEncode(
|
||||||
counter: number[],
|
counter: Uint8Array,
|
||||||
domainSeparation: number,
|
domainSeparation: number,
|
||||||
nonce: number[],
|
nonce: Uint8Array,
|
||||||
key: number[],
|
key: Uint8Array,
|
||||||
): number[] {
|
): Uint8Array {
|
||||||
return counter.concat([domainSeparation ^ MEMBER_MASK], Array(8), nonce, key);
|
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.
|
* Perform SKINNY-128/384+ block cipher encryption.
|
||||||
* @param plaintext The plaintext to encrypt.
|
* @param plaintext The 16-byte plaintext block to encrypt.
|
||||||
* @param tweakey The tweakey to use for encryption.
|
* @param tweakey The 48-byte tweakey for encryption.
|
||||||
* @returns The ciphertext.
|
* @returns The 16-byte ciphertext block.
|
||||||
|
* @throws {InvalidInputError} If inputs have incorrect lengths.
|
||||||
*/
|
*/
|
||||||
export function skinnyEncrypt(
|
export function skinnyEncrypt(
|
||||||
plaintext: number[],
|
plaintext: Uint8Array,
|
||||||
tweakey: number[],
|
tweakey: Uint8Array,
|
||||||
): number[] {
|
): Uint8Array {
|
||||||
const tk = Array(NB_ROUNDS + 1).fill(Array(TWEAK_LENGTH).fill(0));
|
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++) {
|
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++) {
|
for (let j = 0; j < TWEAK_LENGTH; j++) {
|
||||||
tk[i + 1][j] = tk[i][j - (j % 16) + PT[j % 16]];
|
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++) {
|
for (let j = 0; j < 8; j++) {
|
||||||
tk[i + 1][j + 16] = LFSR_8_TK2[tk[i + 1][j + 16]];
|
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]];
|
tk[i + 1][j + 32] = LFSR_8_TK3[tk[i + 1][j + 32]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let s = Array.from(Array(16).keys()).map((i) => plaintext[i]);
|
// Initialize state with plaintext (fix inefficient array creation)
|
||||||
for (let i = 0; i < NB_ROUNDS; i++) {
|
const s = copyBytes(plaintext);
|
||||||
for (let j = 0; j < 16; j++) {
|
|
||||||
|
// 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[j] = S8[s[j]];
|
||||||
}
|
}
|
||||||
|
|
||||||
s[0] ^= C[i] & 0xf;
|
// AddConstants
|
||||||
s[4] ^= (C[i] >> 4) & 0xf;
|
s[0] ^= C[round] & 0xf;
|
||||||
|
s[4] ^= (C[round] >> 4) & 0xf;
|
||||||
s[8] ^= 0x2;
|
s[8] ^= 0x2;
|
||||||
|
|
||||||
|
// AddRoundTweakey: XOR first 8 bytes of state with tweakey
|
||||||
for (let j = 0; j < 8; j++) {
|
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 = [
|
// ShiftRows: apply shift row transformation
|
||||||
s[0],
|
const temp = new Uint8Array(BLOCK_SIZE);
|
||||||
s[1],
|
temp[0] = s[0];
|
||||||
s[2],
|
temp[1] = s[1];
|
||||||
s[3],
|
temp[2] = s[2];
|
||||||
s[7],
|
temp[3] = s[3];
|
||||||
s[4],
|
temp[4] = s[7];
|
||||||
s[5],
|
temp[5] = s[4];
|
||||||
s[6],
|
temp[6] = s[5];
|
||||||
s[10],
|
temp[7] = s[6];
|
||||||
s[11],
|
temp[8] = s[10];
|
||||||
s[8],
|
temp[9] = s[11];
|
||||||
s[9],
|
temp[10] = s[8];
|
||||||
s[13],
|
temp[11] = s[9];
|
||||||
s[14],
|
temp[12] = s[13];
|
||||||
s[15],
|
temp[13] = s[14];
|
||||||
s[12],
|
temp[14] = s[15];
|
||||||
];
|
temp[15] = s[12];
|
||||||
|
s.set(temp);
|
||||||
|
|
||||||
for (let j = 0; j < 4; j++) {
|
// MixColumns: apply linear transformation to columns
|
||||||
const tmp = Array.from(s);
|
for (let col = 0; col < 4; col++) {
|
||||||
s[j] = tmp[j] ^ tmp[8 + j] ^ tmp[12 + j];
|
const c0 = s[col];
|
||||||
s[4 + j] = tmp[j];
|
const c1 = s[4 + col];
|
||||||
s[8 + j] = tmp[4 + j] ^ tmp[8 + j];
|
const c2 = s[8 + col];
|
||||||
s[12 + j] = tmp[0 + j] ^ tmp[8 + j];
|
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
161
src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,13 +5,12 @@ import {
|
|||||||
DecryptResult,
|
DecryptResult,
|
||||||
} from "../src/romulus-m";
|
} from "../src/romulus-m";
|
||||||
|
|
||||||
function parseHexString(string: string): number[] {
|
function parseHexString(string: string): Uint8Array {
|
||||||
const ret = [];
|
const bytes: number[] = [];
|
||||||
for (let i = 0; i < string.length; i += 2) {
|
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 new Uint8Array(bytes);
|
||||||
return ret;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.each(referenceTests)(
|
test.each(referenceTests)(
|
||||||
@@ -27,7 +26,7 @@ test.each(referenceTests)(
|
|||||||
|
|
||||||
// Then
|
// Then
|
||||||
const expectedResult = parseHexString(ciphertext);
|
const expectedResult = parseHexString(ciphertext);
|
||||||
expect(result).toMatchObject(expectedResult);
|
expect(result).toEqual(expectedResult);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,6 +46,6 @@ test.each(referenceTests)(
|
|||||||
success: true,
|
success: true,
|
||||||
plaintext: parseHexString(plaintext),
|
plaintext: parseHexString(plaintext),
|
||||||
};
|
};
|
||||||
expect(result).toMatchObject(expectedResult);
|
expect(result).toEqual(expectedResult);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { cryptoAeadDecrypt, cryptoAeadEncrypt } from "../src/romulus-m";
|
import { cryptoAeadDecrypt, cryptoAeadEncrypt } from "../src/romulus-m";
|
||||||
|
|
||||||
function stringToArray(string: string): number[] {
|
function stringToUint8Array(string: string): Uint8Array {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
return Array.from(encoder.encode(string));
|
return encoder.encode(string);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Encrypt a message with no associated data.", () => {
|
test("Encrypt a message with no associated data.", () => {
|
||||||
// Given
|
// Given
|
||||||
const message = stringToArray("Hello, World! This is a test message.");
|
const message = stringToUint8Array("Hello, World! This is a test message.");
|
||||||
const associatedData = stringToArray("");
|
const associatedData = stringToUint8Array("");
|
||||||
const nonce = stringToArray(
|
const nonce = stringToUint8Array(
|
||||||
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
|
"\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",
|
"\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);
|
const result = cryptoAeadEncrypt(message, associatedData, nonce, key);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
const expectedResult = [
|
const expectedResult = new Uint8Array([
|
||||||
85, 125, 23, 244, 73, 241, 140, 72, 166, 113, 114, 78, 239, 211, 84, 113,
|
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,
|
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,
|
163, 78, 132, 42, 107, 160, 74, 115, 28, 251, 209, 37, 48, 57, 184, 204,
|
||||||
199, 247, 93, 5, 208,
|
199, 247, 93, 5, 208,
|
||||||
];
|
]);
|
||||||
expect(result).toMatchObject(expectedResult);
|
expect(result).toEqual(expectedResult);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Encrypt a message with associated data.", () => {
|
test("Encrypt a message with associated data.", () => {
|
||||||
// Given
|
// Given
|
||||||
const message = stringToArray("Hello, World! This is a test message.");
|
const message = stringToUint8Array("Hello, World! This is a test message.");
|
||||||
const associatedData = stringToArray("Some associated data.");
|
const associatedData = stringToUint8Array("Some associated data.");
|
||||||
const nonce = stringToArray(
|
const nonce = stringToUint8Array(
|
||||||
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
|
"\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",
|
"\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);
|
const result = cryptoAeadEncrypt(message, associatedData, nonce, key);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
const expectedResult = [
|
const expectedResult = new Uint8Array([
|
||||||
225, 53, 3, 212, 22, 112, 246, 194, 61, 171, 230, 187, 157, 102, 32, 76, 62,
|
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,
|
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,
|
142, 69, 40, 167, 88, 94, 195, 174, 217, 242, 149, 224, 125, 196, 237, 172,
|
||||||
165, 116, 119, 128,
|
165, 116, 119, 128,
|
||||||
];
|
]);
|
||||||
expect(result).toMatchObject(expectedResult);
|
expect(result).toEqual(expectedResult);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Decrypt a message with no associated data.", () => {
|
test("Decrypt a message with no associated data.", () => {
|
||||||
// Given
|
// Given
|
||||||
const ciphertext = [
|
const ciphertext = new Uint8Array([
|
||||||
85, 125, 23, 244, 73, 241, 140, 72, 166, 113, 114, 78, 239, 211, 84, 113,
|
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,
|
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,
|
163, 78, 132, 42, 107, 160, 74, 115, 28, 251, 209, 37, 48, 57, 184, 204,
|
||||||
199, 247, 93, 5, 208,
|
199, 247, 93, 5, 208,
|
||||||
];
|
]);
|
||||||
const associatedData = stringToArray("");
|
const associatedData = stringToUint8Array("");
|
||||||
const nonce = stringToArray(
|
const nonce = stringToUint8Array(
|
||||||
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
|
"\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",
|
"\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);
|
const result = cryptoAeadDecrypt(ciphertext, associatedData, nonce, key);
|
||||||
|
|
||||||
// Then
|
// 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.success).toBe(true);
|
||||||
expect(result.plaintext).toMatchObject(expectedResult);
|
expect(result.plaintext).toEqual(expectedResult);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Decrypt a message with associated data.", () => {
|
test("Decrypt a message with associated data.", () => {
|
||||||
// Given
|
// Given
|
||||||
const ciphertext = [
|
const ciphertext = new Uint8Array([
|
||||||
225, 53, 3, 212, 22, 112, 246, 194, 61, 171, 230, 187, 157, 102, 32, 76, 62,
|
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,
|
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,
|
142, 69, 40, 167, 88, 94, 195, 174, 217, 242, 149, 224, 125, 196, 237, 172,
|
||||||
165, 116, 119, 128,
|
165, 116, 119, 128,
|
||||||
];
|
]);
|
||||||
const associatedData = stringToArray("Some associated data.");
|
const associatedData = stringToUint8Array("Some associated data.");
|
||||||
const nonce = stringToArray(
|
const nonce = stringToUint8Array(
|
||||||
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
|
"\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",
|
"\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);
|
const result = cryptoAeadDecrypt(ciphertext, associatedData, nonce, key);
|
||||||
|
|
||||||
// Then
|
// 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.success).toBe(true);
|
||||||
expect(result.plaintext).toMatchObject(expectedResult);
|
expect(result.plaintext).toEqual(expectedResult);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
/* 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. */,
|
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||||
"module": "commonjs" /* Specify what module code is generated. */,
|
"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. */,
|
"rootDir": "src" /* Specify the root folder within your source files. */,
|
||||||
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
|
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
|
||||||
"outDir": "dist" /* Specify an output folder for all emitted files. */,
|
"outDir": "dist" /* Specify an output folder for all emitted files. */,
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||||
"strict": true /* Enable all strict type-checking options. */,
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
"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
|
"declaration": true
|
||||||
},
|
},
|
||||||
"exclude": ["tests", "dist"]
|
"exclude": ["tests", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user