Summary
AesGcmCrypto in seismic-viem writes a numeric encryption nonce into only the low 64 bits of the 96-bit nonce field, silently discarding the top 32 bits. Two values that differ only above bit 63 therefore produce the same AES-GCM nonce, which is a hard failure of the mode when they are used under one key.
The Python client encodes the same value across all 12 bytes, so the two clients disagree.
The canonical width is 96 bits
The node's transaction type defines the field as U96 (seismic-alloy/crates/consensus/src/transaction/seismic.rs):
pub encryption_nonce: U96,
pub fn get_rand_encryption_nonce() -> U96 {
let mut bytes = [0u8; 12]; // 96 bits = 12 bytes
rng.fill_bytes(&mut bytes);
U96::from_be_bytes(bytes)
}
/// Convert U96 to Nonce
pub fn get_enclave_nonce(self) -> Nonce {
self.encryption_nonce.to_be_bytes().into()
}
The Python client matches that, and rejects anything wider (int.to_bytes raises OverflowError):
def _nonce_to_bytes(nonce: int | EncryptionNonce) -> bytes:
"""Convert a nonce to exactly 12 bytes."""
if isinstance(nonce, int):
return nonce.to_bytes(12, "big")
return bytes(nonce)
seismic-viem does not (clients/ts/packages/seismic-viem/src/crypto/aes.ts):
private readonly U64_SIZE = 8 // Size of u64 in bytes
/**
* Creates a nonce from a u64 number, matching Rust's implementation
* @param num - The number to convert (will be treated as u64)
*/
private numberToNonce(num: bigint | number): Uint8Array {
let value = BigInt(num)
const nonceBuffer = new Uint8Array(this.NONCE_LENGTH)
// Write the u64 value in big-endian format to the last 8 bytes
for (let i = this.NONCE_LENGTH - 1; i >= this.NONCE_LENGTH - this.U64_SIZE; i--) {
nonceBuffer[i] = Number(value & 0xffn)
value = value >> 8n
}
// First 4 bytes remain as zeros
return nonceBuffer
}
The loop stops at index 4, so bits 64–95 are dropped. The doc comment's claim that this matches Rust is what's actually wrong — Rust's field is U96, not u64.
Reproduction
const c = new AesGcmCrypto(key)
c.createNonce(5n) // 0x000000000000000000000005
c.createNonce(2n ** 64n + 5n) // 0x000000000000000000000005 <-- same nonce
c.createNonce(0n) // 0x000000000000000000000000
c.createNonce(2n ** 64n) // 0x000000000000000000000000 <-- same nonce
Against the Python client and the node's U96 encoding, 2^64 + 5 is 0x000000010000000000000005.
Impact
AesGcmCrypto, its createNonce, and its encrypt/decrypt are public API (EncryptionNonce = number | bigint | Hex, so the numeric path is a supported input). Consequences:
- Nonce reuse. Any caller deriving nonces from a counter or identifier that can exceed 2^64 gets collisions with no error. Reusing a nonce under one AES-GCM key leaks the XOR of the plaintexts and can expose the authentication key.
- Cross-client divergence. The same numeric nonce yields different bytes in
seismic-viem and seismic-web3 for any value ≥ 2^64, so ciphertext produced by one client can't be reproduced or verified by the other.
- Silent truncation. Nothing is thrown; the value is just quietly narrowed. The Python client raises
OverflowError for the same input.
The main transaction path carries encryptionNonce as Hex and is unaffected — the exposure is via the numeric nonce API, which is exactly what the security-params docs point callers at for "deterministic tests and debugging" and "reproducing an exact encrypted or signed request shape".
Suggested fix
Write the value across all 12 bytes, and reject out-of-range input instead of truncating:
if (value < 0n) throw new Error(...)
if (value >= 1n << 96n) throw new Error(...)
for (let i = this.NONCE_LENGTH - 1; i >= 0; i--) { ... }
Values below 2^64 encode exactly as before, so this is backward compatible for every currently-representable nonce.
I have this implemented with unit tests (including reference vectors cross-checked against the Python client) and will open a PR.
Summary
AesGcmCryptoinseismic-viemwrites a numeric encryption nonce into only the low 64 bits of the 96-bit nonce field, silently discarding the top 32 bits. Two values that differ only above bit 63 therefore produce the same AES-GCM nonce, which is a hard failure of the mode when they are used under one key.The Python client encodes the same value across all 12 bytes, so the two clients disagree.
The canonical width is 96 bits
The node's transaction type defines the field as
U96(seismic-alloy/crates/consensus/src/transaction/seismic.rs):The Python client matches that, and rejects anything wider (
int.to_bytesraisesOverflowError):seismic-viemdoes not (clients/ts/packages/seismic-viem/src/crypto/aes.ts):The loop stops at index 4, so bits 64–95 are dropped. The doc comment's claim that this matches Rust is what's actually wrong — Rust's field is
U96, notu64.Reproduction
Against the Python client and the node's
U96encoding,2^64 + 5is0x000000010000000000000005.Impact
AesGcmCrypto, itscreateNonce, and itsencrypt/decryptare public API (EncryptionNonce = number | bigint | Hex, so the numeric path is a supported input). Consequences:seismic-viemandseismic-web3for any value ≥ 2^64, so ciphertext produced by one client can't be reproduced or verified by the other.OverflowErrorfor the same input.The main transaction path carries
encryptionNonceasHexand is unaffected — the exposure is via the numeric nonce API, which is exactly what the security-params docs point callers at for "deterministic tests and debugging" and "reproducing an exact encrypted or signed request shape".Suggested fix
Write the value across all 12 bytes, and reject out-of-range input instead of truncating:
Values below 2^64 encode exactly as before, so this is backward compatible for every currently-representable nonce.
I have this implemented with unit tests (including reference vectors cross-checked against the Python client) and will open a PR.