Skip to content

fix(sdk): use a distinct IV for each key access object's metadata (DSPX-4492) - #1085

Open
dmihalcik-virtru wants to merge 6 commits into
mainfrom
DSPX-4492-kao-metadata-iv
Open

dmihalcik-virtru wants to merge 6 commits into
mainfrom
DSPX-4492-kao-metadata-iv

Conversation

@dmihalcik-virtru

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

Copy link
Copy Markdown
Member

Part of DSPX-4492. Stacked on #1077.

What

Hardening, not a fix for an exploitable bug.

SplitKey.getKeyAccessObjects (lib/tdf3/src/models/encryption-information.ts) used to encrypt every key access object's (KAO's) metadata with one fixed IV, keyInfo.unwrappedKeyIvBinary. That IV comes from generateKey(). It now draws a fresh random IV for each metadata encryption.

Why

KAOs that share a split ID share that split's key, for example one split wrapped for several KASes. With a single split, that key is the payload key itself. KAOs on different splits use different keys, so the shared IV never mattered for them.

Today's risk is low. Client.encrypt gives every KAO the same metadata. KAOs that share a split therefore encrypt the same plaintext under the same key and IV, which produces identical ciphertext. That reveals only that the metadata matches, which is already evident. Nothing the SDK writes is affected.

The latent risk: code that builds SplitKey.keyAccess directly with different metadata per KAO would reuse an AES-GCM nonce for different plaintexts. That leaks the XOR of the plaintexts and the GHASH key. A distinct IV per KAO removes that trap. It also makes the DSPX-4492 invocation accounting accurate: each metadata encryption is its own random-IV invocation.

Invocation accounting

With a single split, every KAO's metadata is one AES-GCM invocation under the payload key. The writer from #1077 assumed exactly one. With N KAOs, a write could therefore reach (2^32 - 1) + N invocations, which is over the SP 800-38D §8.3 cap of 2^32.

The second commit counts invocations instead of payload segments:

  • New constant: MAX_INVOCATIONS_PER_KEY = 2^32. MAX_PAYLOAD_SEGMENTS_PER_KEY stays as 2^32 - 1, the single-KAO case, to keep the names the draft stack expects.
  • New method: SplitKey.payloadKeyMetadataInvocations(). It returns one per KAO when there is a single split, and zero when there are several, because each split's share is then a different, uniformly random key rather than the payload key.
  • Counter: writeStream starts its counter at that value. This is the same pre-increment a future §8.2.1 counter would need, with metadata taking invocations 0 through N-1.
  • Test seam: the internal override is renamed maxPayloadSegments → maxInvocationsPerKey.

Compatibility

Readers need no change. The IV was already written in two places: as the ciphertext's 12-byte prefix, and in the iv field of encryptedMetadata. Both now carry the new per-KAO IV, so readers that use either one keep working. KeyInfo.unwrappedKeyIvBinary and the manifest's method.iv are left as they are.

How to test

cd lib && npm test

tests/mocha/unit/encryption-information.spec.ts

  • Two KAOs that share a split and have different metadata get different IVs.
  • Each ciphertext's IV prefix matches its iv field.
  • Neither IV is the old fixed IV.
  • Each ciphertext decrypts to its own metadata.
  • payloadKeyMetadataInvocations returns the right count for single and multiple splits.

tests/mocha/unit/tdf.spec.ts

  • With two KAOs on one split, the write allows one fewer payload segment.
  • With two splits, metadata doesn't count against the payload key.

These tests fail if the fixed IV is put back, or if the counter always starts at 1.

Risk

Low. This touches crypto: KAO metadata is now encrypted with different IVs, but the format is unchanged and readers already take the IV from the ciphertext or from the iv field.

Also fixes DSPX-5238: a custom encrypt keyMiddleware could reuse a DEK and metadata IV across objects (AES-GCM nonce reuse). It was found independently in the DSPX-5124 analysis (virtru-corp/adr#377).

Summary by CodeRabbit

  • New Features
    • Stream encryption now enforces an AES-GCM usage limit per key and can stop before exceeding the configured limit.
    • Key-access metadata encryption uses a separate IV for each metadata object.
    • Added a dedicated error for when encryption reaches the invocation limit.
  • Bug Fixes
    • Improved safeguards for writes involving multiple key-access objects, including metadata encrypted with split keys.

@dmihalcik-virtru
dmihalcik-virtru requested a review from a team as a code owner October 14, 2026 14:12
@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f137b61f-c8b2-49eb-9429-370d64c7e10b
📥 Commits

Reviewing files that changed from the base of the PR and between 63a9a8b and 1a7fdd5.

📒 Files selected for processing: 7
  • lib/src/errors.ts
  • lib/src/index.ts
  • lib/tdf3/src/models/encryption-information.ts
  • lib/tdf3/src/tdf.ts
  • lib/tests/mocha/unit/encryption-information.spec.ts
  • lib/tests/mocha/unit/errors.spec.ts
  • lib/tests/mocha/unit/tdf.spec.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

The change adds fresh IVs for encrypted key-access metadata and tracks AES-GCM invocations across metadata and payload segments. writeStream validates the invocation limit and raises IvExhaustionError before encrypting a segment that would exceed the budget.

Changes: AES-GCM Invocation Budget

Layer / File(s) Summary
Metadata IVs and invocation accounting
lib/tdf3/src/models/encryption-information.ts, lib/tests/mocha/unit/encryption-information.spec.ts
SplitKey counts key-access metadata invocations when all key-access objects use one split. Each encrypted key-access object now receives a fresh IV. Tests check IV uniqueness, ciphertext decryption, and invocation counts.
Stream invocation budget enforcement
lib/src/errors.ts, lib/src/index.ts, lib/tdf3/src/tdf.ts, lib/tests/mocha/unit/errors.spec.ts, lib/tests/mocha/unit/tdf.spec.ts
The exported invocation limit is 2^32. writeStream validates the configured limit, accounts for metadata invocations, and reserves an invocation before each payload encryption. The tests cover reservation limits and stream behavior at exhaustion.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant writeStream
  participant SplitKey
  participant reservePayloadSegment
  participant AESGCM
  writeStream->>SplitKey: Read payloadKeyMetadataInvocations()
  writeStream->>reservePayloadSegment: Reserve invocation before each segment
  reservePayloadSegment-->>writeStream: Return updated count or throw IvExhaustionError
  writeStream->>AESGCM: Encrypt payload segment
Loading

Possibly related PRs

  • opentdf/web-sdk#1077: Adds the initial payload invocation budget and IvExhaustionError, which this change extends to include metadata invocations.

Suggested reviewers: ivanovspvirtru, elizabethhealy, sujankota

Merge Risk: ⚪ Minimal · up to 1a7fd

The identified budget inconsistency is limited to a test setting and does not block merging.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the primary change: using a distinct IV for each key access object's metadata. It matches the pull request objectives and changeset.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
⚔️ Resolve merge conflicts: 💡
  • Resolve merge conflict in branch DSPX-4492-kao-metadata-iv
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • 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

A rabbit checks each IV twice,
Then counts each segment, neat and precise.
Fresh keys of access get fresh starts,
The stream stops short when budget parts.
The burrow hums; no nonce repeats.

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

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4492-kao-metadata-iv branch from cec178b to cdd1c4c Compare October 14, 2026 14:18
@dmihalcik-virtru dmihalcik-virtru changed the title fix(sdk): encrypt each key access object's metadata under its own IV (DSPX-4492) fix(sdk): use a distinct IV for each key access object's metadata (DSPX-4492) Oct 14, 2026
@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

dmihalcik-virtru and others added 3 commits October 22, 2026 16:23
…32 (DSPX-4492)

The TDF writer encrypts each payload segment with AES-GCM under one DEK
using a random 96-bit IV. By the birthday bound, k invocations collide
with probability about k^2 / 2^97; NIST SP 800-38D section 8 caps this
at 2^-32. Add MAX_PAYLOAD_SEGMENTS_PER_KEY = 2^32 - 1 (invocation 0 is
reserved for key access metadata) and have writeStream count every
segment encryption attempt, throwing the new public IvExhaustionError
before encrypting the segment that would exceed the cap. This applies
to known- and unknown-length streams alike, since both share the same
segment path.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…Stream wiring (DSPX-4492)

- Rewrite the MAX_PAYLOAD_SEGMENTS_PER_KEY and IvExhaustionError docs around
  the §8.3 2^32-invocation cap, which is the binding limit, rather than a
  derived ~2^32.5 birthday bound; note the budget is per writeStream call.
- Replace the log2 test with MAX + 1 <= 2^32.
- Add an @internal maxPayloadSegments test seam, checked up front in
  writeStream so it can lower the cap but never raise or disable it, and
  writeStream tests that refuse the segment past the budget (full and short
  final segments) without encrypting it.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
… docs (DSPX-4492)

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…PX-4492)

Hardening. SplitKey.getKeyAccessObjects encrypted every KAO's metadata
with the one fixed IV from generateKey(). KAOs that share a split share
its key (with a single split, the payload key itself). Client.encrypt
gives every KAO the same metadata, so this only ever produced identical
ciphertexts and leaked nothing; but per-KAO metadata, possible when
building SplitKey.keyAccess directly, would reuse an AES-GCM nonce.

Draw a fresh random IV per metadata encryption. Readers are unaffected:
the IV was already written both as the ciphertext's 12-byte prefix and
in the encryptedMetadata iv field, and both now carry the new IV.

Also correct the segment-cap docs: metadata takes one invocation per KAO
sharing the data key, not exactly one.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…et (DSPX-4492)

With a single key split, every KAO's metadata is encrypted under the
payload key itself, one AES-GCM invocation each. The writer assumed
exactly one, so N KAOs let a write reach (2^32 - 1) + N invocations,
over the SP 800-38D §8.3 cap of 2^32.

Count invocations rather than payload segments: add
MAX_INVOCATIONS_PER_KEY = 2^32 (MAX_PAYLOAD_SEGMENTS_PER_KEY stays as
the single-KAO case, 2^32 - 1) and start writeStream's counter at
SplitKey.payloadKeyMetadataInvocations(): one per KAO with a single
split, none with several, since split shares are independent keys.
This is the pre-increment a future §8.2.1 counter would need too.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4492-kao-metadata-iv branch from b8e1e1c to 1a7fdd5 Compare October 23, 2026 00:23
@sonarqubecloud

Copy link
Copy Markdown

Base automatically changed from DSPX-4492-exhausted to main October 23, 2026 17:44

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