Skip to content
Draft
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
5 changes: 5 additions & 0 deletions lib/tdf3/src/ciphers/aes-gcm-cipher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {

const KEY_LENGTH = 32;
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;

type ProcessGcmPayload = {
payload: Binary;
Expand All @@ -39,6 +40,10 @@ export class AesGcmCipher extends SymmetricCipher {
this.keyLength = KEY_LENGTH;
}

override encryptedPayloadSize(plaintextSize: number): number {
return IV_LENGTH + plaintextSize + AUTH_TAG_LENGTH;
}

/**
* Encrypts the payload using AES w/ GCM mode. This function will take the
* result from the crypto service and construct the payload automatically from
Expand Down
58 changes: 58 additions & 0 deletions lib/tdf3/src/ciphers/gcm-iv-counter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ConfigurationError } from '../../../src/errors.js';

const GCM_IV_LENGTH = 12;

/**
* Conservative ceiling for authenticated-encryption invocations under one
* BaseTDF payload key. Invocation zero is reserved for encrypted metadata.
*/
export const MAX_GCM_INVOCATIONS_PER_KEY = 2 ** 32;

/** A deterministic, unsigned 96-bit big-endian AES-GCM IV counter. */
export class GcmIvCounter {
private nextInvocation: number;

constructor(
firstInvocation = 1,
private readonly limit = MAX_GCM_INVOCATIONS_PER_KEY
) {
if (!Number.isInteger(firstInvocation) || firstInvocation < 1) {
throw new ConfigurationError(
`Invalid first invocation: ${firstInvocation}; invocation 0 is reserved for metadata`
);
}
if (!Number.isInteger(limit) || limit < firstInvocation) {
throw new ConfigurationError(
`Invalid invocation limit: ${limit}; must be at least ${firstInvocation}`
);
}
if (limit > MAX_GCM_INVOCATIONS_PER_KEY) {
throw new ConfigurationError(
`Invalid invocation limit: ${limit}; exceeds the maximum of ${MAX_GCM_INVOCATIONS_PER_KEY} AES-GCM invocations per key`
);
}
this.nextInvocation = firstInvocation;
}

/** The all-zero IV reserved for encrypted BaseTDF metadata. */
static metadataIv(): Uint8Array {
return new Uint8Array(GCM_IV_LENGTH);
}

/** Return the next payload IV, starting at invocation one. */
next(): Uint8Array {
if (this.nextInvocation >= this.limit) {
throw new ConfigurationError(
`Exceeded the maximum of ${this.limit} AES-GCM invocations for a single key`
);
}

const iv = new Uint8Array(GCM_IV_LENGTH);
new DataView(iv.buffer).setUint32(
GCM_IV_LENGTH - Uint32Array.BYTES_PER_ELEMENT,
this.nextInvocation
);
this.nextInvocation += 1;
return iv;
}
}
2 changes: 2 additions & 0 deletions lib/tdf3/src/ciphers/symmetric-cipher-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export abstract class SymmetricCipher {
return this.cryptoService.generateKey(this.keyLength);
}

abstract encryptedPayloadSize(plaintextSize: number): number;

abstract encrypt(payload: Binary, key: SymmetricKey, iv: Binary): Promise<EncryptResult>;

abstract decrypt(
Expand Down
5 changes: 4 additions & 1 deletion lib/tdf3/src/models/encryption-information.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { ROOT_INTEGRITY_ALGORITHM, SEGMENT_INTEGRITY_ALGORITHM } from '../tdf.js';
import { ConfigurationError } from '../../../src/errors.js';
import { toArrayBuffer } from '../utils/index.js';
import { GcmIvCounter } from '../ciphers/gcm-iv-counter.js';

export type KeyInfo = {
readonly unwrappedKey: SymmetricKey;
Expand Down Expand Up @@ -66,7 +67,9 @@ export class SplitKey {

async generateKey(): Promise<KeyInfo> {
const unwrappedKey = await this.cipher.generateKey();
const unwrappedKeyIvBinary = await this.generateIvBinary();
// A single split uses this same key for metadata and payload encryption.
// Reserve invocation zero for metadata; payload segments begin at one.
const unwrappedKeyIvBinary = Binary.fromArrayBuffer(toArrayBuffer(GcmIvCounter.metadataIv()));
return { unwrappedKey, unwrappedKeyIvBinary };
}

Expand Down
18 changes: 9 additions & 9 deletions lib/tdf3/src/tdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type { AssertionConfig, AssertionKey, AssertionVerificationKeys } from '.
import * as assertions from './assertions.js';
import { Binary } from './binary.js';
import { AesGcmCipher } from './ciphers/aes-gcm-cipher.js';
import { GcmIvCounter } from './ciphers/gcm-iv-counter.js';
import type { SymmetricCipher } from './ciphers/symmetric-cipher-base.js';
import type { DecryptParams } from './client/builders.js';
import { DecoratedReadableStream } from './client/DecoratedReadableStream.js';
Expand Down Expand Up @@ -578,6 +579,7 @@ export async function writeStream(cfg: EncryptConfiguration): Promise<DecoratedR
let fileByteCount = 0;
let aggregateHash422 = '';
const segmentHashList: Uint8Array[] = [];
const payloadIv = new GcmIvCounter();

const zipWriter = new ZipWriter();
const manifest = await _generateManifest(
Expand All @@ -593,14 +595,11 @@ export async function writeStream(cfg: EncryptConfiguration): Promise<DecoratedR
throw new ConfigurationError('internal: please use "loadTDFStream" first to load a manifest.');
}

// determine default segment size by writing empty buffer
// Determine the encrypted segment size without performing a throwaway GCM
// invocation. Reusing that invocation for real payload would repeat its IV.
const { segmentSizeDefault } = cfg;
const encryptedBlargh = await cfg.encryptionInformation.encrypt(
Binary.fromArrayBuffer(new ArrayBuffer(segmentSizeDefault)),
cfg.keyForEncryption.unwrappedKey
);
const payloadBuffer = new Uint8Array(encryptedBlargh.payload.asByteArray());
const encryptedSegmentSizeDefault = payloadBuffer.length;
const encryptedSegmentSizeDefault =
cfg.encryptionInformation.cipher.encryptedPayloadSize(segmentSizeDefault);

// start writing the content
entryInfos[0].filename = '0.payload';
Expand Down Expand Up @@ -816,10 +815,11 @@ export async function writeStream(cfg: EncryptConfiguration): Promise<DecoratedR
bytesProcessed += chunk.length;
cfg.progressHandler?.(bytesProcessed);

// Don't pass in an IV here. The encrypt function will generate one for you, ensuring that each segment has a unique IV.
const iv = payloadIv.next();
const encryptedResult = await cfg.encryptionInformation.encrypt(
Binary.fromArrayBuffer(toArrayBuffer(chunk)),
cfg.keyForEncryption.unwrappedKey
cfg.keyForEncryption.unwrappedKey,
Binary.fromArrayBuffer(toArrayBuffer(iv))
);
const payloadBuffer = new Uint8Array(encryptedResult.payload.asByteArray());
let hash: string;
Expand Down
68 changes: 67 additions & 1 deletion lib/tests/mocha/encrypt-decrypt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getMocks } from '../mocks/index.js';
import type { KasPublicKeyAlgorithm } from '../../src/access.js';
import type { AuthProvider, HttpRequest } from '../../src/auth/auth.js';
import type { KeyInfo } from '../../tdf3/index.js';
import { AesGcmCipher, SplitKey, WebCryptoService } from '../../tdf3/index.js';
import { AesGcmCipher, Binary, SplitKey, WebCryptoService } from '../../tdf3/index.js';
import { Client } from '../../tdf3/src/index.js';
import type {
AssertionConfig,
Expand All @@ -15,6 +15,8 @@ import type {
import { getSystemMetadataAssertionConfig } from '../../tdf3/src/assertions.js';
import type { Scope } from '../../tdf3/src/client/builders.js';
import { NetworkError } from '../../src/errors.js';
import { fromBuffer } from '../../src/seekable.js';
import { ZipReader } from '../../tdf3/src/utils/zip-reader.js';

const Mocks = getMocks();

Expand Down Expand Up @@ -387,6 +389,70 @@ describe('encrypt decrypt test', function () {
}
}

it('writes deterministic payload IVs after the reserved metadata IV', async function () {
const cipher = new AesGcmCipher(WebCryptoService);
const encryptionInformation = new SplitKey(cipher);
const key = await encryptionInformation.generateKey();
const client = new Client.Client({
kasEndpoint: kasUrl,
platformUrl: kasUrl,
dpopKeys: Mocks.entityKeyPair(),
clientId: 'id',
authProvider,
});

const encryptedStream = await client.encrypt({
metadata: Mocks.getMetadataObject(),
wrappingKeyAlgorithm: 'rsa:2048',
offline: true,
scope: { dissem: ['user@domain.com'], attributes: [] },
keyMiddleware: () => Promise.resolve({ keyForEncryption: key, keyForManifest: key }),
windowSize: 3,
source: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('1234567'));
controller.close();
},
}),
});

const encryptedTdf = await encryptedStream.toBuffer();
const { manifest } = encryptedStream;
assert.deepEqual(
Binary.fromBase64(manifest.encryptionInformation.method.iv).asByteArray(),
Array(12).fill(0)
);

const zipReader = new ZipReader(fromBuffer(encryptedTdf));
const centralDirectory = await zipReader.getCentralDirectory();
const { encryptedSegmentSizeDefault, segments } =
manifest.encryptionInformation.integrityInformation;
let encryptedOffset = 0;

for (const [index, segmentInfo] of segments.entries()) {
const encryptedSize = segmentInfo.encryptedSegmentSize ?? encryptedSegmentSizeDefault;
if (encryptedSize === undefined) {
assert.fail(`payload segment ${index} has no encrypted size`);
}
const encryptedSegment = await zipReader.getPayloadSegment(
centralDirectory,
'0.payload',
encryptedOffset,
encryptedSize
);
const expectedIv = new Uint8Array(12);
new DataView(expectedIv.buffer).setUint32(8, index + 1);
assert.deepEqual(
encryptedSegment.subarray(0, 12),
expectedIv,
`payload segment ${index} should use invocation ${index + 1}`
);
encryptedOffset += encryptedSize;
}

assert.lengthOf(segments, 3);
});

it('decrypts when the same KAS wraps the same split twice (DSPX-3379)', async function () {
const cipher = new AesGcmCipher(WebCryptoService);
const encryptionInformation = new SplitKey(cipher);
Expand Down
42 changes: 42 additions & 0 deletions lib/tests/mocha/unit/tdf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { getMocks } from '../../mocks/index.js';
import * as DefaultCryptoService from '../../../tdf3/src/crypto/index.js';
import type { CryptoService } from '../../../tdf3/src/crypto/declarations.js';
import { isMlKemKeyAlgorithm } from '../../../tdf3/src/crypto/declarations.js';
import {
GcmIvCounter,
MAX_GCM_INVOCATIONS_PER_KEY,
} from '../../../tdf3/src/ciphers/gcm-iv-counter.js';

const sampleCert = `
-----BEGIN CERTIFICATE-----
Expand Down Expand Up @@ -78,6 +82,44 @@ describe('TDF', () => {
});
});

describe('GcmIvCounter', () => {
it('reserves IV zero for metadata and starts payload IVs at one', () => {
expect(GcmIvCounter.metadataIv()).to.deep.equal(new Uint8Array(12));

const counter = new GcmIvCounter();
expect(counter.next()).to.deep.equal(Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]));
expect(counter.next()).to.deep.equal(Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]));
});

it('increments across a multi-byte carry boundary', () => {
const counter = new GcmIvCounter(0xffff, 0x10002);

expect(counter.next()).to.deep.equal(
Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])
);
expect(counter.next()).to.deep.equal(Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0]));
expect(counter.next()).to.deep.equal(Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1]));
});

it('stops before the per-key invocation ceiling', () => {
const counter = new GcmIvCounter(MAX_GCM_INVOCATIONS_PER_KEY - 1, MAX_GCM_INVOCATIONS_PER_KEY);

expect(counter.next()).to.deep.equal(
Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff])
);
expect(() => counter.next()).to.throw('AES-GCM invocations for a single key');
expect(() => counter.next()).to.throw('AES-GCM invocations for a single key');
});

it('rejects ranges that include the metadata IV or exceed the invocation ceiling', () => {
expect(() => new GcmIvCounter(0)).to.throw('invocation 0 is reserved for metadata');
expect(() => new GcmIvCounter(2, 1)).to.throw('Invalid invocation limit');
expect(() => new GcmIvCounter(1, MAX_GCM_INVOCATIONS_PER_KEY + 1)).to.throw(
'exceeds the maximum'
);
});
});

describe('fetchKasPublicKey', () => {
it('missing kas names throw', async () => {
try {
Expand Down
61 changes: 61 additions & 0 deletions spec/DSPX-4496.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
ticket: DSPX-4496
title: Switch web-sdk payload segments to deterministic i.v. nonce values
status: draft
authors:
- dmihalcik@virtru.com
branches:
- opentdf/web-sdk:DSPX-4496-count-iv
prs: []
created: 2026-09-08T00:00:00Z
updated: 2026-09-08T00:00:00Z
jira_priority: Critical
---

# Switch web-sdk payload segments to deterministic i.v. nonce values

## Summary

Use deterministic sequential IV generation for BaseTDF payload segments.

## Problem / Motivation

The writer currently draws a random 96-bit AES-GCM IV for every segment. At high segment counts,
random IVs accumulate collision risk according to the birthday bound. Repeating an IV with the same
key catastrophically compromises AES-GCM confidentiality and integrity.

## Proposed Solution

Use an unsigned 96-bit big-endian invocation counter. Reserve IV 0 for encrypted metadata and assign
payload segments IVs 1, 2, 3, and so on in stream order. Stop before 2^32 total invocations under a
payload key. This matches the deterministic BaseTDF convention in the Java SDK.

Calculate AES-GCM segment overhead directly instead of encrypting and discarding a dummy segment;
the dummy operation would otherwise consume an IV before payload encryption.

## Inputs / Outputs / Contracts

No public API or manifest schema changes. Each encrypted payload segment continues to store its
12-byte IV before the ciphertext and 16-byte authentication tag.

## Edge Cases & Constraints

- A payload key must be freshly generated for every TDF. Reusing a key across TDFs would repeat the
deterministic IV sequence.
- Metadata uses IV 0 because its split key is identical to the payload key for a single split.
- The writer refuses to issue an IV at or beyond invocation 2^32.
- Empty payloads do not consume a payload IV.

## Out of Scope

- Changing the BaseTDF wire format or validating deterministic IVs while reading.
- Non-payload AES-GCM operations such as EC/ML-KEM key wrapping, which use different keys.
- Random-access or distributed segment writers; the web SDK writer emits segments sequentially.

## Acceptance Criteria

- [x] Metadata encryption uses IV 0 and payload segments begin at IV 1.
- [x] Consecutive payload segments use consecutive 96-bit big-endian IVs.
- [x] Counter carry and the 2^32 invocation ceiling are unit tested.
- [x] Segment-size calculation does not perform a throwaway encryption.
- [x] Existing BaseTDF round-trip tests continue to pass.
Loading