Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cli): Better errors and properly set exit code in more cases #418

Merged
merged 5 commits into from
Jan 23, 2025
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
7 changes: 2 additions & 5 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,11 +562,8 @@ export const handleArgs = (args: string[]) => {
log('DEBUG', `About to TDF3 decrypt [${argv.file}]`);
const ct = await client.read(await parseReadOptions(argv));
const destination = argv.output ? createWriteStream(argv.output) : process.stdout;
try {
await ct.pipeTo(Writable.toWeb(destination));
} catch (e) {
log('ERROR', `Failed to pipe to destination stream: ${e}`);
}
await ct.pipeTo(Writable.toWeb(destination));

const lastRequest = authProvider.requestLog[authProvider.requestLog.length - 1];
log('SILLY', `last request is ${JSON.stringify(lastRequest)}`);
let accessToken = null;
Expand Down
53 changes: 1 addition & 52 deletions lib/tdf3/src/models/key-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,58 +60,7 @@ export class Wrapped {
}
}

export class Remote {
readonly type = 'remote';
keyAccessObject?: KeyAccessObject;
wrappedKey?: string;
policyBinding?: string;

constructor(
public readonly url: string,
public readonly kid: string | undefined,
public readonly publicKey: string,
public readonly metadata: unknown,
public readonly sid: string
) {}

async write(
policy: Policy,
keyBuffer: Uint8Array,
encryptedMetadataStr: string
): Promise<KeyAccessObject> {
const policyStr = JSON.stringify(policy);
const policyBinding = await cryptoService.hmac(
hex.encodeArrayBuffer(keyBuffer),
base64.encode(policyStr)
);
const unwrappedKeyBinary = Binary.fromArrayBuffer(keyBuffer.buffer);
const wrappedKeyBinary = await cryptoService.encryptWithPublicKey(
unwrappedKeyBinary,
this.publicKey
);

// this.wrappedKey = wrappedKeyBinary.asBuffer().toString('hex');
this.wrappedKey = base64.encode(wrappedKeyBinary.asString());

this.keyAccessObject = {
type: 'remote',
url: this.url,
protocol: 'kas',
wrappedKey: this.wrappedKey,
encryptedMetadata: base64.encode(encryptedMetadataStr),
policyBinding: {
alg: 'HS256',
hash: base64.encode(policyBinding),
},
};
if (this.kid) {
this.keyAccessObject.kid = this.kid;
}
return this.keyAccessObject;
}
}

export type KeyAccess = Remote | Wrapped;
export type KeyAccess = Wrapped;

export type KeyAccessObject = {
sid?: string;
Expand Down
122 changes: 67 additions & 55 deletions lib/tdf3/src/tdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import {
KeyInfo,
Manifest,
Policy,
Remote as KeyAccessRemote,
SplitKey,
Wrapped as KeyAccessWrapped,
KeyAccess,
Expand Down Expand Up @@ -81,8 +80,8 @@ export type BuildKeyAccess = {

type Segment = {
hash: string;
segmentSize: number | undefined;
encryptedSegmentSize: number | undefined;
segmentSize?: number;
encryptedSegmentSize?: number;
};

type EntryInfo = {
Expand All @@ -92,14 +91,32 @@ type EntryInfo = {
fileByteCount?: number;
};

type Mailbox<T> = Promise<T> & {
set: (value: T) => void;
reject: (error: Error) => void;
};

function mailbox<T>(): Mailbox<T> {
let set: (value: T) => void;
let reject: (error: Error) => void;

const promise = new Promise<T>((resolve, rejectFn) => {
set = resolve;
reject = rejectFn;
}) as Mailbox<T>;

promise.set = set!;
promise.reject = reject!;

return promise;
}

type Chunk = {
hash: string;
plainSegmentSize?: number;
encryptedOffset: number;
encryptedSegmentSize?: number;
decryptedChunk?: null | DecryptResult;
promise: Promise<unknown>;
_resolve?: (value: unknown) => void;
_reject?: (value: unknown) => void;
decryptedChunk: Mailbox<DecryptResult>;
};

export type IntegrityAlgorithm = 'GMAC' | 'HS256';
Expand Down Expand Up @@ -219,10 +236,8 @@ export async function buildKeyAccess({
switch (type) {
case 'wrapped':
return new KeyAccessWrapped(kasUrl, kasKeyIdentifier, pubKey, metadata, sid);
case 'remote':
return new KeyAccessRemote(kasUrl, kasKeyIdentifier, pubKey, metadata, sid);
default:
throw new ConfigurationError(`buildKeyAccess: Key access type ${type} is unknown`);
throw new ConfigurationError(`buildKeyAccess: Key access type [${type}] is unsupported`);
}
}

Expand Down Expand Up @@ -726,10 +741,10 @@ async function decryptChunk(
hash: string,
cipher: SymmetricCipher,
segmentIntegrityAlgorithm: IntegrityAlgorithm,
cryptoService: CryptoService,
isLegacyTDF: boolean
): Promise<DecryptResult> {
if (segmentIntegrityAlgorithm !== 'GMAC' && segmentIntegrityAlgorithm !== 'HS256') {
throw new UnsupportedError(`Unsupported integrity alg [${segmentIntegrityAlgorithm}]`);
}
const segmentSig = await getSignature(
new Uint8Array(reconstructedKeyBinary.asArrayBuffer()),
Expand Down Expand Up @@ -809,7 +824,6 @@ export async function sliceAndDecrypt({
reconstructedKeyBinary,
slice,
cipher,
cryptoService,
segmentIntegrityAlgorithm,
isLegacyTDF,
}: {
Expand All @@ -822,34 +836,35 @@ export async function sliceAndDecrypt({
isLegacyTDF: boolean;
}) {
for (const index in slice) {
const { encryptedOffset, encryptedSegmentSize, _resolve, _reject } = slice[index];
const { encryptedOffset, encryptedSegmentSize, plainSegmentSize } = slice[index];

const offset =
slice[0].encryptedOffset === 0 ? encryptedOffset : encryptedOffset % slice[0].encryptedOffset;
const encryptedChunk = new Uint8Array(
buffer.slice(offset, offset + (encryptedSegmentSize as number))
);

if (encryptedChunk.length !== encryptedSegmentSize) {
throw new DecryptError('Failed to fetch entire segment');
}

try {
const result = await decryptChunk(
encryptedChunk,
reconstructedKeyBinary,
slice[index]['hash'],
cipher,
segmentIntegrityAlgorithm,
cryptoService,
isLegacyTDF
);
slice[index].decryptedChunk = result;
if (_resolve) {
_resolve(null);
if (plainSegmentSize && result.payload.length() !== plainSegmentSize) {
throw new DecryptError(
`incorrect segment size: found [${result.payload.length()}], expected [${plainSegmentSize}]`
);
}
slice[index].decryptedChunk.set(result);
} catch (e) {
if (_reject) {
_reject(e);
} else {
throw e;
}
slice[index].decryptedChunk.reject(e);
}
}
}
Expand All @@ -872,6 +887,7 @@ export async function readStream(cfg: DecryptConfiguration) {
encryptedSegmentSizeDefault: defaultSegmentSize,
rootSignature,
segmentHashAlg,
segmentSizeDefault,
segments,
} = manifest.encryptionInformation.integrityInformation;
const { metadata, reconstructedKeyBinary } = await unwrapKey({
Expand Down Expand Up @@ -908,14 +924,6 @@ export async function readStream(cfg: DecryptConfiguration) {
integrityAlgorithm
);

const rootSig = isLegacyTDF
? base64.encode(hex.encodeArrayBuffer(payloadSig))
: base64.encodeArrayBuffer(payloadSig);

if (manifest.encryptionInformation.integrityInformation.rootSignature.sig !== rootSig) {
throw new IntegrityError('Failed integrity check on root signature');
}

if (!cfg.noVerifyAssertions) {
for (const assertion of manifest.assertions || []) {
// Create a default assertion key
Expand All @@ -934,27 +942,36 @@ export async function readStream(cfg: DecryptConfiguration) {
}
}

const rootSig = isLegacyTDF
? base64.encode(hex.encodeArrayBuffer(payloadSig))
: base64.encodeArrayBuffer(payloadSig);

if (manifest.encryptionInformation.integrityInformation.rootSignature.sig !== rootSig) {
throw new IntegrityError('Failed integrity check on root signature');
}

let mapOfRequestsOffset = 0;
const chunkMap = new Map(
segments.map(({ hash, encryptedSegmentSize = encryptedSegmentSizeDefault }) => {
const result = (() => {
let _resolve, _reject;
const chunk: Chunk = {
hash,
encryptedOffset: mapOfRequestsOffset,
encryptedSegmentSize,
promise: new Promise((resolve, reject) => {
_resolve = resolve;
_reject = reject;
}),
};
chunk._resolve = _resolve;
chunk._reject = _reject;
return chunk;
})();
mapOfRequestsOffset += encryptedSegmentSize || encryptedSegmentSizeDefault;
return [hash, result];
})
segments.map(
({
hash,
encryptedSegmentSize = encryptedSegmentSizeDefault,
segmentSize = segmentSizeDefault,
}) => {
const result = (() => {
const chunk: Chunk = {
hash,
encryptedOffset: mapOfRequestsOffset,
encryptedSegmentSize,
decryptedChunk: mailbox<DecryptResult>(),
plainSegmentSize: segmentSize,
};
return chunk;
})();
mapOfRequestsOffset += encryptedSegmentSize;
return [hash, result];
}
)
);

const cipher = new AesGcmCipher(cfg.cryptoService);
Expand Down Expand Up @@ -984,16 +1001,11 @@ export async function readStream(cfg: DecryptConfiguration) {
}

const [hash, chunk] = chunkMap.entries().next().value;
if (!chunk.decryptedChunk) {
await chunk.promise;
}
const decryptedSegment = chunk.decryptedChunk;
const decryptedSegment = await chunk.decryptedChunk;

controller.enqueue(new Uint8Array(decryptedSegment.payload.asByteArray()));
progress += chunk.encryptedSegmentSize;
cfg.progressHandler?.(progress);

chunk.decryptedChunk = null;
chunkMap.delete(hash);
},
...(cfg.fileStreamServiceWorker && { fileStreamServiceWorker: cfg.fileStreamServiceWorker }),
Expand Down
38 changes: 38 additions & 0 deletions lib/tests/mocha/unit/tdf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect } from 'chai';

import * as TDF from '../../../tdf3/src/tdf.js';
import { KeyAccessObject } from '../../../tdf3/src/models/key-access.js';
import { PolicyBody, type Policy } from '../../../tdf3/src/models/policy.js';
import { OriginAllowList } from '../../../src/access.js';
import { ConfigurationError, InvalidFileError, UnsafeUrlError } from '../../../src/errors.js';

Expand Down Expand Up @@ -98,6 +99,43 @@ describe('fetchKasPublicKey', async () => {
});
});

describe('validatePolicyObject', () => {
const testCases: { title: string; policy: Partial<Policy>; error?: string }[] = [
{
title: 'missing uuid',
policy: { body: { dataAttributes: [], dissem: ['someDissem'] } },
error: 'uuid',
},
{
title: 'missing body',
policy: { uuid: 'someUuid' },
error: 'body',
},
{
title: 'missing body.dissem',
policy: { uuid: 'someUuid', body: {} as PolicyBody },
error: 'dissem',
},
{
title: 'valid policy',
policy: { uuid: 'someUuid', body: { dataAttributes: [], dissem: ['someDissem'] } },
},
];

testCases.forEach(({ title, policy, error }) => {
it(`should handle ${title}`, () => {
if (error) {
expect(() => TDF.validatePolicyObject(policy as Policy)).to.throw(
ConfigurationError,
error
);
} else {
expect(() => TDF.validatePolicyObject(policy as Policy)).to.not.throw();
}
});
});
});

describe('splitLookupTableFactory', () => {
it('should return a correct split table for valid input', () => {
const keyAccess: KeyAccessObject[] = [
Expand Down
Loading