Skip to content

feat(sdk): stateful per-key AES-GCM IV counter (alternative to #4118) - #4121

Draft
dmihalcik-virtru wants to merge 14 commits into
mainfrom
DSPX-4493-stateful-iv
Draft

dmihalcik-virtru wants to merge 14 commits into
mainfrom
DSPX-4493-stateful-iv

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

What this is

This is an experiment stacked on #4118, not a replacement. It keeps the NIST SP 800-38D §8.2.1 deterministic IV construction, but makes it stateful per sealer instead of stateless per message:

IV = fixed field (64b, crypto/rand, one per sealer) ‖ invocation counter (32b, atomic, per sealer)

Each ocrypto.AesGcmSealer owns its key, its fixed field and an atomic.Uint64 counter. Seal(plaintext) takes the next counter value (one Add per seal) and has no ordinal parameter.

Does it simplify things?

Yes. In production code, compared with main:

files +/-
#4118 (stateless) 11 +1379 / -129
this branch (stateful) 8 +808 / -91

Overall diff against #4118: 11 files, +586 / -1207.

Removed compared with #4118:

  • MessageID, NewMessageID, MessageIDFromBytes, ErrInvalidMessageID
  • the key→sealer registry shared between CreateTDF and the writer
  • the metadataSealer closure threaded through resolve, resolvePolicyAndKeyAccess and buildKeyAccessObjects (key_splitter.go goes back to main)
  • the metadata cache/pin, and ErrChunkedMetadataChanged
  • segmentPart / metadataPart, and the reserved part 0
  • the long retry-safety comment in WriteSegment. A retry now takes a fresh counter value by construction.
  • ErrMessagePartsExhausted and ErrMessageInvocationsExhausted, merged into ErrSealerExhausted. The deterministic and fallback paths now count the same way.
  • errSealerKeySize

Kept from #4118:

  • the FIPS policy+capability probe, and the random-IV fallback
  • the header/zipHeader naming
  • encodeEncryptedMetadata
  • the index ceiling in WriteSegment

Rebased onto #4203, and FIPS mode keeps the 2^32-1 cap (option 2b)

Rebased onto main, which now includes #4203 (ErrAESGCMExhausted, a 2^32-1 cap on segment encryptions per DEK). The last commit brings this branch in line with option 2b of the Go supplement to the AES-GCM IV ADR (virtru-corp/adr#373, stacked on virtru-corp/adr#368):

  • ocrypto.MaxSeals() is 2^32-1 on both paths. On the random-IV (FIPS) fallback that sits at P ≈ 2^-33 when a writer reaches the cap, inside SP 800-38D's 2^-32. This is the limit feat(sdk): return ErrAESGCMExhausted when IV collision risk exceeds 2^-32 (DSPX-4492) #4203 already enforced on every build.
  • No derived segment sizing. The default stays at 2 MiB and the maximum at 4 MiB in every mode, and maxPayloadSegments is MaxInt32 again. FIPS mode makes no difference an application can see.
  • One counter. The writer's own counter from feat(sdk): return ErrAESGCMExhausted when IV collision risk exceeds 2^-32 (DSPX-4492) #4203 is gone, and the DEK sealer's counter is the only budget. ocrypto.ErrSealerExhausted reaches callers wrapped as sdk.ErrAESGCMExhausted. Tests reach the cap by injecting a sealer instead of lowering the writer's cap.
  • The 2^26 cap with 4 MiB derived segments is recorded in the ADR as rejected. It would cut the FIPS maximum from about 8 PiB to 128 TiB at 2 MiB segments.

Trade-offs

  • Same-key uniqueness across sealers is probabilistic. Metadata gets its own throwaway sealer. With a single split, that sealer shares the payload key, so the two are kept apart only by independent 64-bit fixed fields, a collision chance of about 2^-64. In feat(sdk): derive AES-GCM segment IVs from a message id and part number #4118 this was structural (part 0 vs. part ≥1).
  • The IV no longer encodes the segment index. Counters follow call order, so concurrent or out-of-order writers get a permutation. Readers never relied on the index, because they still take the IV off the wire.
  • Retries spend seals. A failed attempt that is retried uses a counter value, so the exhaustion bound depends on failure history. This is the same attempt-counting rule feat(sdk): return ErrAESGCMExhausted when IV collision risk exceeds 2^-32 (DSPX-4492) #4203 introduced.
  • FIPS margin. On the fallback, a writer at the cap has a 2× margin under NIST's ceiling, not the 4096× that 2^26 would give. A caller that wants more margin can use larger segments.

ADR

adr/decisions/2026-09-28-deterministic-gcm-iv-construction.md has been rewritten on this branch. It now chooses the stateful option and lists #4118's stateless design as the rejected alternative. If we keep #4118 instead, drop the ADR commit.

Testing

After the rebase and the 2b commit: go test -race passes for lib/ocrypto and sdk, and both also pass under GODEBUG=fips140=on. TestREADMECodeBlocks and the otdfctl tests pass. golangci-lint reports no new issues. nsqzqtlq does not build sdk on its own (the ocrypto API changes one commit before the SDK catches up), as was already the case before the rebase. The spec/DSPX-4493*.md files and the ADR now describe the stateful design and option 2b.

Before the rebase:

  • make fmt is clean. golangci-lint reports no issues in changed files; the pre-existing issues are in untouched files.
  • go test -race passes for lib/ocrypto, lib/flattening, lib/identifier, sdk, examples and otdfctl.
  • service unit tests pass. service/integration and service/entityresolution/integration need Postgres/Keycloak and were not run locally, and neither was lib/fixtures, which needs Keycloak.
  • cd sdk && go test -run TestREADMECodeBlocks passes.
  • GODEBUG=fips140=on go test ./... passes in both sdk and lib/ocrypto, which covers the fallback path.

@coderabbitai

coderabbitai Bot commented Sep 29, 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.

@github-actions github-actions Bot added comp:sdk A software development kit, including library, for client applications and inter-service communicati comp:lib:ocrypto size/l labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 245.748687ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 136.205783ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 430.848852ms
Throughput 232.10 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 59.310678882s
Average Latency 591.87206ms
Throughput 84.30 requests/second

@dmihalcik-virtru
dmihalcik-virtru changed the base branch from DSPX-4493 to main September 29, 2026 21:26
@github-actions

Copy link
Copy Markdown
Contributor

X-Test Failure Report

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 418.466817ms
Throughput 238.97 requests/second

TDF3 Benchmark Results

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 46.262009852s
Average Latency 461.765514ms
Throughput 108.08 requests/second

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 437.379878ms
Throughput 228.63 requests/second

TDF3 Benchmark Results

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 46.409024827s
Average Latency 463.170567ms
Throughput 107.74 requests/second

dmihalcik-virtru added a commit that referenced this pull request Oct 15, 2026
…r and 2b

Rewrite the spec and plan around what #4121 does: a per-key counter in
ocrypto.AesGcmSealer, a throwaway sealer for metadata, and one 2^32-1 cap
in every FIPS mode. The #4118 design survives only as history and as a
rejected option. Tighten the ADR without changing its decisions.

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

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 243.881304ms
Throughput 410.04 requests/second

TDF3 Benchmark Results

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 28.187218323s
Average Latency 281.347919ms
Throughput 177.39 requests/second

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 426.466643ms
Throughput 234.48 requests/second

TDF3 Benchmark Results

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 48.070794217s
Average Latency 479.828164ms
Throughput 104.01 requests/second

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Adds an opaque, keyed sealer that derives each part's AES-GCM IV from a
per-message random field and a big-endian part number (NIST SP 800-38D
8.2.1) rather than drawing it from an RBG (8.2.2). Fusing the key and the
message ID at construction makes cross-message IV reuse unconstructible.

Where Go refuses caller-supplied AES-GCM IVs, or where FIPS 140-3 is
enabled and a caller-supplied IV would make encryption a non-approved
service, the sealer falls back to random IVs internally. The fallback is
total -- same API, same wire bytes -- and surfaces only as a smaller
MaxMessageParts (2^26, for a 2^-45 collision bound) which doubles as an
encryption budget on that path.

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

Payload segment i is now sealed at message part i+1 and key access object
metadata at part 0, all under one per-TDF message id, replacing the
per-segment random nonce: NIST SP 800-38D 8.2.1 in place of 8.2.2. The wire
format is unchanged. The IV is still the segment's 12-byte prefix and readers
take it off the wire as they always have, so this is writer-side only, with no
manifest or spec version change.

- part = index + 1 is a pure function, so sparse, out-of-order, concurrent and
  retried writes all stay correct where a stateful counter would not.
- One sealer registry per TDF, keyed by a copy of the key bytes, so the
  single-split case -- where the sole share is the DEK verbatim -- reaches one
  sealer for both the metadata and the payload, and part 0 stays reserved.
- GetManifest and Finalize re-resolve key access on every call, so the
  encrypted metadata is pinned on the first build and a second value is
  refused with ErrChunkedMetadataChanged rather than sealed at a spent part.
- maxPayloadSegments and defaultSegmentSize are derived from
  ocrypto.MaxMessageParts. They are the whole application-visible surface of
  the RBG fallback ocrypto takes where FIPS 140-3 is enabled.

DSPX-4493

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

resolve
Fills in the DSPX-4493 spec, checks in the implementation plan beside it, and
adds an ADR covering the two decisions this turns on: the IV construction
itself (NIST SP 800-38D 8.2.1, fixed field || part number) and what to do
where Go will not allow a caller-supplied IV.

The FIPS section carries the cap arithmetic. On the RBG fallback
MaxMessageParts drops to 2^26, six bits under NIST's approved 2^32 ceiling --
a 4096x margin on collision probability, P ~ 2^-45 -- which still reaches
256 TiB at the maximum segment size. Also records that the fallback and that
cap are Go-specific and should not be mirrored by DSPX-4495 or DSPX-4496.

Validation also records the checks that needed a build rather than a test:
both modules under GOFIPS140=certified with no GODEBUG override (Go 1.27.1,
certified module v1.0.0-c2097c7c), and cross-mode interop in both directions,
which is the claim that the fallback changes how the header is chosen and
never what it means.

DSPX-4493

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Replace the stateless message-id || part-number construction with a
sealer that owns its key, a random 64-bit fixed field, and an atomic
invocation counter: IV = fixed || counter++. Callers no longer supply an
ordinal, so retried, repeated and out-of-order encryptions can never
reuse an IV. On the FIPS random-IV fallback the same counter is the
encryption budget, so the two exhaustion errors merge into
ErrSealerExhausted. MaxMessageParts becomes MaxSeals.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…umbers, and metadata pin

With the IV counter living inside the key's sealer, the writer holds one
sealer for the DEK and key access metadata is sealed by a throwaway
sealer of its own. That removes the message ID, the key->sealer registry
shared between CreateTDF and the writer, the metadataSealer closure
threaded through key access resolution, the per-key metadata cache, the
reserved metadata part and segmentPart, and ErrChunkedMetadataChanged:
re-sealing metadata on every manifest build now just takes a fresh IV.
Retried segments likewise take a fresh IV, so retry safety no longer
depends on WriteSegment's error handling.

The trade: in a single-split TDF the metadata and payload sealers share
a key and count independently, kept apart only by their random 64-bit
fixed fields, and an IV no longer identifies its segment index.

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

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

Align the Go SDK with option 2b of the AES-GCM IV ADR (virtru-corp/adr#373):

- ocrypto: MaxSeals is 2^32-1 on both the deterministic and the random-IV
  (FIPS) path, the cap #4203 already enforced on random IVs. At the cap the
  random path sits at P ~ 2^-33, inside SP 800-38D's 2^-32.
- sdk: drop the derived default segment size; it is 2 MiB (max 4 MiB) again
  in every mode, and maxPayloadSegments is the archive's MaxInt32 again.
- sdk: drop the writer's own encryption counter. The DEK sealer's counter is
  the only budget, and ocrypto.ErrSealerExhausted is reported wrapped as
  ErrAESGCMExhausted. Tests reach the cap through an injected sealer instead
  of a lowered writer cap.
- adr: record the decision and the rejected 2^26 cap.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…r and 2b

Rewrite the spec and plan around what #4121 does: a per-key counter in
ocrypto.AesGcmSealer, a throwaway sealer for metadata, and one 2^32-1 cap
in every FIPS mode. The #4118 design survives only as history and as a
rejected option. Tighten the ADR without changing its decisions.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
On the FIPS random-IV path Seal returned a header slice whose spare
capacity ran into the ciphertext, so appending to the header overwrote
it. Clip the header's capacity.

Add tests the existing ones could not fail:
- the production 2^32-1 ceiling on both paths, including counter 2^32,
  which must not truncate back to 0;
- NewAESGcmSealer itself selecting random IVs under GODEBUG=fips140=on
  and =only, by re-running the test binary in each mode;
- appending to a random-path header leaving the ciphertext intact.

Cite SP 800-38D 8.2.1, not 8.3, for the counter path's bound.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
The docs said an unmeasured stream in CreateTDF stops at the DEK sealer
with ErrAESGCMExhausted. WriteSegment refuses index >= MaxInt32 first,
with ErrChunkedSegmentIndexExhausted, and CreateTDF seals each index
once, so only ChunkedWriter retries can exhaust the sealer. Say so on
maxPayloadSegments and ErrAESGCMExhausted, and make the latter's message
name the remedy rather than an IV collision limit.

Re-export ErrChunkedSegmentIndexExhausted from the experimental writer
as ErrSegmentIndexExhausted, list it in the writer's error conditions,
and fix the stale collision-probability wording on its ErrAESGCMExhausted.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- spec: an unmeasured stream in CreateTDF stops at the MaxInt32
  segment-index limit, not at the sealer; the acceptance item that
  claimed otherwise only held with an injected low-limit sealer.
- spec/ADR: callers never supply an IV (Seal does return one).
- spec: list ErrChunkedSegmentIndexExhausted as a public addition, scope
  metadata re-sealing to ChunkedWriter, and say what wraps what.
- ADR: 2^-32 / 2^-45 is an 8192x margin, not 4096x; Go's counter-nonce
  GCM is internal, not unexported.
- plan: segment sizes are the same in every FIPS mode, not fixed.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
FIPS 140-3 mode sends every AES-GCM sealer down the random-IV path,
which unit tests otherwise reach only through an injected sealer. Run
the sdk and lib/ocrypto suites a second time with fips140=on so the
whole writer and reader round-trip is checked on that path.

fips140=only is left out: it also refuses the RSA-OAEP SHA-1 key wrap
the TDF format uses, which fails unrelated tests.

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

Copy link
Copy Markdown
Contributor

⚠️ Govulncheck found vulnerabilities ⚠️

The following modules have known vulnerabilities:

  • otdfctl
  • sdk

See the workflow run for details.

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 234.52296ms
Throughput 426.40 requests/second

TDF3 Benchmark Results

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 25.792130973s
Average Latency 257.377866ms
Throughput 193.86 requests/second

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

comp:lib:ocrypto comp:sdk A software development kit, including library, for client applications and inter-service communicati size/l

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant