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
46 changes: 23 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.

Expand Down
19 changes: 14 additions & 5 deletions src/CipherMessage/.core/CipherKeyHarness/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export function getImportKeyAlgorithmByAlgCode(
return {
name: 'AES-CTR',
}
case 'A256GCM':
return {
name: 'AES-GCM',
}
}

throw new CryptosuiteError(
Expand Down
26 changes: 25 additions & 1 deletion src/CipherMessage/.core/helpers/getParamsByAlgCode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/CipherMessage/.core/helpers/validateKeyByAlgCode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
25 changes: 20 additions & 5 deletions src/CipherMessage/.core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ type NoAsymmetric = {
crv?: never
}

type A256CTRKey = JsonWebKey &
type CipherAlg = 'A256CTR' | 'A256GCM'

type CipherKeyByAlg<Alg extends CipherAlg> = JsonWebKey &
NoAsymmetric & {
kty: 'oct'
k: string
alg: 'A256CTR'
alg: Alg
use: 'enc'
key_ops: readonly ('encrypt' | 'decrypt')[]
}
Expand All @@ -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
9 changes: 3 additions & 6 deletions src/CipherMessage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.
4 changes: 2 additions & 2 deletions src/CipherMessage/deriveCipherKey/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
)
}

Expand Down
4 changes: 2 additions & 2 deletions src/CipherMessage/generateCipherKey/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ export async function generateCipherKey(): Promise<CipherKey> {
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.'
)
}

Expand Down
21 changes: 19 additions & 2 deletions src/Identifier/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,19 @@ export type OpaqueIdentifier = string
* @returns A derived opaque identifier in normalized presentation.
*/
export async function deriveOID(source: Uint8Array): Promise<OpaqueIdentifier> {
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',
Expand All @@ -52,7 +62,14 @@ export async function deriveOID(source: Uint8Array): Promise<OpaqueIdentifier> {
* @returns A randomly generated opaque identifier.
*/
export async function generateOID(): Promise<OpaqueIdentifier> {
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)))
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/KeyAgreement/.core/DecapsulateKeyHarness/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
)
}

Expand Down
4 changes: 2 additions & 2 deletions src/KeyAgreement/.core/EncapsulateKeyHarness/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/KeyAgreement/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,23 @@ export function validateKeyByAlgCode(
)
}

let keyBytes: Uint8Array
try {
fromBase64UrlString(candidate.k)
keyBytes = fromBase64UrlString(candidate.k)
} catch {
throw new CryptosuiteError(
'BASE64URL_INVALID',
'validateKeyByAlgCode: invalid base64url key material.'
)
}

if (keyBytes.byteLength < 32) {
throw new CryptosuiteError(
'HMAC_JWK_INVALID',
'validateKeyByAlgCode: key material must be at least 256 bits.'
)
}

const {
d: _d,
p: _p,
Expand Down
Loading
Loading