diff --git a/README.md b/README.md index adea7e0..300ff53 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ JS/TS runtime-agnostic, quantum-safe, and agile cryptography toolkit with a decl ## Current algorithms - Identifier: `SHA-384` or 48 random bytes, encoded as a fixed-length base64url string -- Cipher messaging: `AES-CTR-256` +- Cipher messaging: `AES-GCM-256` - Message authentication: `HMAC-SHA-256` - Key agreement: `X25519-ML-KEM-768` - Digital signatures: `Ed25519-ML-DSA-65` @@ -172,8 +172,8 @@ const verified = await Cryptographic.digitalSignature.verify( ## Security notes -- `AES-CTR` does not provide integrity on its own -- authenticate or sign ciphertexts at the protocol layer +- `AES-GCM` provides confidentiality and message integrity for each ciphertext +- authenticate peers and session setup at the protocol layer - never reuse a `(key, iv)` pair - treat JWKs and derived key material as secrets - sign a canonical byte representation, not loosely structured objects @@ -182,7 +182,7 @@ const verified = await Cryptographic.digitalSignature.verify( Latest local `npm run test` run on `2026-04-17` with Node `v22.14.0 (win32 x64)`: -- `63/63` tests passed +- `65/65` tests passed - Coverage passed at `100%` for statements, branches, functions, and lines - End-to-end runtime suites all passed in: - Node ESM @@ -207,25 +207,25 @@ Latest local `npm run bench` run on `2026-04-17` with Node `v22.14.0 (win32 x64) | Benchmark | ops | ms | ms/op | ops/sec | | ----------------------------------- | --: | ------: | ------: | --------: | -| `identifier.generate` | 100 | 3.76 | 0.0376 | 26617.69 | -| `identifier.derive` | 100 | 32.60 | 0.3260 | 3067.77 | -| `identifier.validate` | 100 | 0.43 | 0.0043 | 232883.09 | -| `cipherMessage.generateKey` | 100 | 43.36 | 0.4336 | 2306.01 | -| `cipherMessage.deriveKey` | 100 | 75.53 | 0.7553 | 1324.01 | -| `cipherMessage.encrypt` | 100 | 38.18 | 0.3818 | 2619.10 | -| `cipherMessage.decrypt` | 100 | 30.86 | 0.3086 | 3240.51 | -| `messageAuthentication.generateKey` | 100 | 42.06 | 0.4206 | 2377.45 | -| `messageAuthentication.deriveKey` | 100 | 67.14 | 0.6714 | 1489.35 | -| `messageAuthentication.sign` | 100 | 26.91 | 0.2691 | 3716.46 | -| `messageAuthentication.verify` | 100 | 28.26 | 0.2826 | 3538.58 | -| `keyAgreement.generateKeypair` | 100 | 877.66 | 8.7766 | 113.94 | -| `keyAgreement.deriveKeypair` | 100 | 728.01 | 7.2801 | 137.36 | -| `keyAgreement.encapsulate` | 100 | 1649.16 | 16.4916 | 60.64 | -| `keyAgreement.decapsulate` | 100 | 1093.07 | 10.9307 | 91.49 | -| `digitalSignature.generateKeypair` | 100 | 849.80 | 8.4980 | 117.67 | -| `digitalSignature.deriveKeypair` | 100 | 714.64 | 7.1464 | 139.93 | -| `digitalSignature.sign` | 100 | 3293.13 | 32.9313 | 30.37 | -| `digitalSignature.verify` | 100 | 1195.09 | 11.9509 | 83.68 | +| `identifier.generate` | 100 | 2.91 | 0.0291 | 34389.08 | +| `identifier.derive` | 100 | 34.97 | 0.3497 | 2859.53 | +| `identifier.validate` | 100 | 0.41 | 0.0041 | 243961.94 | +| `cipherMessage.generateKey` | 100 | 48.77 | 0.4877 | 2050.38 | +| `cipherMessage.deriveKey` | 100 | 67.84 | 0.6784 | 1474.02 | +| `cipherMessage.encrypt` | 100 | 36.80 | 0.3680 | 2717.03 | +| `cipherMessage.decrypt` | 100 | 36.02 | 0.3602 | 2776.57 | +| `messageAuthentication.generateKey` | 100 | 44.84 | 0.4484 | 2230.24 | +| `messageAuthentication.deriveKey` | 100 | 75.64 | 0.7564 | 1322.07 | +| `messageAuthentication.sign` | 100 | 29.09 | 0.2909 | 3437.31 | +| `messageAuthentication.verify` | 100 | 25.33 | 0.2533 | 3947.69 | +| `keyAgreement.generateKeypair` | 100 | 827.02 | 8.2702 | 120.92 | +| `keyAgreement.deriveKeypair` | 100 | 842.11 | 8.4211 | 118.75 | +| `keyAgreement.encapsulate` | 100 | 1669.17 | 16.6917 | 59.91 | +| `keyAgreement.decapsulate` | 100 | 1240.95 | 12.4095 | 80.58 | +| `digitalSignature.generateKeypair` | 100 | 808.57 | 8.0857 | 123.67 | +| `digitalSignature.deriveKeypair` | 100 | 612.56 | 6.1256 | 163.25 | +| `digitalSignature.sign` | 100 | 3478.93 | 34.7893 | 28.74 | +| `digitalSignature.verify` | 100 | 2574.33 | 25.7433 | 38.85 | Results vary by machine and Node version. diff --git a/src/CipherMessage/.core/CipherKeyHarness/class.ts b/src/CipherMessage/.core/CipherKeyHarness/class.ts index 7bbc5f3..813dc4e 100644 --- a/src/CipherMessage/.core/CipherKeyHarness/class.ts +++ b/src/CipherMessage/.core/CipherKeyHarness/class.ts @@ -84,11 +84,20 @@ export class CipherKeyHarness { const params: CipherParams = { iv: cipherMessage.iv, } - const plaintext = await crypto.subtle.decrypt( - getParamsByAlgCode(this.algCode, params), - key, - cipherMessage.ciphertext - ) + let plaintext: ArrayBuffer + try { + plaintext = await crypto.subtle.decrypt( + getParamsByAlgCode(this.algCode, params), + key, + cipherMessage.ciphertext + ) + } catch (error) { + if (error instanceof CryptosuiteError) throw error + throw new CryptosuiteError( + 'CIPHER_ARTIFACT_INVALID', + 'CipherKeyHarness.decrypt: failed to decrypt or authenticate the cipher message.' + ) + } return new Uint8Array(plaintext) } } diff --git a/src/CipherMessage/.core/helpers/createParamsByAlgCode/index.ts b/src/CipherMessage/.core/helpers/createParamsByAlgCode/index.ts index 23f69d4..ca5cbd6 100644 --- a/src/CipherMessage/.core/helpers/createParamsByAlgCode/index.ts +++ b/src/CipherMessage/.core/helpers/createParamsByAlgCode/index.ts @@ -19,6 +19,7 @@ import type { CipherKey, CipherParams } from '../../types/index.js' export function createParamsByAlgCode(algCode: CipherKey['alg']): CipherParams { switch (algCode) { case 'A256CTR': + case 'A256GCM': if (!globalThis.crypto?.getRandomValues) { throw new CryptosuiteError( 'GET_RANDOM_VALUES_UNAVAILABLE', diff --git a/src/CipherMessage/.core/helpers/getImportKeyAlgorithmByAlgCode/index.ts b/src/CipherMessage/.core/helpers/getImportKeyAlgorithmByAlgCode/index.ts index 632784b..6bc57ef 100644 --- a/src/CipherMessage/.core/helpers/getImportKeyAlgorithmByAlgCode/index.ts +++ b/src/CipherMessage/.core/helpers/getImportKeyAlgorithmByAlgCode/index.ts @@ -23,6 +23,10 @@ export function getImportKeyAlgorithmByAlgCode( return { name: 'AES-CTR', } + case 'A256GCM': + return { + name: 'AES-GCM', + } } throw new CryptosuiteError( diff --git a/src/CipherMessage/.core/helpers/getParamsByAlgCode/index.ts b/src/CipherMessage/.core/helpers/getParamsByAlgCode/index.ts index f443941..e5a773e 100644 --- a/src/CipherMessage/.core/helpers/getParamsByAlgCode/index.ts +++ b/src/CipherMessage/.core/helpers/getParamsByAlgCode/index.ts @@ -18,12 +18,13 @@ import type { CipherKey, CipherParams, A256CTRParams, + A256GCMParams, } from '../../types/index.js' export function getParamsByAlgCode( algCode: CipherKey['alg'], params: CipherParams -): AesCtrParams { +): AesCtrParams | AesGcmParams { switch (algCode) { case 'A256CTR': { const { iv } = params as A256CTRParams @@ -50,6 +51,29 @@ export function getParamsByAlgCode( length: 32, } } + case 'A256GCM': { + const { iv } = params as A256GCMParams + + if (!(iv instanceof Uint8Array)) { + throw new CryptosuiteError( + 'CIPHER_MESSAGE_INVALID', + 'getParamsByAlgCode: expected a Uint8Array iv for AES-GCM.' + ) + } + + if (iv.byteLength !== 12) { + throw new CryptosuiteError( + 'CIPHER_MESSAGE_INVALID', + 'getParamsByAlgCode: expected a 96-bit IV for AES-GCM.' + ) + } + + return { + name: 'AES-GCM', + iv, + tagLength: 128, + } + } } throw new CryptosuiteError( diff --git a/src/CipherMessage/.core/helpers/validateKeyByAlgCode/index.ts b/src/CipherMessage/.core/helpers/validateKeyByAlgCode/index.ts index 276c809..fe4be31 100644 --- a/src/CipherMessage/.core/helpers/validateKeyByAlgCode/index.ts +++ b/src/CipherMessage/.core/helpers/validateKeyByAlgCode/index.ts @@ -28,7 +28,8 @@ export function validateKeyByAlgCode(key: JsonWebKey): CipherKey { } switch (candidate.alg) { - case 'A256CTR': { + case 'A256CTR': + case 'A256GCM': { if (candidate.kty !== 'oct' || typeof candidate.k !== 'string') { throw new CryptosuiteError( 'CIPHER_KEY_INVALID', @@ -96,7 +97,7 @@ export function validateKeyByAlgCode(key: JsonWebKey): CipherKey { ...rest, kty: 'oct', k: candidate.k, - alg: 'A256CTR', + alg: candidate.alg, use: 'enc', key_ops: candidate.key_ops === undefined diff --git a/src/CipherMessage/.core/types/index.ts b/src/CipherMessage/.core/types/index.ts index f562df7..0735a1b 100644 --- a/src/CipherMessage/.core/types/index.ts +++ b/src/CipherMessage/.core/types/index.ts @@ -28,11 +28,13 @@ type NoAsymmetric = { crv?: never } -type A256CTRKey = JsonWebKey & +type CipherAlg = 'A256CTR' | 'A256GCM' + +type CipherKeyByAlg = JsonWebKey & NoAsymmetric & { kty: 'oct' k: string - alg: 'A256CTR' + alg: Alg use: 'enc' key_ops: readonly ('encrypt' | 'decrypt')[] } @@ -45,22 +47,35 @@ export type A256CTRParams = { iv: Uint8Array } +/** + * Algorithm parameters serialized alongside an AES-GCM cipher message. + */ +export type A256GCMParams = { + /** The 96-bit initialization vector used for encryption. */ + iv: Uint8Array +} + type A256CTRMessage = { /** The encrypted payload bytes. */ ciphertext: ArrayBuffer } & A256CTRParams +type A256GCMMessage = { + /** The encrypted payload bytes. */ + ciphertext: ArrayBuffer +} & A256GCMParams + /** * Symmetric AES-CTR-256 JWK used for cipher messaging operations. */ -export type CipherKey = A256CTRKey +export type CipherKey = CipherKeyByAlg<'A256CTR'> | CipherKeyByAlg<'A256GCM'> /** * Serialized parameters required to decrypt a cipher message. */ -export type CipherParams = A256CTRParams +export type CipherParams = A256CTRParams | A256GCMParams /** * Cipher message artifact returned by cipher encryption operations. */ -export type CipherMessage = A256CTRMessage +export type CipherMessage = A256CTRMessage | A256GCMMessage diff --git a/src/CipherMessage/README.md b/src/CipherMessage/README.md index f3b7a27..5bff27d 100644 --- a/src/CipherMessage/README.md +++ b/src/CipherMessage/README.md @@ -17,10 +17,7 @@ after the default algorithm changes. ## Current default -Current default generation and derivation target `A256CTR` / `AES-CTR`. - -Integrity is intentionally not provided here. Integrity is expected to be -handled at the message/signature layer. +Current default generation and derivation target `A256GCM` / `AES-GCM`. ## Responsibilities @@ -67,7 +64,7 @@ When upgrading the default algorithm: ## Naming rule Inside `.core`, use standard WebCrypto algorithm names when dealing with -`CryptoKey.algorithm`, for example `AES-CTR`. +`CryptoKey.algorithm`, for example `AES-GCM`. -JWK `alg` values such as `A256CTR` are the external key identifiers. Do not +JWK `alg` values such as `A256GCM` are the external key identifiers. Do not confuse them with WebCrypto `algorithm.name`. diff --git a/src/CipherMessage/deriveCipherKey/index.ts b/src/CipherMessage/deriveCipherKey/index.ts index e34ec86..7e94d9d 100644 --- a/src/CipherMessage/deriveCipherKey/index.ts +++ b/src/CipherMessage/deriveCipherKey/index.ts @@ -72,14 +72,14 @@ export async function deriveCipherKey( info: new Uint8Array(0), }, key, - { name: 'AES-CTR', length: 256 }, + { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ) } catch { throw new CryptosuiteError( 'ALGORITHM_UNSUPPORTED', - 'deriveCipherKey: HKDF-SHA-256 to AES-CTR-256 is not supported by this WebCrypto runtime.' + 'deriveCipherKey: HKDF-SHA-256 to AES-GCM-256 is not supported by this WebCrypto runtime.' ) } diff --git a/src/CipherMessage/generateCipherKey/index.ts b/src/CipherMessage/generateCipherKey/index.ts index 2b38eb9..8298105 100644 --- a/src/CipherMessage/generateCipherKey/index.ts +++ b/src/CipherMessage/generateCipherKey/index.ts @@ -33,14 +33,14 @@ export async function generateCipherKey(): Promise { let cipherKey: CryptoKey try { cipherKey = await crypto.subtle.generateKey( - { name: 'AES-CTR', length: 256 }, + { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ) } catch { throw new CryptosuiteError( 'ALGORITHM_UNSUPPORTED', - 'generateCipherKey: AES-CTR-256 is not supported by this WebCrypto runtime.' + 'generateCipherKey: AES-GCM-256 is not supported by this WebCrypto runtime.' ) } diff --git a/src/Identifier/index.ts b/src/Identifier/index.ts index 4be83be..56d822f 100644 --- a/src/Identifier/index.ts +++ b/src/Identifier/index.ts @@ -33,9 +33,19 @@ export type OpaqueIdentifier = string * @returns A derived opaque identifier in normalized presentation. */ export async function deriveOID(source: Uint8Array): Promise { + if (!globalThis.crypto?.subtle) { + throw new CryptosuiteError( + 'SUBTLE_UNAVAILABLE', + 'deriveOID: crypto.subtle is unavailable.' + ) + } + let hash: ArrayBuffer try { - hash = await crypto.subtle.digest('SHA-384', toBufferSource(source)) + hash = await globalThis.crypto.subtle.digest( + 'SHA-384', + toBufferSource(source) + ) } catch { throw new CryptosuiteError( 'SHA384_UNSUPPORTED', @@ -52,7 +62,14 @@ export async function deriveOID(source: Uint8Array): Promise { * @returns A randomly generated opaque identifier. */ export async function generateOID(): Promise { - return toBase64UrlString(crypto.getRandomValues(new Uint8Array(48))) + if (!globalThis.crypto?.getRandomValues) { + throw new CryptosuiteError( + 'GET_RANDOM_VALUES_UNAVAILABLE', + 'generateOID: crypto.getRandomValues is unavailable.' + ) + } + + return toBase64UrlString(globalThis.crypto.getRandomValues(new Uint8Array(48))) } /** diff --git a/src/KeyAgreement/.core/DecapsulateKeyHarness/class.ts b/src/KeyAgreement/.core/DecapsulateKeyHarness/class.ts index 67ae64e..4f623c8 100644 --- a/src/KeyAgreement/.core/DecapsulateKeyHarness/class.ts +++ b/src/KeyAgreement/.core/DecapsulateKeyHarness/class.ts @@ -60,14 +60,14 @@ export class DecapsulateKeyHarness { cipherKey = await crypto.subtle.importKey( 'raw', toBufferSource(sharedSecret), - { name: 'AES-CTR', length: 256 }, + { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ) } catch { throw new CryptosuiteError( 'ALGORITHM_UNSUPPORTED', - 'DecapsulateKeyHarness: AES-CTR-256 is not supported by this WebCrypto runtime.' + 'DecapsulateKeyHarness: AES-GCM-256 is not supported by this WebCrypto runtime.' ) } diff --git a/src/KeyAgreement/.core/EncapsulateKeyHarness/class.ts b/src/KeyAgreement/.core/EncapsulateKeyHarness/class.ts index c16c3c1..200bbfd 100644 --- a/src/KeyAgreement/.core/EncapsulateKeyHarness/class.ts +++ b/src/KeyAgreement/.core/EncapsulateKeyHarness/class.ts @@ -60,14 +60,14 @@ export class EncapsulateKeyHarness { cipherKey = await crypto.subtle.importKey( 'raw', toBufferSource(sharedSecret), - { name: 'AES-CTR', length: 256 }, + { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ) } catch { throw new CryptosuiteError( 'ALGORITHM_UNSUPPORTED', - 'EncapsulateKeyHarness: AES-CTR-256 is not supported by this WebCrypto runtime.' + 'EncapsulateKeyHarness: AES-GCM-256 is not supported by this WebCrypto runtime.' ) } diff --git a/src/KeyAgreement/README.md b/src/KeyAgreement/README.md index a4b8bd6..ab2718a 100644 --- a/src/KeyAgreement/README.md +++ b/src/KeyAgreement/README.md @@ -23,7 +23,7 @@ Encapsulation returns a `KeyOffer` and a symmetric `CipherKey`. Decapsulation takes that `KeyOffer` and reconstructs the same `CipherKey`. -The shared secret is used directly as raw `AES-CTR-256` key material and is +The shared secret is used directly as raw `AES-GCM-256` key material and is then normalized through `CipherMessage` key validation. ## Responsibilities diff --git a/src/MessageAuthentication/.core/helpers/validateKeyByAlgCode/index.ts b/src/MessageAuthentication/.core/helpers/validateKeyByAlgCode/index.ts index 6fd8e77..5e0815e 100644 --- a/src/MessageAuthentication/.core/helpers/validateKeyByAlgCode/index.ts +++ b/src/MessageAuthentication/.core/helpers/validateKeyByAlgCode/index.ts @@ -58,8 +58,9 @@ export function validateKeyByAlgCode( ) } + let keyBytes: Uint8Array try { - fromBase64UrlString(candidate.k) + keyBytes = fromBase64UrlString(candidate.k) } catch { throw new CryptosuiteError( 'BASE64URL_INVALID', @@ -67,6 +68,13 @@ export function validateKeyByAlgCode( ) } + if (keyBytes.byteLength < 32) { + throw new CryptosuiteError( + 'HMAC_JWK_INVALID', + 'validateKeyByAlgCode: key material must be at least 256 bits.' + ) + } + const { d: _d, p: _p, diff --git a/test/e2e/runtime-suite.mjs b/test/e2e/runtime-suite.mjs index 9706c94..00dd23c 100644 --- a/test/e2e/runtime-suite.mjs +++ b/test/e2e/runtime-suite.mjs @@ -182,10 +182,10 @@ export async function runCryptosuiteRuntimeSuite(Cryptographic) { } ) - await run('cipherMessage.generateKey returns an AES-CTR key', async () => { + await run('cipherMessage.generateKey returns an AES-GCM key', async () => { const cipherKey = await Cryptographic.cipherMessage.generateKey() assertEqual(cipherKey.kty, 'oct', 'cipher key must be symmetric') - assertEqual(cipherKey.alg, 'A256CTR', 'cipher key alg must be A256CTR') + assertEqual(cipherKey.alg, 'A256GCM', 'cipher key alg must be A256GCM') assert(typeof cipherKey.k === 'string', 'cipher key material must exist') }) @@ -370,7 +370,7 @@ export async function runCryptosuiteRuntimeSuite(Cryptographic) { ) assertEqual( cipherKey.alg, - 'A256CTR', + 'A256GCM', 'encapsulated cipher key alg mismatch' ) } diff --git a/test/integration/integration.test.mjs b/test/integration/integration.test.mjs index 1c727be..976482c 100644 --- a/test/integration/integration.test.mjs +++ b/test/integration/integration.test.mjs @@ -20,7 +20,7 @@ test('integration: identifier generate/derive/validate', async () => { assert.equal(derived.length, 64) }) -test('integration: AES-CTR encrypt/decrypt roundtrip', async () => { +test('integration: AES-GCM encrypt/decrypt roundtrip', async () => { const cipherKey = await Cryptographic.cipherMessage.generateKey() const cipherMessage = await Cryptographic.cipherMessage.encrypt( cipherKey, diff --git a/test/support/fixtures.mjs b/test/support/fixtures.mjs index bdf8f20..54c86a9 100644 --- a/test/support/fixtures.mjs +++ b/test/support/fixtures.mjs @@ -34,6 +34,17 @@ export function createA256CtrKey(overrides = {}) { } } +export function createA256GcmKey(overrides = {}) { + return { + kty: 'oct', + k: toBase64UrlString(filledBytes(32, 11)), + alg: 'A256GCM', + use: 'enc', + key_ops: ['encrypt', 'decrypt'], + ...overrides, + } +} + export function createHs256Key(overrides = {}) { return { kty: 'oct', diff --git a/test/unit/cipherMessage.test.mjs b/test/unit/cipherMessage.test.mjs index f5c690d..7172b2f 100644 --- a/test/unit/cipherMessage.test.mjs +++ b/test/unit/cipherMessage.test.mjs @@ -8,7 +8,11 @@ import { restoreCrypto, setCrypto, } from '../support/index.mjs' -import { bytes, createA256CtrKey } from '../support/fixtures.mjs' +import { + bytes, + createA256CtrKey, + createA256GcmKey, +} from '../support/fixtures.mjs' if (!globalThis.crypto) { globalThis.crypto = webcrypto @@ -26,7 +30,7 @@ test('cipherMessage.generateKey throws when crypto.subtle is unavailable', async ) }) -test('cipherMessage.generateKey maps unsupported AES-CTR to ALGORITHM_UNSUPPORTED', async () => { +test('cipherMessage.generateKey maps unsupported AES-GCM to ALGORITHM_UNSUPPORTED', async () => { setCrypto( buildCrypto({ subtle: { @@ -63,7 +67,7 @@ test('cipherMessage.deriveKey requires getRandomValues when salt is omitted', as ) }) -test('cipherMessage.deriveKey maps unsupported HKDF or AES-CTR to ALGORITHM_UNSUPPORTED', async () => { +test('cipherMessage.deriveKey maps unsupported HKDF or AES-GCM to ALGORITHM_UNSUPPORTED', async () => { setCrypto( buildCrypto({ subtle: { @@ -95,7 +99,7 @@ test('cipherMessage encrypt/decrypt accepts a minimal valid JWK without optional }) ) - const cipherKey = createA256CtrKey({ + const cipherKey = createA256GcmKey({ use: undefined, key_ops: undefined, }) @@ -118,6 +122,7 @@ test('cipherMessage.encrypt rejects malformed cipher keys', async () => { () => Cryptographic.cipherMessage.encrypt( createA256CtrKey({ + alg: 'A256GCM', k: 'A', }), bytes(1) @@ -126,12 +131,25 @@ test('cipherMessage.encrypt rejects malformed cipher keys', async () => { ) }) -test('cipherMessage.encrypt rejects unsupported cipher alg codes', async () => { +test('cipherMessage.encrypt still accepts malformed historical AES-CTR keys as validation failures', async () => { await expectCodeAsync( () => Cryptographic.cipherMessage.encrypt( createA256CtrKey({ - alg: 'A128CTR', + k: 'A', + }), + bytes(1) + ), + 'BASE64URL_INVALID' + ) +}) + +test('cipherMessage.encrypt rejects unsupported cipher alg codes', async () => { + await expectCodeAsync( + () => + Cryptographic.cipherMessage.encrypt( + createA256GcmKey({ + alg: 'A128GCM', }), bytes(1) ), @@ -142,14 +160,33 @@ test('cipherMessage.encrypt rejects unsupported cipher alg codes', async () => { test('cipherMessage.decrypt rejects malformed cipher message artifacts', async () => { await expectCodeAsync( () => - Cryptographic.cipherMessage.decrypt(createA256CtrKey(), { + Cryptographic.cipherMessage.decrypt(createA256GcmKey(), { iv: new Uint8Array(12), }), 'CIPHER_MESSAGE_INVALID' ) }) -test('cipherMessage.decrypt rejects invalid AES-CTR iv lengths', async () => { +test('cipherMessage.decrypt rejects invalid AES-GCM iv lengths', async () => { + setCrypto( + buildCrypto({ + subtle: { + importKey: async () => ({}), + }, + }) + ) + + await expectCodeAsync( + () => + Cryptographic.cipherMessage.decrypt(createA256GcmKey(), { + ciphertext: new ArrayBuffer(1), + iv: new Uint8Array(11), + }), + 'CIPHER_MESSAGE_INVALID' + ) +}) + +test('cipherMessage.decrypt still rejects invalid historical AES-CTR iv lengths', async () => { setCrypto( buildCrypto({ subtle: { diff --git a/test/unit/identifier.test.mjs b/test/unit/identifier.test.mjs index 5ab49a9..1b0e0bc 100644 --- a/test/unit/identifier.test.mjs +++ b/test/unit/identifier.test.mjs @@ -24,6 +24,17 @@ test('identifier.generate returns a fixed-length opaque identifier', async () => assert.equal(Cryptographic.identifier.validate(identifier), identifier) }) +test('identifier.generate throws when crypto.getRandomValues is unavailable', async () => { + setCrypto({ + subtle: globalThis.crypto.subtle, + }) + + await expectCodeAsync( + () => Cryptographic.identifier.generate(), + 'GET_RANDOM_VALUES_UNAVAILABLE' + ) +}) + test('identifier.derive returns a deterministic opaque identifier', async () => { const source = bytes(1, 2, 3, 4) const one = await Cryptographic.identifier.derive(source) @@ -32,6 +43,17 @@ test('identifier.derive returns a deterministic opaque identifier', async () => assert.equal(one.length, 64) }) +test('identifier.derive throws when crypto.subtle is unavailable', async () => { + setCrypto({ + getRandomValues: globalThis.crypto.getRandomValues, + }) + + await expectCodeAsync( + () => Cryptographic.identifier.derive(bytes(1, 2, 3)), + 'SUBTLE_UNAVAILABLE' + ) +}) + test('identifier.validate accepts only 64-char base64url strings', () => { const valid = 'A'.repeat(64) assert.equal(Cryptographic.identifier.validate(valid), valid) diff --git a/test/unit/keyAgreement.test.mjs b/test/unit/keyAgreement.test.mjs index 7bc7687..073171c 100644 --- a/test/unit/keyAgreement.test.mjs +++ b/test/unit/keyAgreement.test.mjs @@ -10,7 +10,7 @@ import { } from '../support/index.mjs' import { bytes, - createA256CtrKey, + createA256GcmKey, createMlKemPublicKey, } from '../support/fixtures.mjs' @@ -47,7 +47,7 @@ test('keyAgreement.encapsulate accepts a minimal public key without key_ops', as buildCrypto({ subtle: { importKey: async () => ({}), - exportKey: async () => createA256CtrKey(), + exportKey: async () => createA256GcmKey(), }, }) ) @@ -57,7 +57,7 @@ test('keyAgreement.encapsulate accepts a minimal public key without key_ops', as key_ops: undefined, }) assert.ok(result.keyOffer.ciphertext instanceof ArrayBuffer) - assert.equal(result.cipherKey.alg, 'A256CTR') + assert.equal(result.cipherKey.alg, 'A256GCM') }) test('keyAgreement.encapsulate maps shared-secret export failures to ENCAPSULATION_FAILED', async () => { @@ -92,7 +92,7 @@ test('keyAgreement.decapsulate rejects ciphertexts with invalid lengths', async buildCrypto({ subtle: { importKey: async () => ({}), - exportKey: async () => createA256CtrKey(), + exportKey: async () => createA256GcmKey(), }, }) ) @@ -116,7 +116,7 @@ test('keyAgreement.decapsulate accepts a minimal private key without key_ops', a buildCrypto({ subtle: { importKey: async () => ({}), - exportKey: async () => createA256CtrKey(), + exportKey: async () => createA256GcmKey(), }, }) ) @@ -127,7 +127,7 @@ test('keyAgreement.decapsulate accepts a minimal private key without key_ops', a ...decapsulateKey, key_ops: undefined, }) - assert.equal(result.cipherKey.alg, 'A256CTR') + assert.equal(result.cipherKey.alg, 'A256GCM') }) test('keyAgreement cluster reuses cached harnesses for the same key objects', async () => { @@ -137,15 +137,15 @@ test('keyAgreement cluster reuses cached harnesses for the same key objects', as buildCrypto({ subtle: { importKey: async () => ({}), - exportKey: async () => createA256CtrKey(), + exportKey: async () => createA256GcmKey(), }, }) ) const first = await Cryptographic.keyAgreement.encapsulate(encapsulateKey) const second = await Cryptographic.keyAgreement.encapsulate(encapsulateKey) - assert.equal(first.cipherKey.alg, 'A256CTR') - assert.equal(second.cipherKey.alg, 'A256CTR') + assert.equal(first.cipherKey.alg, 'A256GCM') + assert.equal(second.cipherKey.alg, 'A256GCM') const third = await Cryptographic.keyAgreement.decapsulate( first.keyOffer, @@ -155,6 +155,6 @@ test('keyAgreement cluster reuses cached harnesses for the same key objects', as second.keyOffer, decapsulateKey ) - assert.equal(third.cipherKey.alg, 'A256CTR') - assert.equal(fourth.cipherKey.alg, 'A256CTR') + assert.equal(third.cipherKey.alg, 'A256GCM') + assert.equal(fourth.cipherKey.alg, 'A256GCM') }) diff --git a/test/unit/messageAuthentication.test.mjs b/test/unit/messageAuthentication.test.mjs index 68fd740..932aecb 100644 --- a/test/unit/messageAuthentication.test.mjs +++ b/test/unit/messageAuthentication.test.mjs @@ -120,6 +120,19 @@ test('messageAuthentication.sign rejects malformed HMAC key material', async () ) }) +test('messageAuthentication.sign rejects weak HMAC key material', async () => { + await expectCodeAsync( + () => + Cryptographic.messageAuthentication.sign( + createHs256Key({ + k: 'AQID', + }), + bytes(1) + ), + 'HMAC_JWK_INVALID' + ) +}) + test('messageAuthentication.sign rejects unsupported alg codes', async () => { await expectCodeAsync( () => diff --git a/test/unit/source-cipherMessage.test.mjs b/test/unit/source-cipherMessage.test.mjs index ce179b5..18736fb 100644 --- a/test/unit/source-cipherMessage.test.mjs +++ b/test/unit/source-cipherMessage.test.mjs @@ -14,7 +14,11 @@ import { restoreCrypto, setCrypto, } from '../support/index.mjs' -import { bytes, createA256CtrKey } from '../support/fixtures.mjs' +import { + bytes, + createA256CtrKey, + createA256GcmKey, +} from '../support/fixtures.mjs' if (!globalThis.crypto) { globalThis.crypto = webcrypto @@ -50,6 +54,10 @@ test('source cipher helpers validate supported and unsupported algorithm branche () => validateKeyByAlgCode(createA256CtrKey({ alg: 'A128CTR' })), 'ALGORITHM_UNSUPPORTED' ) + expectCodeSync( + () => validateKeyByAlgCode(createA256GcmKey({ alg: 'A128GCM' })), + 'ALGORITHM_UNSUPPORTED' + ) const normalized = validateKeyByAlgCode( createA256CtrKey({ use: undefined, key_ops: undefined, extra: 'ok' }) @@ -57,6 +65,13 @@ test('source cipher helpers validate supported and unsupported algorithm branche assert.equal(normalized.use, 'enc') assert.deepEqual(normalized.key_ops, ['encrypt', 'decrypt']) assert.equal(normalized.extra, 'ok') + + const normalizedGcm = validateKeyByAlgCode( + createA256GcmKey({ use: undefined, key_ops: undefined, extra: 'ok' }) + ) + assert.equal(normalizedGcm.alg, 'A256GCM') + assert.deepEqual(normalizedGcm.key_ops, ['encrypt', 'decrypt']) + assert.equal(normalizedGcm.extra, 'ok') }) test('source cipher param helpers cover supported and unsupported branches', () => { @@ -69,6 +84,9 @@ test('source cipher param helpers cover supported and unsupported branches', () const params = createParamsByAlgCode('A256CTR') assert.equal(params.iv.byteLength, 12) assert.equal(params.iv[0], 7) + const gcmParams = createParamsByAlgCode('A256GCM') + assert.equal(gcmParams.iv.byteLength, 12) + assert.equal(gcmParams.iv[0], 7) setCrypto({ subtle: globalThis.crypto.subtle, @@ -84,6 +102,8 @@ test('source cipher param helpers cover supported and unsupported branches', () const importAlgorithm = getImportKeyAlgorithmByAlgCode('A256CTR') assert.equal(importAlgorithm.name, 'AES-CTR') + const gcmImportAlgorithm = getImportKeyAlgorithmByAlgCode('A256GCM') + assert.equal(gcmImportAlgorithm.name, 'AES-GCM') expectCodeSync( () => getImportKeyAlgorithmByAlgCode('A128CTR'), @@ -96,15 +116,29 @@ test('source cipher param helpers cover supported and unsupported branches', () assert.equal(webCryptoParams.name, 'AES-CTR') assert.equal(webCryptoParams.counter.byteLength, 16) assert.equal(webCryptoParams.length, 32) + const gcmWebCryptoParams = getParamsByAlgCode('A256GCM', { + iv: new Uint8Array(12).fill(9), + }) + assert.equal(gcmWebCryptoParams.name, 'AES-GCM') + assert.equal(gcmWebCryptoParams.iv.byteLength, 12) + assert.equal(gcmWebCryptoParams.tagLength, 128) expectCodeSync( () => getParamsByAlgCode('A256CTR', { iv: new ArrayBuffer(12) }), 'CIPHER_MESSAGE_INVALID' ) + expectCodeSync( + () => getParamsByAlgCode('A256GCM', { iv: new ArrayBuffer(12) }), + 'CIPHER_MESSAGE_INVALID' + ) expectCodeSync( () => getParamsByAlgCode('A256CTR', { iv: new Uint8Array(11) }), 'CIPHER_MESSAGE_INVALID' ) + expectCodeSync( + () => getParamsByAlgCode('A256GCM', { iv: new Uint8Array(11) }), + 'CIPHER_MESSAGE_INVALID' + ) expectCodeSync( () => getParamsByAlgCode('A128CTR', { iv: new Uint8Array(12) }), 'ALGORITHM_UNSUPPORTED' @@ -144,7 +178,7 @@ test('source CipherKeyHarness covers constructor, import failure, and decrypt va }) ) - const harness = new CipherKeyHarness(createA256CtrKey()) + const harness = new CipherKeyHarness(createA256GcmKey()) const encrypted = await harness.encrypt(bytes(1, 2, 3)) assert.ok(encrypted.ciphertext instanceof ArrayBuffer) assert.equal(encrypted.iv.byteLength, 12) @@ -159,6 +193,27 @@ test('source CipherKeyHarness covers constructor, import failure, and decrypt va () => harness.decrypt({ iv: new Uint8Array(12) }), 'CIPHER_MESSAGE_INVALID' ) + + setCrypto( + buildCrypto({ + subtle: { + importKey: async () => ({}), + decrypt: async () => { + throw new Error('auth fail') + }, + }, + }) + ) + + const decryptFailHarness = new CipherKeyHarness(createA256GcmKey()) + await expectCodeAsync( + () => + decryptFailHarness.decrypt({ + ciphertext: new ArrayBuffer(3), + iv: new Uint8Array(12).fill(4), + }), + 'CIPHER_ARTIFACT_INVALID' + ) }) test('source deriveCipherKey covers subtle-unavailable branch', async () => { @@ -176,12 +231,12 @@ test('source deriveCipherKey covers generated-salt branch', async () => { subtle: { importKey: async () => ({}), deriveKey: async () => ({}), - exportKey: async () => createA256CtrKey(), + exportKey: async () => createA256GcmKey(), }, }) ) const result = await deriveCipherKey(bytes(1, 2, 3)) - assert.equal(result.cipherKey.alg, 'A256CTR') + assert.equal(result.cipherKey.alg, 'A256GCM') assert.deepEqual(Array.from(result.salt), Array(16).fill(5)) }) diff --git a/test/unit/source-keyAgreement.test.mjs b/test/unit/source-keyAgreement.test.mjs index 4a9a7b7..927b749 100644 --- a/test/unit/source-keyAgreement.test.mjs +++ b/test/unit/source-keyAgreement.test.mjs @@ -18,6 +18,7 @@ import { } from '../support/index.mjs' import { createA256CtrKey, + createA256GcmKey, createMlKemPrivateKey, createMlKemPublicKey, createX25519MlKem768PrivateKey, @@ -261,7 +262,7 @@ test('source key agreement harnesses cover constructor, invariant, and export br buildCrypto({ subtle: { importKey: async () => ({}), - exportKey: async () => createA256CtrKey(), + exportKey: async () => createA256GcmKey(), }, }) ) diff --git a/test/unit/source-messageAuthentication.test.mjs b/test/unit/source-messageAuthentication.test.mjs index 58b3bd3..ce267b7 100644 --- a/test/unit/source-messageAuthentication.test.mjs +++ b/test/unit/source-messageAuthentication.test.mjs @@ -42,6 +42,10 @@ test('source message authentication helpers cover validation and unsupported bra () => validateKeyByAlgCode(createHs256Key({ k: 'A' })), 'BASE64URL_INVALID' ) + expectCodeSync( + () => validateKeyByAlgCode(createHs256Key({ k: 'AQID' })), + 'HMAC_JWK_INVALID' + ) expectCodeSync( () => validateKeyByAlgCode(createHs256Key({ alg: 'HS512' })), 'ALGORITHM_UNSUPPORTED'