Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
37 changes: 37 additions & 0 deletions packages/cachekit/src/encryption/manager-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,43 @@ describe('EncryptionManagerCore', () => {
manager.dispose();
expect(freed.length).toBe(1);
});

/** Reads the tenant_id component (component 1) back out of a built AAD buffer. */
function decodeAadTenantId(aad: Uint8Array): string {
const view = new DataView(aad.buffer, aad.byteOffset, aad.byteLength);
const len = view.getUint32(1, false);
return new TextDecoder().decode(aad.slice(5, 5 + len));
}

it('LAB-4668: HKDF derivation and AAD construction resolve the identical tenant_id', async () => {
// Regression for the mismatch: manager-core.ts used `tenantId ?? 'default'`
// for HKDF but `tenantId ?? ''` for AAD, so an unset tenant derived keys
// for "default" while binding AAD to "" — ciphertext could never
// authenticate against a conformant reader.
const { bindings } = mockBindings();
const manager = new TestManager(async () => bindings);

await manager.encrypt(new Uint8Array([1]), 'ns:k');

const [, derivedTenantId] = vi.mocked(bindings.deriveTenantKeys).mock.calls[0];
const [, aad] = vi.mocked(bindings.encryptWithTenantKeys).mock.calls[0];
expect(derivedTenantId).toBe('default');
expect(decodeAadTenantId(aad)).toBe('default');
manager.dispose();
});

it('LAB-4668: with a configured tenantId, HKDF and AAD both use it', async () => {
const { bindings } = mockBindings();
const manager = new TestManager(async () => bindings, 'acme-corp');

await manager.encrypt(new Uint8Array([1]), 'ns:k');

const [, derivedTenantId] = vi.mocked(bindings.deriveTenantKeys).mock.calls[0];
const [, aad] = vi.mocked(bindings.encryptWithTenantKeys).mock.calls[0];
expect(derivedTenantId).toBe('acme-corp');
expect(decodeAadTenantId(aad)).toBe('acme-corp');
manager.dispose();
});
});

describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => {
Expand Down
10 changes: 6 additions & 4 deletions packages/cachekit/src/encryption/manager-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export class EncryptionManagerCore {
private native: EncryptionBindings | null = null;
private disposed = false;
private initPromise: Promise<void> | null = null;
/** Single source of truth for tenant_id — read by both HKDF derivation and AAD construction. */
private readonly effectiveTenantId: string;
// Note: Nonce tracking is done in Rust via getNonceCounter().
// The Rust encryptor throws NonceCounterExhausted when the limit is reached.

Expand Down Expand Up @@ -130,11 +132,12 @@ export class EncryptionManagerCore {
*/
constructor(
private readonly masterKey: string,
private readonly tenantId: string | undefined,
tenantId: string | undefined,
private readonly loadBindings: () => Promise<EncryptionBindings>,
private readonly previousMasterKeys: readonly string[] = []
) {
validateKeyHex(masterKey, 'Master key');
this.effectiveTenantId = tenantId ?? 'default';
Comment thread
27Bslash6 marked this conversation as resolved.
if (previousMasterKeys.length > MAX_PREVIOUS_MASTER_KEYS) {
throw new ConfigurationError(
`previousMasterKeys accepts at most ${MAX_PREVIOUS_MASTER_KEYS} keys, got ${previousMasterKeys.length} — drop retired keys explicitly, the list is never truncated`
Expand Down Expand Up @@ -203,12 +206,11 @@ export class EncryptionManagerCore {
// byte buffers are wiped in the finally below as soon as the binding has
// consumed them — on error paths too (the hex config strings remain on
// the manager for init retry, per the documented masterKey pattern).
const effectiveTenantId = this.tenantId ?? 'default';
let tenantKeys: EncryptionTenantKeys;
try {
tenantKeys = this.native.deriveTenantKeys(
masterKeyBytes,
effectiveTenantId,
this.effectiveTenantId,
previousKeyBytes.length > 0 ? previousKeyBytes : undefined
);
} finally {
Expand Down Expand Up @@ -372,7 +374,7 @@ export class EncryptionManagerCore {

// Encode all components as UTF-8 (matches Python exactly)
const components = [
encoder.encode(this.tenantId ?? ''),
encoder.encode(this.effectiveTenantId),
Comment thread
27Bslash6 marked this conversation as resolved.
encoder.encode(cacheKey),
encoder.encode(format),
encoder.encode(compressed ? 'True' : 'False'), // Python str(bool) format
Expand Down
19 changes: 12 additions & 7 deletions packages/cachekit/src/encryption/manager.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,17 +299,22 @@ describe('Nonce Exhaustion Error Path (Gap 2 - lines 125-137)', () => {
});

describe('EncryptionManager with empty tenant ID (Edge Case)', () => {
it('uses default tenant ID when none provided', async () => {
// No tenant ID provided - should use 'default' internally
const manager = new EncryptionManager(VALID_HEX_KEY);
it('an unset tenant ID is interchangeable with an explicit "default"', async () => {
// LAB-4668: a same-instance round-trip cannot catch an HKDF/AAD tenant
// mismatch — both sides share it. Crossing to an explicit 'default'
// manager (what py/rs write) exercises the real AAD in both directions.
const unset = new EncryptionManager(VALID_HEX_KEY);
const explicit = new EncryptionManager(VALID_HEX_KEY, 'default');
const data = new Uint8Array([1, 2, 3]);

try {
const encrypted = await manager.encrypt(data, 'test-key');
const decrypted = await manager.decrypt(encrypted, 'test-key');
expect(Array.from(decrypted)).toEqual(Array.from(data));
const fromUnset = await unset.encrypt(data, 'test-key');
expect(Array.from(await explicit.decrypt(fromUnset, 'test-key'))).toEqual([1, 2, 3]);
const fromExplicit = await explicit.encrypt(data, 'test-key');
expect(Array.from(await unset.decrypt(fromExplicit, 'test-key'))).toEqual([1, 2, 3]);
} finally {
manager.dispose();
unset.dispose();
explicit.dispose();
}
});
});
Expand Down
Loading