Skip to content

feat(sdk): add AesGcmEncryptor to CryptoService (DSPX-4496) - #1063

Draft
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4496-aes-gcm-encryptor
Draft

dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4496-aes-gcm-encryptor

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

What

Adds an optional, per-key AES-GCM encryptor to the CryptoService interface. The encryptor owns its IV construction: no IV goes in, and the IV only comes back as output.

interface CryptoService {
  // ...existing members
  createAesGcmEncryptor?: (key: SymmetricKey) => Promise<AesGcmEncryptor>;
}

type AesGcmEncryptor = {
  readonly ivConstruction: 'deterministic' | 'rbg'; // NIST SP 800-38D §8.2.1 or §8.2.2
  readonly invocationLimit: number;                 // hard ceiling per encryptor, counting attempts
  readonly encrypt: (plaintext: Uint8Array) => Promise<AesGcmEncryptResult>; // { readonly iv, ciphertext, tag }
};
  • createAesGcmEncryptorFor(cryptoService, key) uses the provider's encryptor when the provider has one. Otherwise it builds §8.2.1 deterministic IVs through GcmIvCounter over the provider's own randomBytes and caller-IV encrypt: a random 8-byte fixed field drawn per encryptor, then a 4-byte big-endian counter, with an invocationLimit of 2^32.
  • DefaultCryptoService deliberately does not implement createAesGcmEncryptor, and takes that fallback. If it did, a provider written as { ...DefaultCryptoService, encrypt: hsmEncrypt } would inherit an encryptor bound to the WebCrypto encrypt, and the SDK would prefer it, silently bypassing the override.
  • guardAesGcmEncryptor wraps every encryptor:
    • it reads ivConstruction and invocationLimit once, and rejects an unknown construction or a limit that is not an integer from 1 to 2^32;
    • it counts attempts before calling down, failed and concurrent ones included, and throws the new IvExhaustionError at the limit;
    • it checks that each result has a 12-byte IV, a 16-byte tag, and ciphertext the same length as the plaintext, all as Uint8Arrays;
    • it copies the IV and tag, so a provider that reuses its buffers cannot change them after the check.
  • New public types: AesGcmEncryptor, AesGcmEncryptResult and AesGcmIvConstruction from tdf3; IvExhaustionError from the package root.
  • The AES-GCM constants live in a new import-free module, ciphers/aes-gcm-limits.ts, so sizing code later in the series can use them without an import cycle.
  • The design note is in spec/DSPX-4496.md.

The writer doesn't use any of this yet. #1018 routes encryption through it, so this PR changes nothing at runtime.

Why

The DSPX-4492 ADR allows both §8.2.1 deterministic and §8.2.2 RBG IVs. FIPS 140-3 IG C.H ties approved AES-GCM encryption to IVs generated inside the module, and deterministic counter state is security-relevant state. If the SDK builds the IV and passes it in, the IV comes from outside the module.

Making the IV an output of a per-key encryptor lets any provider keep the whole construction inside its boundary. The SDK only looks at what the encryptor reports; it never needs to know which provider it has. SP 800-38D §8.3 caps RBG-based IVs at 2^32 invocations per key; per the ADR, providers using random IVs should report invocationLimit ≤ 2^26. The limit is enforced per encryptor, not across encryptors that share a key.

Open question for reviewers

ivConstruction: 'deterministic' | 'rbg' versus a boolean such as deterministicCounter or needsRbgIvs. See the spec.

How to test

cd lib && npm test. tests/mocha/unit/aes-gcm-encryptor.spec.ts and the GcmIvCounter tests in tdf.spec.ts cover:

  • the default path's IV layout, and decrypting its output;
  • a fresh fixed field for each encryptor;
  • a provider spread from DefaultCryptoService keeping its encrypt and randomBytes overrides;
  • splitting off a tag that a caller-IV service leaves inside the payload, and rejecting a service that returns no tag;
  • an IV staying spent when the provider's encrypt fails;
  • concurrent calls: distinct, consecutive IVs, and no overshooting the limit;
  • the limit, including failed attempts, reading the limit only once, and invalid limits (NaN, Infinity, strings, undefined, out of range);
  • rejecting malformed results, including a ciphertext with the tag still attached and a missing result;
  • the guard being applied to the provider's encryptor;
  • counter carry and the 2^32 ceiling.

Risk

This adds to the public crypto interface, so please review the API shape carefully. The runtime risk is nil until #1018.

DSPX-4496 series

Part PR Base Depends on
1 of 6 #1059 reject non-positive segment sizes main merged
2 of 6 #1060 require 12-byte AES-GCM IVs main merged
3 of 6 #1061 calculate encrypted segment size main merged
4 of 6 #1062 default segment size 2 MiB main merged
5 of 6 #1063 AesGcmEncryptor SPI main —
6 of 6 #1018 writer IVs from the CryptoService encryptor DSPX-4496-aes-gcm-encryptor #1063

The large-file work (#1019 through #1024) is stacked on #1018. #1022 uses the payload encryptor's invocationLimit for its up-front byte ceiling.

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4496-aes-gcm-encryptor branch from 442bc14 to 1b7c6e9 Compare October 7, 2026 13:30
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4496-encrypted-segment-size branch from 32d08b0 to 5281f17 Compare October 7, 2026 13:30
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4496-encrypted-segment-size branch from 5281f17 to e6951e2 Compare October 7, 2026 17:04
Base automatically changed from DSPX-4496-encrypted-segment-size to main October 8, 2026 15:31
Add an optional `CryptoService.createAesGcmEncryptor(key)` that returns a
per-key AES-GCM encryptor. The encryptor owns its IV construction, either
NIST SP 800-38D 8.2.1 deterministic or 8.2.2 RBG, and reports it, together
with its invocation limit, as read-only properties. No IV goes in; the IV
only comes out. That lets a module-backed provider keep IV generation
inside its own boundary, as FIPS 140-3 IG C.H requires.

- `DefaultCryptoService` implements it with `GcmIvCounter`: a random 8-byte
  fixed field drawn per encryptor, then a 4-byte big-endian counter.
- `createAesGcmEncryptorFor` falls back to the same deterministic
  construction over the caller-IV `encrypt` for providers without the
  method.
- `guardAesGcmEncryptor` wraps every encryptor. It enforces
  `invocationLimit` with the new IvExhaustionError, counting failed attempts
  too, and checks the IV, ciphertext and tag lengths of every result.

The writer does not use any of this yet; a follow-up change routes payload
and metadata encryption through it. The design note is
spec/DSPX-4496.md.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>

wip: review fixes for #1063
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4496-aes-gcm-encryptor branch from 1b7c6e9 to a05aa55 Compare October 15, 2026 13:11
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

1 participant