Skip to content
Merged
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
47 changes: 45 additions & 2 deletions cli/bin/opentdf.bats
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,53 @@
[[ $output == *"Not enough"* ]]
}

@test "requires optional arguments" {
# `encrypt` validates in stages: flags, then oidcEndpoint, then the input file,
# then auth. Each of the next few tests pins one stage by checking that the
# later stages have not run yet -- the assertion on what is *absent* is the one
# that keeps the ordering from drifting back.

@test "rejects an unknown integrity algorithm before anything else" {
run $BATS_TEST_DIRNAME/opentdf.mjs --segmentIntegrityAlgorithm bogus encrypt noone
echo "$output"
[[ $output == *"Invalid values"* ]]
[[ $output == *"bogus"* ]]
[[ $output != *"noone"* ]]
}

@test "rejects a GMAC root signature before touching the file" {
run $BATS_TEST_DIRNAME/opentdf.mjs --rootIntegrityAlgorithm gmac encrypt noone
echo "$output"
[ "$status" -eq 1 ]
[[ $output == *"unsupported root integrity algorithm"* ]]
[[ $output != *"noone"* ]]
}

@test "requires an oidcEndpoint before opening the input file" {
run $BATS_TEST_DIRNAME/opentdf.mjs encrypt noone
echo "$output"
[[ $output == *"must be specified"* ]]
[ "$status" -eq 1 ]
[[ $output == *"oidcEndpoint must be specified"* ]]
[[ $output != *"not accessable"* ]]
}

@test "fails on an unreadable input file" {
run $BATS_TEST_DIRNAME/opentdf.mjs --kasEndpoint "https://example.com" --oidcEndpoint "http://invalid" --concurrencyLimit 1 --auth "b:c" encrypt noone
echo "$output"
[ "$status" -eq 1 ]
[[ $output == *"File is not accessable [noone]"* ]]
}

# Integrity algorithms are accepted in any casing but must reach the manifest
# uppercase, since readers match the spec's spelling exactly. This runs against a
# closed local port, so it gets as far as the create options and then fails to
# reach the KAS -- hence no $status assertion.
@test "normalizes integrity algorithm casing" {
echo "hello" > "$BATS_TEST_TMPDIR/plain.txt"
run $BATS_TEST_DIRNAME/opentdf.mjs --kasEndpoint "http://localhost:9999" --oidcEndpoint "http://localhost:9999" --concurrencyLimit 1 --auth "b:c" --log-level debug --output "$BATS_TEST_TMPDIR/out.tdf" --segmentIntegrityAlgorithm gmac --rootIntegrityAlgorithm HS256 encrypt "$BATS_TEST_TMPDIR/plain.txt"
echo "$output"
[[ $output != *"Invalid values"* ]]
[[ $output == *"\"rootIntegrityAlgorithm\":\"HS256\""* ]]
[[ $output == *"\"segmentIntegrityAlgorithm\":\"GMAC\""* ]]
}

@test "fails with missing file arguments" {
Expand Down
91 changes: 85 additions & 6 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ class InvalidAuthProvider {

const containerTypes = ['tdf3', 'ztdf'];

/** Accepted values for the two integrity-algorithm flags, lowercase by convention. */
const integrityAlgorithms = ['hs256', 'gmac'] as const;

/** Flag values are case-insensitive; normalize before yargs checks `choices`. */
const lowercaseIntegrityAlgorithm = (v: unknown) =>
typeof v === 'string' ? v.toLowerCase() : (v as string);

const parseJwt = (jwt: string, field = 1) => {
return JSON.parse(base64.decode(jwt.split('.')[field]));
};
Expand Down Expand Up @@ -270,11 +277,22 @@ async function parseCreateOptions(argv: Partial<mainArgs>): Promise<CreateOption
return c;
}

async function parseCreateTDFOptions(argv: Partial<mainArgs>): Promise<CreateTDFOptions> {
const c: CreateTDFOptions = await parseCreateOptions(argv);
if (argv.assertions?.length) {
c.assertionConfigs = await parseAssertionConfig(argv.assertions);
}
/**
* The subset of `CreateTDFOptions` derived purely from flags -- no filesystem, no
* network. Split out so `encrypt` can reject a bad flag before it opens anything.
* Synchronous on purpose: the missing `async` is the contract.
*/
type CreateTDFFlags = Pick<
CreateTDFOptions,
| 'wrappingKeyAlgorithm'
| 'mimeType'
| 'tdfSpecVersion'
| 'rootIntegrityAlgorithm'
| 'segmentIntegrityAlgorithm'
>;

function parseCreateTDFFlags(argv: Partial<mainArgs>): CreateTDFFlags {
const c: CreateTDFFlags = {};
if (argv.encapKeyType?.length) {
if (!isPublicKeyAlgorithm(argv.encapKeyType)) {
throw new CLIError('CRITICAL', `Unsupported rewrap key algorithm: [${argv.encapKeyType}]`);
Expand All @@ -291,6 +309,39 @@ async function parseCreateTDFOptions(argv: Partial<mainArgs>): Promise<CreateTDF
if (argv.tdfSpecVersion) {
c.tdfSpecVersion = argv.tdfSpecVersion as never;
}
if (argv.rootIntegrityAlgorithm?.length) {
// Only HMAC is supported for the root integrity algorithm.
if (argv.rootIntegrityAlgorithm.toLowerCase() !== 'hs256') {
throw new CLIError(
'CRITICAL',
`unsupported root integrity algorithm: [${argv.rootIntegrityAlgorithm}]; only [hs256] is supported`
);
}
c.rootIntegrityAlgorithm = 'HS256';
}
if (argv.segmentIntegrityAlgorithm?.length) {
const segmentAlg = argv.segmentIntegrityAlgorithm.toUpperCase();
if (segmentAlg !== 'GMAC' && segmentAlg !== 'HS256') {
throw new CLIError(
'CRITICAL',
`unsupported segment integrity algorithm: [${argv.segmentIntegrityAlgorithm}]`
);
}
c.segmentIntegrityAlgorithm = segmentAlg;
}
return c;
}

async function parseCreateTDFOptions(
argv: Partial<mainArgs>,
flags: CreateTDFFlags = parseCreateTDFFlags(argv)
): Promise<CreateTDFOptions> {
const c: CreateTDFOptions = { ...(await parseCreateOptions(argv)), ...flags };
if (argv.assertions?.length) {
// Reads from disk (parseAssertionConfig falls back to openAsBlob), so it
// stays here rather than in the pure pass.
c.assertionConfigs = await parseAssertionConfig(argv.assertions);
}
log('DEBUG', `CreateTDFOptions: ${JSON.stringify(c)}`);
return c;
}
Expand Down Expand Up @@ -472,6 +523,24 @@ export const handleArgs = (args: string[]) => {
type: 'string',
default: '',
},
rootIntegrityAlgorithm: {
alias: 'root-integrity-algorithm',
group: 'Encrypt Options:',
desc: 'Algorithm for the root signature. Only hs256 is supported; gmac is rejected',
type: 'string',
choices: integrityAlgorithms,
coerce: lowercaseIntegrityAlgorithm,
default: 'hs256',
},
segmentIntegrityAlgorithm: {
alias: 'segment-integrity-algorithm',
group: 'Encrypt Options:',
desc: 'Algorithm for per-segment integrity',
type: 'string',
choices: integrityAlgorithms,
coerce: lowercaseIntegrityAlgorithm,
default: 'gmac',
},
rewrapKeyType: {
alias: 'rewrap-encapsulation-algorithm',
group: 'Decrypt Options:',
Expand Down Expand Up @@ -624,6 +693,16 @@ export const handleArgs = (args: string[]) => {
},
async (argv) => {
log('DEBUG', 'Running encrypt command');
// Order is deliberate and covered by bin/opentdf.bats:
// 1. pure flag checks -- no I/O, so a typo fails instantly
// 2. oidcEndpoint, mirroring `decrypt`
// 3. open the input file and any assertion files
// 4. the auth provider, which may reach the IdP
const createFlags = parseCreateTDFFlags(argv);
if (!argv.oidcEndpoint) {
throw new CLIError('CRITICAL', 'oidcEndpoint must be specified');
}
const createOptions = await parseCreateTDFOptions(argv, createFlags);
const authProvider = await processAuth(argv);
log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`);
const guessedPolicyEndpoint = guessPolicyUrl(argv);
Expand All @@ -640,7 +719,7 @@ export const handleArgs = (args: string[]) => {
try {
log('SILLY', `Initialized client`);
log('DEBUG', `TDF Create`);
const ct: DecoratedStream = await client.createTDF(await parseCreateTDFOptions(argv));
const ct: DecoratedStream = await client.createTDF(createOptions);
if (!ct) {
throw new CLIError('CRITICAL', 'Encrypt configuration error: No output?');
}
Expand Down
19 changes: 19 additions & 0 deletions lib/src/opentdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
decryptStreamFrom,
InspectedTDFOverview,
loadTDFStream,
type IntegrityAlgorithm,

Check warning on line 33 in lib/src/opentdf.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'IntegrityAlgorithm' is deprecated.

See more on https://sonarcloud.io/project/issues?id=opentdf_client-web&issues=AaCOgbeXG_dh45Huw5hx&open=AaCOgbeXG_dh45Huw5hx&pullRequest=1030
type RootIntegrityAlgorithm,
type SegmentIntegrityAlgorithm,
} from '../tdf3/src/tdf.js';
import { base64 } from './encodings/index.js';
import { Policy } from '../tdf3/src/models/policy.js';
Expand All @@ -44,7 +46,9 @@
type KeyAccessObject,
type Manifest,
type Payload,
type RootIntegrityAlgorithm,
type Segment,
type SegmentIntegrityAlgorithm,
type SplitType,
PUBLIC_KEY_ALGORITHMS,
isPublicKeyAlgorithm,
Expand Down Expand Up @@ -130,6 +134,19 @@

/** TDF spec version to target. */
tdfSpecVersion?: '4.2.2' | '4.3.0';

/**
* Algorithm used for the root signature over the aggregate of segment
* hashes. `HS256` only. Defaults to `HS256`.
*/
rootIntegrityAlgorithm?: RootIntegrityAlgorithm;

/**
* Algorithm used for each segment's integrity value. `GMAC` reads out the
* AES-GCM tag the cipher already produced for that segment; `HS256` computes
* an HMAC over the segment ciphertext. Defaults to `GMAC`.
*/
segmentIntegrityAlgorithm?: SegmentIntegrityAlgorithm;
};

/** Options for creating a TDF. */
Expand Down Expand Up @@ -392,6 +409,8 @@
windowSize: opts.windowSize,
wrappingKeyAlgorithm: opts.wrappingKeyAlgorithm,
tdfSpecVersion: opts.tdfSpecVersion,
rootIntegrityAlgorithm: opts.rootIntegrityAlgorithm,
segmentIntegrityAlgorithm: opts.segmentIntegrityAlgorithm,
});
const stream: DecoratedStream = oldStream.stream;
stream.manifest = Promise.resolve(oldStream.manifest);
Expand Down
9 changes: 8 additions & 1 deletion lib/tdf3/src/client/builders.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { validateAttribute, validateAttributeObject } from './validation.js';
import { AttributeObject, KeyInfo, Policy } from '../models/index.js';
import { type Metadata } from '../tdf.js';
import {
type Metadata,
type RootIntegrityAlgorithm,
type SegmentIntegrityAlgorithm,
} from '../tdf.js';
import { Binary } from '../binary.js';

import { ConfigurationError } from '../../../src/errors.js';
Expand Down Expand Up @@ -57,6 +61,9 @@ export type EncryptParams = {
// Preferred wrapping key algorithm. Used when KID resolution is not available.
wrappingKeyAlgorithm?: KasPublicKeyAlgorithm;

rootIntegrityAlgorithm?: RootIntegrityAlgorithm;
segmentIntegrityAlgorithm?: SegmentIntegrityAlgorithm;

// Unsupported
asHtml?: boolean;
// Unsupported
Expand Down
20 changes: 18 additions & 2 deletions lib/tdf3/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import {
buildKeyAccess,
type EncryptConfiguration,
fetchKasPublicKey,
isRootIntegrityAlgorithm,
isSegmentIntegrityAlgorithm,
loadTDFStream,
readStream,
ROOT_INTEGRITY_ALGORITHM,
SEGMENT_INTEGRITY_ALGORITHM,
validatePolicyObject,
writeStream,
} from '../tdf.js';
Expand Down Expand Up @@ -544,7 +548,19 @@ export class Client {
streamMiddleware = async (stream: DecoratedReadableStream) => stream,
tdfSpecVersion,
wrappingKeyAlgorithm,
rootIntegrityAlgorithm = ROOT_INTEGRITY_ALGORITHM,
segmentIntegrityAlgorithm = SEGMENT_INTEGRITY_ALGORITHM,
} = opts;
if (!isRootIntegrityAlgorithm(rootIntegrityAlgorithm)) {
throw new ConfigurationError(
`unsupported root integrity algorithm [${rootIntegrityAlgorithm}]; only [${ROOT_INTEGRITY_ALGORITHM}] is supported`
);
}
if (!isSegmentIntegrityAlgorithm(segmentIntegrityAlgorithm)) {
throw new ConfigurationError(
`unsupported segment integrity algorithm [${segmentIntegrityAlgorithm}]`
);
}
const keyMiddleware = keyMiddlewareOpt ?? (() => defaultKeyMiddleware(this.cryptoService));
const scope = opts.scope ?? { attributes: [], dissem: [] };

Expand Down Expand Up @@ -772,8 +788,8 @@ export class Client {
dpopKeys,
encryptionInformation,
segmentSizeDefault: windowSize,
integrityAlgorithm: 'HS256',
segmentIntegrityAlgorithm: 'GMAC',
rootIntegrityAlgorithm,
segmentIntegrityAlgorithm,
contentStream: opts.source,
mimeType,
policy: policyObject,
Expand Down
16 changes: 11 additions & 5 deletions lib/tdf3/src/models/encryption-information.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
type EncryptResult,
type SymmetricKey,
} from '../crypto/declarations.js';
import { IntegrityAlgorithm } from '../tdf.js';
import {
ROOT_INTEGRITY_ALGORITHM,
SEGMENT_INTEGRITY_ALGORITHM,
type SegmentIntegrityAlgorithm,
} from '../tdf.js';
import { ConfigurationError } from '../../../src/errors.js';

export type KeyInfo = {
Expand All @@ -32,10 +36,10 @@ export type EncryptionInformation = {
readonly keyAccess: KeyAccessObject[];
readonly integrityInformation: {
readonly rootSignature: {
alg: IntegrityAlgorithm;
alg: SegmentIntegrityAlgorithm;
sig: string;
};
segmentHashAlg?: IntegrityAlgorithm;
segmentHashAlg?: SegmentIntegrityAlgorithm;
segments: Segment[];
segmentSizeDefault?: number;
encryptedSegmentSizeDefault?: number;
Expand Down Expand Up @@ -153,10 +157,12 @@ export class SplitKey {
},
integrityInformation: {
rootSignature: {
alg: 'HS256',
// Placeholders; `writeStream` overwrites both once the payload has
// been segmented and the aggregate hash is known.
alg: ROOT_INTEGRITY_ALGORITHM,
sig: '',
},
segmentHashAlg: 'GMAC',
segmentHashAlg: SEGMENT_INTEGRITY_ALGORITHM,
segments: [],
},
policy: policyForManifest,
Expand Down
Loading
Loading