Skip to content
Open
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
6 changes: 3 additions & 3 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,14 @@
"filename": "packages/cachekit/src/intents.test.ts",
"hashed_secret": "42c48ae0d1c6bc8d47b3b25fdcf2eb1156cd0c6a",
"is_verified": false,
"line_number": 206
"line_number": 249
},
{
"type": "Secret Keyword",
"filename": "packages/cachekit/src/intents.test.ts",
"hashed_secret": "18060b49185cba9a51b0d10290136007c3c8ab00",
"is_verified": false,
"line_number": 242
"line_number": 285
}
],
"packages/cachekit/test/protocol/cross-sdk-interop.protocol.test.ts": [
Expand Down Expand Up @@ -782,5 +782,5 @@
}
]
},
"generated_at": "2026-07-29T00:08:01Z"
"generated_at": "2026-08-07T14:22:29Z"
}
4 changes: 2 additions & 2 deletions packages/cachekit-core-ts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cachekit-core-ts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ crate-type = ["cdylib"]
[dependencies]
napi = { version = "3", features = ["napi6"] }
napi-derive = "3"
cachekit-core = { version = "0.4.0", features = ["encryption"] }
cachekit-core = { version = "0.5.0", features = ["encryption"] }

[build-dependencies]
napi-build = "2"
Expand Down
4 changes: 3 additions & 1 deletion packages/cachekit-core-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ If your platform isn't listed, the package will fail to load at runtime. Open an
Public exports (consumed by `@cachekit-io/cachekit`):

- `ByteStorage` — LZ4 compression + xxHash3-64 integrity envelope
- `TenantKeys` — HKDF-SHA256 per-tenant derived keys with `ZeroizeOnDrop`
- `TenantKeys` — HKDF-SHA256 per-tenant derived keys with `ZeroizeOnDrop`;
optionally holds a decrypt-only keyring (max 3 previous master keys) for
key-rotation grace windows — sequential decrypt attempts, current key first
- `deriveKey` — single-domain HKDF key derivation
- `encrypt` / `decrypt` — AES-256-GCM with AAD binding
- `version` — version string from the underlying Cargo crate
Expand Down
30 changes: 29 additions & 1 deletion packages/cachekit-core-ts/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,28 @@ export declare class TenantKeys {
* This matches Python's ZeroKnowledgeEncryptor.get_nonce_counter().
*/
getNonceCounter(): number
/**
* Number of keyring entries built at derivation (1 current key +
* decrypt-only previous keys).
*
* The SDK asserts this equals `1 + previousMasterKeys.length` right
* after deriveTenantKeys: a version-skewed native binary that ignored
* the keyring argument would otherwise silently decrypt with the
* current key only, turning every pre-rotation entry into a miss.
*/
keyringEntryCount(): number
}

/**
* Decrypt ciphertext using TenantKeys (keys stay in Rust memory).
*
* Uses the encryptor stored in TenantKeys for consistency.
*
* With previous master keys configured (rotation grace window), decryption
* runs cachekit-core's keyring loop: sequential attempts, current key
* first, identical AAD every attempt. Only an AES-GCM authentication
* failure advances to the next key; structural errors are terminal.
*
* # Arguments
* * `ciphertext` - Previously encrypted data
* * `aad` - Must match AAD used during encryption
Expand Down Expand Up @@ -145,17 +160,30 @@ export declare function deriveKey(masterKey: Uint8Array, domain: string, tenantS
* # Arguments
* * `master_key` - 32-byte master encryption key
* * `tenant_id` - Tenant identifier for key isolation
* * `previous_master_keys` - Optional decrypt-only previous master keys
* (max 3, each 32 bytes) retained during a key-rotation grace window.
* Reads attempt keys sequentially, current first, identical AAD per
* attempt (protocol `spec/encryption.md` → "Key Rotation (Keyring)").
* Writes always use `master_key`.
*
* # Returns
* TenantKeys object with derived keys (stays in Rust memory)
*
* # Errors
* Returns InvalidArg if any key has the wrong length, more than 3 previous
* keys are supplied (rejected, never truncated), or `master_key` also
* appears in `previous_master_keys` (forward-only rule: a key that ever
* encrypted is never re-promoted).
*
* # Example
* ```javascript
* const masterKey = Buffer.from(process.env.MASTER_KEY, 'hex');
* const tenantKeys = deriveTenantKeys(masterKey, 'tenant-123');
* // During a rotation grace window:
* const rotating = deriveTenantKeys(newKey, 'tenant-123', [oldKey]);
* ```
*/
export declare function deriveTenantKeys(masterKey: Uint8Array, tenantId: string): TenantKeys
export declare function deriveTenantKeys(masterKey: Uint8Array, tenantId: string, previousMasterKeys?: Array<Uint8Array> | undefined | null): TenantKeys

/**
* Encrypt plaintext using TenantKeys (keys stay in Rust memory).
Expand Down
101 changes: 93 additions & 8 deletions packages/cachekit-core-ts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use napi_derive::napi;
use cachekit_core::encryption::key_derivation::{
derive_tenant_keys as core_derive_tenant_keys, TenantKeys as CoreTenantKeys,
};
use cachekit_core::encryption::{derive_domain_key, ZeroKnowledgeEncryptor};
use cachekit_core::encryption::{derive_domain_key, Keyring, ZeroKnowledgeEncryptor};
use cachekit_core::ByteStorage as CoreByteStorage;

// Security limits to prevent DoS
Expand Down Expand Up @@ -233,6 +233,15 @@ pub struct TenantKeys {
/// Shared encryptor for consistent nonce tracking across operations.
/// Matches Python pattern where each EncryptionWrapper has ONE encryptor.
encryptor: ZeroKnowledgeEncryptor,
/// Decrypt keyring, present only during a rotation grace window
/// (previousMasterKeys configured). None keeps the single-key decrypt
/// path on the pre-derived tenant key. All keyring material zeroizes
/// on drop inside cachekit-core.
keyring: Option<Keyring>,
/// Keyring entries actually built (1 current + decrypt-only keys).
/// Exposed so the SDK can attest that rotation config survived the
/// FFI boundary — an older binding would silently drop the argument.
keyring_entries: u32,
}

#[napi]
Expand All @@ -257,6 +266,18 @@ impl TenantKeys {
pub fn get_nonce_counter(&self) -> i64 {
self.encryptor.get_nonce_counter() as i64
}

/// Number of keyring entries built at derivation (1 current key +
/// decrypt-only previous keys).
///
/// The SDK asserts this equals `1 + previousMasterKeys.length` right
/// after deriveTenantKeys: a version-skewed native binary that ignored
/// the keyring argument would otherwise silently decrypt with the
/// current key only, turning every pre-rotation entry into a miss.
#[napi]
pub fn keyring_entry_count(&self) -> u32 {
self.keyring_entries
}
}

/// Derive per-tenant keys using HKDF-SHA256.
Expand All @@ -269,17 +290,34 @@ impl TenantKeys {
/// # Arguments
/// * `master_key` - 32-byte master encryption key
/// * `tenant_id` - Tenant identifier for key isolation
/// * `previous_master_keys` - Optional decrypt-only previous master keys
/// (max 3, each 32 bytes) retained during a key-rotation grace window.
/// Reads attempt keys sequentially, current first, identical AAD per
/// attempt (protocol `spec/encryption.md` → "Key Rotation (Keyring)").
/// Writes always use `master_key`.
///
/// # Returns
/// TenantKeys object with derived keys (stays in Rust memory)
///
/// # Errors
/// Returns InvalidArg if any key has the wrong length, more than 3 previous
/// keys are supplied (rejected, never truncated), or `master_key` also
/// appears in `previous_master_keys` (forward-only rule: a key that ever
/// encrypted is never re-promoted).
///
/// # Example
/// ```javascript
/// const masterKey = Buffer.from(process.env.MASTER_KEY, 'hex');
/// const tenantKeys = deriveTenantKeys(masterKey, 'tenant-123');
/// // During a rotation grace window:
/// const rotating = deriveTenantKeys(newKey, 'tenant-123', [oldKey]);
/// ```
#[napi]
pub fn derive_tenant_keys(master_key: Uint8Array, tenant_id: String) -> Result<TenantKeys> {
pub fn derive_tenant_keys(
master_key: Uint8Array,
tenant_id: String,
previous_master_keys: Option<Vec<Uint8Array>>,
) -> Result<TenantKeys> {
if master_key.len() != 32 {
return Err(Error::new(
Status::InvalidArg,
Expand All @@ -294,13 +332,44 @@ pub fn derive_tenant_keys(master_key: Uint8Array, tenant_id: String) -> Result<T
return Err(Error::new(Status::InvalidArg, "tenant_id cannot be empty"));
}

let previous = previous_master_keys.unwrap_or_default();
for key in &previous {
if key.len() != 32 {
return Err(Error::new(
Status::InvalidArg,
format!(
"Previous master key must be exactly 32 bytes, got {}",
key.len()
),
));
}
}
// Keyring only exists during a rotation grace window; None keeps the
// pre-derived single-key decrypt path. Keyring::new re-validates the
// cap (3) and the current-key collision — config errors, hence InvalidArg.
let keyring = if previous.is_empty() {
None
} else {
let refs: Vec<&[u8]> = previous.iter().map(|k| k.as_ref()).collect();
Some(
Keyring::new(&master_key, &refs)
.map_err(|e| Error::new(Status::InvalidArg, e.to_string()))?,
)
};

let inner = core_derive_tenant_keys(&master_key, &tenant_id)
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;

let encryptor = ZeroKnowledgeEncryptor::new()
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;

Ok(TenantKeys { inner, encryptor })
let keyring_entries = 1 + previous.len() as u32;
Ok(TenantKeys {
inner,
encryptor,
keyring,
keyring_entries,
})
}

/// Encrypt plaintext using TenantKeys (keys stay in Rust memory).
Expand Down Expand Up @@ -335,6 +404,11 @@ pub fn encrypt_with_tenant_keys(
///
/// Uses the encryptor stored in TenantKeys for consistency.
///
/// With previous master keys configured (rotation grace window), decryption
/// runs cachekit-core's keyring loop: sequential attempts, current key
/// first, identical AAD every attempt. Only an AES-GCM authentication
/// failure advances to the next key; structural errors are terminal.
///
/// # Arguments
/// * `ciphertext` - Previously encrypted data
/// * `aad` - Must match AAD used during encryption
Expand All @@ -350,9 +424,20 @@ pub fn decrypt_with_tenant_keys(
) -> Result<Uint8Array> {
validate_decryption_input(ciphertext.len(), aad.len())?;

tenant_keys
.encryptor
.decrypt_aes_gcm(&ciphertext, &tenant_keys.inner.encryption_key, &aad)
.map(|plaintext| plaintext.into())
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))
match &tenant_keys.keyring {
Some(keyring) => keyring
.decrypt(
&tenant_keys.encryptor,
&ciphertext,
&tenant_keys.inner.tenant_id,
&aad,
)
.map(|plaintext| plaintext.into())
.map_err(|e| Error::new(Status::GenericFailure, e.to_string())),
None => tenant_keys
.encryptor
.decrypt_aes_gcm(&ciphertext, &tenant_keys.inner.encryption_key, &aad)
.map(|plaintext| plaintext.into())
.map_err(|e| Error::new(Status::GenericFailure, e.to_string())),
}
}
6 changes: 4 additions & 2 deletions packages/cachekit-core-wasm/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion packages/cachekit-core-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "=0.2.121"
cachekit-core = { version = "0.4.0", features = ["encryption"] }
# js-sys ships from the wasm-bindgen workspace and tracks its ABI; needed to
# accept Uint8Array[] (previous master keys) across the boundary. Exact-pinned
# to the release paired with wasm-bindgen 0.2.121 above.
js-sys = "=0.3.98"
cachekit-core = { version = "0.5.0", features = ["encryption"] }
# Wipes the owned previous-master-key staging buffers on drop (the NAPI crate
# borrows and never copies; this crate must copy out of JS memory). Already in
# the tree transitively via cachekit-core — same resolved version.
zeroize = "1"

# Standalone crate — never join an enclosing cargo workspace.
[workspace]
Expand Down
7 changes: 7 additions & 0 deletions packages/cachekit-core-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ const tenantKeys = deriveTenantKeys(masterKeyBytes, 'tenant-123');
const ciphertext = encryptWithTenantKeys(plaintext, aad, tenantKeys);
const plaintext2 = decryptWithTenantKeys(ciphertext, aad, tenantKeys);
tenantKeys.free(); // zeroizes key material deterministically

// Key-rotation grace window: up to 3 decrypt-only previous keys. Decrypt
// attempts keys sequentially (current first, identical AAD); encrypt always
// uses the current key.
const rotating = deriveTenantKeys(newKeyBytes, 'tenant-123', [oldKeyBytes]);
Comment thread
27Bslash6 marked this conversation as resolved.
const old = decryptWithTenantKeys(oldCiphertext, aad, rotating);
rotating.free(); // zeroize the whole keyring when the grace window ends
```

## Security notes
Expand Down
20 changes: 18 additions & 2 deletions packages/cachekit-core-wasm/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export declare class TenantKeys {
encryptionFingerprint(): Uint8Array;
/** Current nonce counter value — rotate before 2^32. */
getNonceCounter(): number;
/**
* Keyring entries built at derivation (1 current + decrypt-only previous
* keys) — SDK attestation that rotation config survived the boundary.
*/
keyringEntryCount(): number;
}

/** Derive a 32-byte domain key using HKDF-SHA256 (RFC 5869). */
Expand All @@ -48,8 +53,19 @@ export declare function deriveKey(
tenantSalt: string
): Uint8Array;

/** Derive per-tenant keys (encryption / authentication / cache_keys domains). */
export declare function deriveTenantKeys(masterKey: Uint8Array, tenantId: string): TenantKeys;
/**
* Derive per-tenant keys (encryption / authentication / cache_keys domains).
*
* `previousMasterKeys` (max 3, each 32 bytes) holds decrypt-only previous
* master keys retained during a key-rotation grace window: reads attempt
* keys sequentially, current first, identical AAD per attempt; writes always
* use `masterKey`.
*/
export declare function deriveTenantKeys(
masterKey: Uint8Array,
tenantId: string,
previousMasterKeys?: Uint8Array[] | null
): TenantKeys;

/** Encrypt with AES-256-GCM: [nonce(12)][ciphertext][auth_tag(16)]. */
export declare function encryptWithTenantKeys(
Expand Down
Loading
Loading