Skip to content

Commit 297cec6

Browse files
feat(cli): Adds --allowList parameter to cli (#328)
* feat(cli): Adds --allowList parameter to cli e.g. ``` opentdf.mjs --allowList https://kas.a,https://kas.b ``` * Update opentdf.bats * Update opentdf.bats * updates * Update build.yaml * Update access.ts * Update cli.ts --------- Co-authored-by: Elizabeth Healy <[email protected]>
1 parent 4eef553 commit 297cec6

File tree

14 files changed

+140
-135
lines changed

14 files changed

+140
-135
lines changed

.github/workflows/build.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,8 @@ jobs:
107107
- run: npm run license-check
108108
- run: npm run lint
109109
- run: npm pack
110-
- name: Setup BATS
111-
uses: mig4/setup-bats@v1
112-
with:
113-
bats-version: 1.2.1
110+
- name: Setup Bats and bats libs
111+
uses: bats-core/[email protected]
114112
- run: bats bin/opentdf.bats
115113
- uses: actions/upload-artifact@v4
116114
with:

.github/workflows/roundtrip/encrypt-decrypt.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ _nano_test() {
1313
echo "Hello World ${counter}" >"./${plain}"
1414
npx "$1" --log-level DEBUG \
1515
--kasEndpoint http://localhost:65432/api/kas \
16+
--allowList http://localhost:65432 \
1617
--oidcEndpoint http://localhost:65432/auth/realms/tdf \
1718
--auth tdf-client:123-456 \
1819
--output sample.txt.ntdf \

cli/bin/opentdf.bats

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@
22

33
@test "requires some arguments" {
44
run $BATS_TEST_DIRNAME/opentdf.mjs
5+
echo "$output"
56
[[ $output == *"Not enough"* ]]
67
}
78

89
@test "requires optional arguments" {
910
run $BATS_TEST_DIRNAME/opentdf.mjs encrypt noone
11+
echo "$output"
1012
[[ $output == *"Missing required"* ]]
1113
}
1214

1315
@test "fails with missing file arguments" {
14-
run $BATS_TEST_DIRNAME/opentdf.mjs --kasEndpoint https://invalid --oidcEndpoint http://invalid --auth b:c encrypt notafile
16+
run $BATS_TEST_DIRNAME/opentdf.mjs --kasEndpoint "https://example.com" --oidcEndpoint "http://invalid" --auth "b:c" encrypt
1517
[ "$status" -eq 1 ]
16-
[[ $output == *"File is not accessable"* ]]
18+
echo "$output"
19+
[[ $output == *"Must specify file or pipe"* ]]
1720
}
1821

1922
@test "version command" {
2023
run $BATS_TEST_DIRNAME/opentdf.mjs --version
24+
echo "$output"
2125
[[ $output == *"@opentdf/client\":\""* ]]
2226
[[ $output == *"@opentdf/cli\":\""* ]]
2327
}

cli/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/cli.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export const handleArgs = (args: string[]) => {
169169
// AUTH OPTIONS
170170
.option('kasEndpoint', {
171171
demandOption: true,
172-
group: 'KAS Endpoint:',
172+
group: 'KAS Configuration',
173173
type: 'string',
174174
description: 'URL to non-default KAS instance (https://mykas.net)',
175175
})
@@ -179,6 +179,12 @@ export const handleArgs = (args: string[]) => {
179179
type: 'string',
180180
description: 'URL to non-default OIDC IdP (https://myidp.net)',
181181
})
182+
.option('allowList', {
183+
group: 'KAS Configuration',
184+
desc: 'allowed KAS origins, comma separated; defaults to [kasEndpoint]',
185+
type: 'string',
186+
validate: (attributes: string) => attributes.split(','),
187+
})
182188
.option('auth', {
183189
group: 'Authentication:',
184190
type: 'string',
@@ -286,13 +292,19 @@ export const handleArgs = (args: string[]) => {
286292
},
287293
async (argv) => {
288294
log('DEBUG', 'Running decrypt command');
295+
const allowedKases = argv.allowList?.split(',');
289296
const authProvider = await processAuth(argv);
290297
log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`);
291298

292299
const kasEndpoint = argv.kasEndpoint;
293300
if (argv.containerType === 'tdf3') {
294301
log('DEBUG', `TDF3 Client`);
295-
const client = new TDF3Client({ authProvider, kasEndpoint, dpopEnabled: argv.dpop });
302+
const client = new TDF3Client({
303+
allowedKases,
304+
authProvider,
305+
kasEndpoint,
306+
dpopEnabled: argv.dpop,
307+
});
296308
log('SILLY', `Initialized client ${JSON.stringify(client)}`);
297309
log('DEBUG', `About to decrypt [${argv.file}]`);
298310
const ct = await client.decrypt(await tdf3DecryptParamsFor(argv));
@@ -306,8 +318,13 @@ export const handleArgs = (args: string[]) => {
306318
const dpopEnabled = !!argv.dpop;
307319
const client =
308320
argv.containerType === 'nano'
309-
? new NanoTDFClient({ authProvider, kasEndpoint, dpopEnabled })
310-
: new NanoTDFDatasetClient({ authProvider, kasEndpoint, dpopEnabled });
321+
? new NanoTDFClient({ allowedKases, authProvider, kasEndpoint, dpopEnabled })
322+
: new NanoTDFDatasetClient({
323+
allowedKases,
324+
authProvider,
325+
kasEndpoint,
326+
dpopEnabled,
327+
});
311328
const buffer = await processDataIn(argv.file as string);
312329

313330
log('DEBUG', 'Decrypt data.');
@@ -359,10 +376,16 @@ export const handleArgs = (args: string[]) => {
359376
const authProvider = await processAuth(argv);
360377
log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`);
361378
const kasEndpoint = argv.kasEndpoint;
379+
const allowedKases = argv.allowList?.split(',');
362380

363381
if ('tdf3' === argv.containerType) {
364382
log('DEBUG', `TDF3 Client`);
365-
const client = new TDF3Client({ authProvider, kasEndpoint, dpopEnabled: argv.dpop });
383+
const client = new TDF3Client({
384+
allowedKases,
385+
authProvider,
386+
kasEndpoint,
387+
dpopEnabled: argv.dpop,
388+
});
366389
log('SILLY', `Initialized client ${JSON.stringify(client)}`);
367390
const ct = await client.encrypt(await tdf3EncryptParamsFor(argv));
368391
if (!ct) {
@@ -378,8 +401,13 @@ export const handleArgs = (args: string[]) => {
378401
const dpopEnabled = !!argv.dpop;
379402
const client =
380403
argv.containerType === 'nano'
381-
? new NanoTDFClient({ authProvider, dpopEnabled, kasEndpoint })
382-
: new NanoTDFDatasetClient({ authProvider, dpopEnabled, kasEndpoint });
404+
? new NanoTDFClient({ allowedKases, authProvider, dpopEnabled, kasEndpoint })
405+
: new NanoTDFDatasetClient({
406+
allowedKases,
407+
authProvider,
408+
dpopEnabled,
409+
kasEndpoint,
410+
});
383411
log('SILLY', `Initialized client ${JSON.stringify(client)}`);
384412

385413
addParams(client, argv);

lib/src/access.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type AuthProvider } from './auth/auth.js';
2-
import { pemToCryptoPublicKey } from './utils.js';
2+
import { pemToCryptoPublicKey, validateSecureUrl } from './utils.js';
33

44
export class RewrapRequest {
55
signedRequestToken = '';
@@ -60,3 +60,23 @@ export async function fetchECKasPubKey(kasEndpoint: string): Promise<CryptoKey>
6060
const pem = await kasPubKeyResponse.json();
6161
return pemToCryptoPublicKey(pem);
6262
}
63+
64+
const origin = (u: string): string => {
65+
try {
66+
return new URL(u).origin;
67+
} catch (e) {
68+
console.log(`invalid kas url: [${u}]`);
69+
throw e;
70+
}
71+
};
72+
73+
export class OriginAllowList {
74+
origins: string[];
75+
constructor(urls: string[]) {
76+
this.origins = urls.map(origin);
77+
urls.forEach(validateSecureUrl);
78+
}
79+
allows(url: string): boolean {
80+
return this.origins.includes(origin(url));
81+
}
82+
}

lib/src/nanotdf/Client.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,13 @@ import * as base64 from '../encodings/base64.js';
33
import { generateKeyPair, keyAgreement } from '../nanotdf-crypto/index.js';
44
import getHkdfSalt from './helpers/getHkdfSalt.js';
55
import DefaultParams from './models/DefaultParams.js';
6-
import { fetchWrappedKey } from '../access.js';
6+
import { fetchWrappedKey, OriginAllowList } from '../access.js';
77
import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js';
8-
import {
9-
cryptoPublicToPem,
10-
pemToCryptoPublicKey,
11-
safeUrlCheck,
12-
validateSecureUrl,
13-
} from '../utils.js';
8+
import { UnsafeUrlError } from '../errors.js';
9+
import { cryptoPublicToPem, pemToCryptoPublicKey, validateSecureUrl } from '../utils.js';
1410

1511
export interface ClientConfig {
12+
allowedKases?: string[];
1613
authProvider: AuthProvider;
1714
dpopEnabled?: boolean;
1815
dpopKeys?: Promise<CryptoKeyPair>;
@@ -102,7 +99,7 @@ export default class Client {
10299
static readonly INITIAL_RELEASE_IV_SIZE = 3;
103100
static readonly IV_SIZE = 12;
104101

105-
allowedKases: string[];
102+
allowedKases: OriginAllowList;
106103
/*
107104
These variables are expected to be either assigned during initialization or within the methods.
108105
This is needed as the flow is very specific. Errors should be thrown if the necessary step is not completed.
@@ -138,7 +135,7 @@ export default class Client {
138135
// TODO Disallow http KAS. For now just log as error
139136
validateSecureUrl(kasUrl);
140137
this.kasUrl = kasUrl;
141-
this.allowedKases = [kasUrl];
138+
this.allowedKases = new OriginAllowList([kasUrl]);
142139
this.dpopEnabled = dpopEnabled;
143140

144141
if (ephemeralKeyPair) {
@@ -148,13 +145,13 @@ export default class Client {
148145
}
149146
this.iv = 1;
150147
} else {
151-
const { authProvider, dpopEnabled, dpopKeys, ephemeralKeyPair, kasEndpoint } =
148+
const { allowedKases, authProvider, dpopEnabled, dpopKeys, ephemeralKeyPair, kasEndpoint } =
152149
optsOrOldAuthProvider;
153150
this.authProvider = authProvider;
154151
// TODO Disallow http KAS. For now just log as error
155152
validateSecureUrl(kasEndpoint);
156153
this.kasUrl = kasEndpoint;
157-
this.allowedKases = [kasEndpoint];
154+
this.allowedKases = new OriginAllowList(allowedKases || [kasEndpoint]);
158155
this.dpopEnabled = !!dpopEnabled;
159156
if (dpopKeys) {
160157
this.requestSignerKeyPair = dpopKeys;
@@ -215,7 +212,9 @@ export default class Client {
215212
magicNumberVersion: TypedArray | ArrayBuffer,
216213
clientVersion: string
217214
): Promise<CryptoKey> {
218-
safeUrlCheck(this.allowedKases, kasRewrapUrl);
215+
if (!this.allowedKases.allows(kasRewrapUrl)) {
216+
throw new UnsafeUrlError(`request URL ∉ ${this.allowedKases.origins};`, kasRewrapUrl);
217+
}
219218

220219
// Ensure the ephemeral key pair has been set or generated (see createOidcServiceProvider)
221220
await this.fetchOIDCToken();

lib/src/utils.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { type AxiosResponseHeaders, type RawAxiosResponseHeaders } from 'axios';
2-
import { UnsafeUrlError } from './errors.js';
32
import { base64 } from './encodings/index.js';
43
import { pemCertToCrypto, pemPublicToCrypto } from './nanotdf-crypto/index.js';
54

@@ -40,23 +39,6 @@ export function padSlashToUrl(u: string): string {
4039
return `${u}/`;
4140
}
4241

43-
const someStartsWith = (prefixes: string[], requestUrl: string): boolean =>
44-
prefixes.some((prixfixe) => requestUrl.startsWith(padSlashToUrl(prixfixe)));
45-
46-
/**
47-
* Checks that `testUrl` is prefixed with one of the given origin + path fragment URIs in urlPrefixes.
48-
*
49-
* Note this doesn't do anything special to queries or fragments and will fail to work properly if those are present on the prefixes
50-
* @param urlPrefixes a list of origin parts of urls, possibly including some path fragment as well
51-
* @param testUrl a url to see if it is prefixed by one or more of the `urlPrefixes` values
52-
* @throws Error when testUrl is not present
53-
*/
54-
export const safeUrlCheck = (urlPrefixes: string[], testUrl: string): void | never => {
55-
if (!someStartsWith(urlPrefixes, testUrl)) {
56-
throw new UnsafeUrlError(`Invalid request URL: [${testUrl}] ∉ [${urlPrefixes}];`, testUrl);
57-
}
58-
};
59-
6042
export function isBrowser() {
6143
return typeof window !== 'undefined'; // eslint-disable-line
6244
}

lib/tdf3/src/client/index.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,14 @@ import {
5050
type DecryptSource,
5151
EncryptParamsBuilder,
5252
} from './builders.js';
53-
import * as defaultCryptoService from '../crypto/index.js';
54-
import { AttributeSet, Policy, SplitKey } from '../models/index.js';
53+
import { OriginAllowList } from '../../../src/access.js';
5554
import { TdfError } from '../../../src/errors.js';
55+
import { EntityObject } from '../../../src/tdf/EntityObject.js';
5656
import { Binary } from '../binary.js';
57-
import { EntityObject } from 'src/tdf/EntityObject.js';
5857
import { AesGcmCipher } from '../ciphers/aes-gcm-cipher.js';
5958
import { toCryptoKeyPair } from '../crypto/crypto-utils.js';
59+
import * as defaultCryptoService from '../crypto/index.js';
60+
import { AttributeSet, Policy, SplitKey } from '../models/index.js';
6061

6162
const GLOBAL_BYTE_LIMIT = 64 * 1000 * 1000 * 1000; // 64 GB, see WS-9363.
6263
const HTML_BYTE_LIMIT = 100 * 1000 * 1000; // 100 MB, see WS-9476.
@@ -220,7 +221,7 @@ export class Client {
220221
* List of allowed KASes to connect to for rewrap requests.
221222
* Defaults to `[this.kasEndpoint]`.
222223
*/
223-
readonly allowedKases: string[];
224+
readonly allowedKases: OriginAllowList;
224225

225226
readonly kasKeys: Record<string, Promise<KasPublicKeyInfo>> = {};
226227

@@ -274,18 +275,17 @@ export class Client {
274275

275276
const kasOrigin = new URL(this.kasEndpoint).origin;
276277
if (clientConfig.allowedKases) {
277-
this.allowedKases = clientConfig.allowedKases.map((a) => new URL(a).origin);
278-
if (!validateSecureUrl(this.kasEndpoint) && !this.allowedKases.includes(kasOrigin)) {
278+
this.allowedKases = new OriginAllowList(clientConfig.allowedKases);
279+
if (!validateSecureUrl(this.kasEndpoint) && !this.allowedKases.allows(kasOrigin)) {
279280
throw new TdfError(`Invalid KAS endpoint [${this.kasEndpoint}]`);
280281
}
281-
this.allowedKases.forEach(validateSecureUrl);
282282
} else {
283283
if (!validateSecureUrl(this.kasEndpoint)) {
284284
throw new TdfError(
285285
`Invalid KAS endpoint [${this.kasEndpoint}]; to force, please list it among allowedKases`
286286
);
287287
}
288-
this.allowedKases = [kasOrigin];
288+
this.allowedKases = new OriginAllowList([kasOrigin]);
289289
}
290290

291291
this.authProvider = config.authProvider;
@@ -405,7 +405,7 @@ export class Client {
405405
);
406406
const { keyForEncryption, keyForManifest } = await (keyMiddleware as EncryptKeyMiddleware)();
407407
const ecfg: EncryptConfiguration = {
408-
allowedKases: this.allowedKases,
408+
allowList: this.allowedKases,
409409
attributeSet,
410410
byteLimit,
411411
cryptoService: this.cryptoService,
@@ -482,7 +482,7 @@ export class Client {
482482
// TODO: Write error event to stream and don't await.
483483
return await (streamMiddleware as DecryptStreamMiddleware)(
484484
await readStream({
485-
allowedKases: this.allowedKases,
485+
allowList: this.allowedKases,
486486
authProvider: this.authProvider,
487487
chunker,
488488
cryptoService: this.cryptoService,

0 commit comments

Comments
 (0)