Skip to content

Emit GCMParameters SEQUENCE for AES-GCM AlgorithmIdentifier (RFC 5084) - #487

Open
dotCooCoo wants to merge 1 commit into
PeculiarVentures:masterfrom
dotCooCoo:feat/aes-gcm-parameters
Open

Emit GCMParameters SEQUENCE for AES-GCM AlgorithmIdentifier (RFC 5084)#487
dotCooCoo wants to merge 1 commit into
PeculiarVentures:masterfrom
dotCooCoo:feat/aes-gcm-parameters

Conversation

@dotCooCoo

@dotCooCoo dotCooCoo commented Apr 23, 2026

Copy link
Copy Markdown

When constructing a PKCS#12 PFX or CMS EnvelopedData with contentEncryptionAlgorithm: { name: "AES-GCM", ... }, pkijs emits the AlgorithmIdentifier with parameters as a bare OCTET STRING carrying the IV. RFC 5084 §3.2 requires the parameter value to be a GCMParameters SEQUENCE instead:

GCMParameters ::= SEQUENCE {
  aes-nonce        OCTET STRING,
  aes-ICVlen       AES-GCM-ICVlen DEFAULT 12 }

AES-GCM-ICVlen ::= INTEGER (12 | 13 | 14 | 15 | 16)

A bare OCTET STRING is not a GCMParameters, so what pkijs writes today is not
what a reader implementing RFC 5084 expects to parse. The same-shape bug exists
independently on the CMS EnvelopedData path, tracked separately as #287.

Scope

Two emission sites, same root cause, both fixed:

This corrects the encoding. It does not, on its own, make either payload
interoperable with OpenSSL, and neither issue is closed by it:

Both remainders are the same shape: AES-GCM in a container with no mac field
to carry the ICV. That is a larger change than this one and is left to the
issues.

Changes

  • New src/GCMParams.ts — RFC 5084 §3.2 GCMParameters schema class, modeled on PBKDF2Params
  • New test/gcmParams.spec.ts — 28 tests
  • src/CryptoEngine/CryptoEngine.ts — detect AES-GCM, generate a 12-octet nonce (the size RFC 5084 recommends), emit GCMParams.toSchema() on encrypt, and read the parameters back through GCMParams.fromAlgorithmParams on decrypt
  • src/EnvelopedData.ts — same pattern for the CMS path
  • src/index.ts — alphabetical re-export of GCMParams

GCMParams.fromAlgorithmParams is where both paths read an AES-GCM
AlgorithmIdentifier: it returns the nonce, and the tag length where the
parameters state one. Keeping it in one place means the ICV default and the
compatibility shape below cannot drift apart between the PBES2 and CMS callers.

Value constraints

aes-ICVlen reaches WebCrypto as a tag length, and the AlgorithmIdentifier
carrying it is not authenticated, so the value is checked rather than trusted.
GCMParams rejects an aes-ICVlen outside RFC 5084's
INTEGER (12 | 13 | 14 | 15 | 16) on both parse and emit, rejects a zero-length
aes-nonce, and rejects an AES-GCM AlgorithmIdentifier whose parameters field
is absent, which §3.2 requires to be present. Without the range check a blob
declaring aes-ICVlen 4 authenticates under a 32-bit tag.

The GCMParameters ASN.1 type name is capitalized per RFC 5084, but the JS class follows the *Params convention established by PBKDF2Params, PBES2Params, RSAESOAEPParams, RSASSAPSSParams in this package. Comments and ASN.1 blocks still reference the RFC type name GCMParameters.

Backward compatibility

Where the parameters are a bare OCTET STRING rather than a GCMParameters
SEQUENCE, it is read as a raw IV and WebCrypto keeps its own 128-bit tag length,
so pkijs still opens the AES-GCM blobs it produced before this change. That shape
is matched on the OCTET STRING itself, so a GCMParameters that fails to parse
raises its schema error instead of being taken for a legacy blob.

Tests

Schema:

  • encodes nonce + icvLen 16, parses back with identical values
  • omits icvLen from DER when value equals the RFC 5084 default (12)
  • round-trips with icvLen absent (parses as undefined)
  • defaults the nonce to an empty buffer when constructed with no parameters
  • names every schema element when told to, and leaves them empty when not
  • rejects an unknown member name in defaultValues
  • encodes to the DER an RFC 5084 reader expects
  • refuses an aes-ICVlen outside the RFC 5084 set, on parse and on emit
  • accepts every aes-ICVlen the RFC permits
  • refuses an AES-GCM AlgorithmIdentifier that carries no parameters
  • refuses a zero-length aes-nonce
  • clears a previously set icvLen when re-parsing input that omits it
  • toJSON carries the nonce and omits icvLen at the RFC 5084 default

PBES2 path (#486):

  • emits inner AES-GCM AlgorithmIdentifier.algorithmParams as a SEQUENCE
  • normalizes a lower-case algorithm name and still emits a SEQUENCE
  • decrypt recovers the original plaintext (new-format round-trip)
  • legacy fallback: decrypts a blob whose params are a bare OCTET STRING
  • legacy fallback: decrypts an authentic pre-fix blob with 16-byte nonce
  • surfaces a malformed GCMParameters instead of reading it as a raw IV
  • rejects a tampered ciphertext
  • rejects a substituted nonce
  • honors the RFC 5084 icvLen default of 12 when the INTEGER is absent
  • round-trips a private key through the PKCS#12 privacy layer

CMS EnvelopedData path (#287):

  • emits the inner AES-GCM AlgorithmIdentifier.algorithmParams as a SEQUENCE
  • normalizes a lower-case algorithm name and still emits a SEQUENCE
  • full round-trip via RSA-OAEP cert recipient: encrypt → BER → parse → decrypt
  • legacy fallback: decrypts a blob whose params are a bare OCTET STRING
  • honors the RFC 5084 icvLen default of 12 when the INTEGER is absent

Both normalizes a lower-case algorithm name cases cover the same gap on the two
paths: getOIDByAlgorithm matches on algorithm.name.toUpperCase(), so aes-gcm
resolves to the AES-GCM OID. The parameter encoding follows that normalization,
otherwise a case variant emits the AES-GCM OID beside a bare OCTET STRING.

encodes to the DER an RFC 5084 reader expects pins the two shapes the encoder
emits as literal hex, so a change to the encoder cannot quietly agree with a
matching change to the decoder and still fail a third-party reader.

src/GCMParams.ts is at 100% line, branch and function coverage, and the changed
regions of the other two files carry no uncovered line or branch. Full suite:
1764 passing, 57 skipped. Lint clean. tsc --noEmit clean.

The adjacent TODO

encryptEncryptedContentInfo carries a note directly above the line this changes:

// TODO Should we reuse iv from parameters.contentEncryptionAlgorithm or use it's length for ivBuffer?

It is left in place. Both readings resolve to "no", and the change that would
settle the question properly is a breaking one.

Reusing the caller's iv. ContentEncryptionAesGcmParams is
AesGcmParams & AesDerivedKeyParams, and AesGcmParams declares iv as
required, so every caller already supplies a value that the method discards.
Whatever satisfies the type is in there today: a zero buffer, a constant, a value
shared across calls. Honoring the field would turn those into fixed GCM nonces at
upgrade, and a repeated key-nonce pair under GCM exposes the hash subkey and
admits forgery, not only a plaintext XOR. Nothing collides at present because the
key differs per call, from a fresh 64-byte PBKDF2 salt here and a generated
content-encryption key in EnvelopedData.encrypt. That makes nonce hygiene a
property of salt and key generation rather than of the nonce, and it holds only
until a caller can supply either one.

Taking the length from that field. A 96-bit nonce is the size RFC 5084
recommends for AES-GCM. This sizes the buffer from the algorithm, 12 bytes for
GCM and 16 otherwise, in place of the flat 16 that preceded it. Reading the
length from the caller would put the shorter cases back.

What remains is that iv is required and ignored. Making it optional changes the
published types, so it is not attempted here.

Refs #486
Refs #287

@coveralls

coveralls commented Apr 28, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 72.4% (+0.1%) from 72.274% — dotCooCoo:feat/aes-gcm-parameters into PeculiarVentures:master

@dotCooCoo
dotCooCoo force-pushed the feat/aes-gcm-parameters branch from 1f38dbb to 6ffa05a Compare August 20, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants