Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"dependencies": {
"@ethersproject/abi": "^5.8.0",
"@ethersproject/providers": "^5.8.0",
"@noble/ciphers": "^1.2.1",
"@noble/hashes": "^1.7.1",
"@nucypher/nucypher-contracts": "^0.23.0",
"@nucypher/nucypher-core": "^0.14.5",
"axios": "^1.8.4",
Expand Down
66 changes: 66 additions & 0 deletions packages/shared/src/encryption/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Errors during encryption.
*/
export class EncryptionError extends Error {
readonly code: EncryptionErrorCode;

constructor(code: EncryptionErrorCode) {
let message: string;
switch (code) {
case EncryptionErrorCode.PlaintextTooLarge:
message = 'Plaintext is too large to encrypt';
break;
default:
message = 'Unknown encryption error';
}
super(message);
this.name = 'EncryptionError';
this.code = code;
}
}

/**
* Error codes for encryption operations.
*/
export enum EncryptionErrorCode {
PlaintextTooLarge = 'PlaintextTooLarge',
}

/**
* Errors during decryption.
*/
export class DecryptionError extends Error {
readonly code: DecryptionErrorCode;
readonly details?: string;

constructor(code: DecryptionErrorCode, details?: string) {
let message: string;
switch (code) {
case DecryptionErrorCode.CiphertextTooShort:
message = 'The ciphertext must include the nonce';
break;
case DecryptionErrorCode.AuthenticationFailed:
message = 'Decryption of ciphertext failed: ' +
'either someone tampered with the ciphertext or ' +
'you are using an incorrect decryption key.';
break;
case DecryptionErrorCode.DeserializationFailed:
message = details ? `deserialization failed: ${details}` : 'deserialization failed';
break;
default:
message = 'Unknown decryption error';
}
super(message);
this.name = 'DecryptionError';
this.code = code;
}
}

/**
* Error codes for decryption operations.
*/
export enum DecryptionErrorCode {
CiphertextTooShort = 'CiphertextTooShort',
AuthenticationFailed = 'AuthenticationFailed',
DeserializationFailed = 'DeserializationFailed',
}
2 changes: 2 additions & 0 deletions packages/shared/src/encryption/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './errors';
export * from './shared-secret';
47 changes: 47 additions & 0 deletions packages/shared/src/encryption/shared-secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { chacha20poly1305 } from '@noble/ciphers/chacha';
import { randomBytes } from '@noble/hashes/utils';

import {
DecryptionError,
DecryptionErrorCode,
EncryptionError,
EncryptionErrorCode,
} from './errors';

export const CHACHA20POLY1305_NONCE_LENGTH = 12; // bytes

export function encryptWithSharedSecret(
sharedSecret: Uint8Array,
plaintext: Uint8Array,
): Uint8Array {
const nonce = randomBytes(CHACHA20POLY1305_NONCE_LENGTH); // Generate a 12-byte nonce
const cipher = chacha20poly1305(sharedSecret, nonce); // Use an object with key
try {
const ciphertext = cipher.encrypt(plaintext);
const result = new Uint8Array(
CHACHA20POLY1305_NONCE_LENGTH + ciphertext.length,
);
result.set(nonce);
result.set(ciphertext, CHACHA20POLY1305_NONCE_LENGTH);
return result;
} catch (error) {
throw new EncryptionError(EncryptionErrorCode.PlaintextTooLarge);
}
}

export function decryptWithSharedSecret(
sharedSecret: Uint8Array,
ciphertext: Uint8Array,
): Uint8Array {
if (ciphertext.length < CHACHA20POLY1305_NONCE_LENGTH) {
throw new DecryptionError(DecryptionErrorCode.CiphertextTooShort);
}
const nonce = ciphertext.slice(0, CHACHA20POLY1305_NONCE_LENGTH);
const encryptedData = ciphertext.slice(CHACHA20POLY1305_NONCE_LENGTH);
const cipher = chacha20poly1305(sharedSecret, nonce); // Use an object with key
try {
return cipher.decrypt(encryptedData);
} catch (error) {
throw new DecryptionError(DecryptionErrorCode.AuthenticationFailed);
}
}
50 changes: 50 additions & 0 deletions packages/shared/test/fixtures/shared-secret-vectors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"test_vectors": [
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed the fixed nonce, specific shared secret - does it make sense to also have more random values in the test vector? Or is that something planned for later in the process?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The values are randomly generated at the Rust code and fed as constants to the compatibility tests. For values that are randomly generated in this project, there is the unit test in the other file.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The values are randomly generated at the Rust code and fed as constants to the compatibility tests.

Perhaps I'm misunderstanding. The values for the nonce, shared secret don't seem that "random"...? In one case the nonce is just 0s or 1s and the shared secret is just ints 0-31...?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, sorry my bad. I forgot that I updated this in Rust. Yes, the values are fixed. But since the values does not have special numerical meanings, in relation to the algorithm, they are still counted as random (or you may say simi-random).
However, we can generate different values every time. But it will make debugging harder in case of an issue.

Hey @cygnusv, Could you please, provide your input if testing with new random values is needed for this cryptography validation.

Copy link
Copy Markdown
Member

@derekpierre derekpierre Apr 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the values are fixed.

Just to clarify: the test vectors should still contain specific values, but I expected each vector in the generated file to use different values for completeness. These values would originally have been generated randomly or pseudo-randomly.

For example, to generate the test vector file, you could use a fixed seed. Then, using that seed, you can deterministically generate pseudo-random nonces, shared secrets, etc., rather than reusing the same values or incrementing bytes only sequentially.

The seed approach ensures that the same file is generated every time (due to the fixed seed), but each test vector contains distinct, realistic inputs like unique nonces and shared secrets. This avoids test vectors that only differ trivially (e.g. same nonce in every case), which could miss edge cases or failures.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, in all cases, the typescript tests should run on any provided data in this json 😄 . So, whatever will be generated from nucypher-core, should be usable here regardless of what the values are.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. I'm referring to the strategy used in the code that generated the test vectors from nucypher-core.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would like continue this the conversation about the generated test vectors at nucypher/nucypher-core#105.
Thanks,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for being a bit late to the discussion. I've been following along while working on nucypher/nucypher-core#105 and I changed my mind a few times during this process. The production and consumption of test vectors are a bit more complicated than expected due to how our internal protocol objects work, since in some cases serialization/deserialization is more difficult or not possible without adding code. I'm still iterating a proper method for producing test vectors in my PR, but on the PRs in taco-web we should strive to consume test vectors without resorting to duplicated implementations.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With respect to the specific issue discussed here, the test vector ideally should be generated in a 100% deterministic way (i.e. using a seed). However, there's nothing wrong about adding some hard-coded, obviously not random examples, like [1, 1, 1, .....1].

{
"id": "vector1",
"description": "Fixed nonce encryption with known plaintext",
"shared_secret": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31
],
"plaintext": "This is a test message",
"fixed_nonce": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"expected_ciphertext": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 76, 208, 43, 66, 141, 143, 213, 241,
114, 65, 40, 4, 220, 55, 110, 74, 157, 194, 128, 148, 134, 200, 181, 72,
212, 7, 218, 216, 247, 94, 232, 77, 109, 158, 146, 164, 44, 74
]
},
{
"id": "vector2",
"description": "Fixed nonce encryption with alternative values",
"shared_secret": [
31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14,
13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0
],
"plaintext": "",
"fixed_nonce": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
"expected_ciphertext": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 135, 38, 197, 242, 213, 184, 24,
114, 168, 100, 147, 239, 82, 50, 170, 161
]
},
{
"id": "vector3",
"description": "Rust-generated ciphertext for TypeScript compatibility check",
"shared_secret": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31
],
"plaintext": "This is a message encrypted by the Rust implementation",
"expected_ciphertext": [
100, 196, 241, 204, 119, 208, 40, 31, 29, 138, 199, 108, 168, 89, 32,
208, 157, 93, 80, 94, 60, 106, 168, 38, 62, 206, 143, 135, 56, 12, 142,
15, 156, 9, 227, 26, 97, 154, 204, 11, 179, 200, 3, 180, 203, 200, 221,
190, 122, 118, 154, 147, 180, 170, 1, 23, 168, 86, 226, 78, 224, 176,
218, 159, 127, 47, 55, 28, 193, 231, 42, 92, 118, 112, 139, 150, 73,
205, 155, 90, 66, 172
]
}
]
}
171 changes: 171 additions & 0 deletions packages/shared/test/shared-secret-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest';
import { DecryptionError, DecryptionErrorCode } from '../src/encryption/errors';
import testVectorsFile from './fixtures/shared-secret-vectors.json';

// Mock the randomBytes function from @noble/hashes/utils
// This must be done before importing any modules that use it
vi.mock('@noble/hashes/utils', () => {
return {
// Return a function that will be replaced in each test
randomBytes: vi.fn().mockImplementation((bytesLength?: number) => {
return new Uint8Array(bytesLength || 32);
}),
};
});

// Now import modules that depend on the mocked randomBytes
import { randomBytes } from '@noble/hashes/utils';
import {
decryptWithSharedSecret,
encryptWithSharedSecret,
} from '../src/encryption/shared-secret';

/**
* This test file contains fixed test vectors to verify compatibility
* between the TypeScript implementation and the original Rust implementation.
*
* Test vectors are loaded from a JSON file which makes it easier to:
* 1. Share vectors between different implementations (Rust, TypeScript)
* 2. Update or add vectors without changing test logic
* 3. Maintain expected inputs/outputs separately from test code
*/
describe('Shared Secret Compatibility Tests', () => {
// Parse test vectors from the loaded JSON file
const { test_vectors } = testVectorsFile as { test_vectors: any[] };

// Helper to convert test vector from JSON to usable objects
const prepareTestVector = (vector: any) => {
return {
id: vector.id,
description: vector.description,
sharedSecret: new Uint8Array(vector.shared_secret),
plaintext: vector.plaintext
? new TextEncoder().encode(vector.plaintext)
: new Uint8Array(0),
fixedNonce: vector.fixed_nonce
? new Uint8Array(vector.fixed_nonce)
: undefined,
expectedCiphertext: Buffer.from(vector.expected_ciphertext, 'hex'), // new Uint8Array(vector.expected_ciphertext)
};
};

// Parse all test vectors into usable formats
const testVectors = test_vectors.map(prepareTestVector);

// Set up a fixed nonce for testing
function setupFixedNonceMock(fixedNonce: Uint8Array) {
// Get the mocked function
const mockedRandomBytes = vi.mocked(randomBytes);

// Clear previous implementations
mockedRandomBytes.mockReset();

// Configure it to return our fixed nonce
mockedRandomBytes.mockImplementation((bytesLength?: number) => {
// If length is undefined or matches our nonce length, return the fixed nonce
// Otherwise return a new array of the requested length
if (bytesLength === undefined || bytesLength === fixedNonce.length) {
return fixedNonce;
} else {
return new Uint8Array(bytesLength);
}
});

// Return a cleanup function
return () => {
mockedRandomBytes.mockReset();
};
}

describe('Vector-based compatibility tests', () => {
it.each(testVectors)(
'should correctly encrypt and decrypt test vector $id: $description',
(vector) => {
const { sharedSecret, plaintext, fixedNonce, expectedCiphertext } =
vector;

if (fixedNonce && sharedSecret) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases where this is not true? i.e. do test vectors ever not contain a fixedNonce or sharedSecret value? I'm wondering if we need this if statement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Sorry, it should be only checking for fixedNonce. The code is now updated.]
And this is just to give the confidence that even if the test vectors did not contain the nonce (so the encryption of data would not be possible, and the decryption is the only available), the plaintext could be retrieved by decrypting the cipher text.

However, if this case is unnecessary to check, the nucypher/nucypher-core#105 would not generate it and I will remove this if from here 😄 .

// Set up our mock to return the fixed nonce
const cleanupMock = setupFixedNonceMock(fixedNonce);

// Encrypt with fixed nonce
const ciphertext = encryptWithSharedSecret(sharedSecret, plaintext);
// Always restore the original implementation
cleanupMock();

// Check against expected ciphertext from the JSON file
const ciphertextHex = Buffer.from(ciphertext).toString('hex');
const expectedHex = Buffer.from(expectedCiphertext).toString('hex');
expect(ciphertextHex).toEqual(expectedHex);
}

// Verify decryption works correctly
const decrypted = decryptWithSharedSecret(
sharedSecret,
expectedCiphertext,
);

expect(Buffer.from(decrypted).toString()).toEqual(
Buffer.from(plaintext).toString(),
);
},
);
});

describe('Error handling compatibility tests', () => {
// Using the first test vector's shared secret for error tests
const { sharedSecret, plaintext } = testVectors[0];

it('should handle ciphertext too short error in the same way as Rust', () => {
const tooShortCiphertext = new Uint8Array([0x01, 0x02, 0x03]); // Less than nonce length

try {
decryptWithSharedSecret(sharedSecret, tooShortCiphertext);
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DecryptionError);
const decryptionError = error as DecryptionError;
expect(decryptionError.code).toBe(
DecryptionErrorCode.CiphertextTooShort,
);
expect(decryptionError.message).toContain(
'The ciphertext must include the nonce',
);
}
});

it('should handle tampered ciphertext in the same way as Rust', () => {
// We need to setup a fixed nonce for the encryption
const mockNonce = new Uint8Array(12).fill(0); // 12 zeros as nonce

// Use the existing helper to setup the mock
const cleanupMock = setupFixedNonceMock(mockNonce);

try {
// Encrypt normally
const ciphertext = encryptWithSharedSecret(sharedSecret, plaintext);
Comment thread
Muhammad-Altabba marked this conversation as resolved.
Outdated

// Tamper with the ciphertext by changing one byte
const tamperedCiphertext = new Uint8Array(ciphertext);
tamperedCiphertext[tamperedCiphertext.length - 1] ^= 0x01; // Flip one bit in the last byte

try {
decryptWithSharedSecret(sharedSecret, tamperedCiphertext);
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DecryptionError);
const decryptionError = error as DecryptionError;
expect(decryptionError.code).toBe(
DecryptionErrorCode.AuthenticationFailed,
);
expect(decryptionError.message).toContain(
'Decryption of ciphertext failed',
);
}
} finally {
// Always restore the original implementation
cleanupMock();
}
});
});
});
Loading