Skip to content

Commit

Permalink
feat(cli): Adds --allowList parameter to cli (#328)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
dmihalcik-virtru and elizabethhealy authored Aug 23, 2024
1 parent 4eef553 commit 297cec6
Show file tree
Hide file tree
Showing 14 changed files with 140 additions and 135 deletions.
6 changes: 2 additions & 4 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,8 @@ jobs:
- run: npm run license-check
- run: npm run lint
- run: npm pack
- name: Setup BATS
uses: mig4/setup-bats@v1
with:
bats-version: 1.2.1
- name: Setup Bats and bats libs
uses: bats-core/[email protected]
- run: bats bin/opentdf.bats
- uses: actions/upload-artifact@v4
with:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/roundtrip/encrypt-decrypt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ _nano_test() {
echo "Hello World ${counter}" >"./${plain}"
npx "$1" --log-level DEBUG \
--kasEndpoint http://localhost:65432/api/kas \
--allowList http://localhost:65432 \
--oidcEndpoint http://localhost:65432/auth/realms/tdf \
--auth tdf-client:123-456 \
--output sample.txt.ntdf \
Expand Down
8 changes: 6 additions & 2 deletions cli/bin/opentdf.bats
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@

@test "requires some arguments" {
run $BATS_TEST_DIRNAME/opentdf.mjs
echo "$output"
[[ $output == *"Not enough"* ]]
}

@test "requires optional arguments" {
run $BATS_TEST_DIRNAME/opentdf.mjs encrypt noone
echo "$output"
[[ $output == *"Missing required"* ]]
}

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

@test "version command" {
run $BATS_TEST_DIRNAME/opentdf.mjs --version
echo "$output"
[[ $output == *"@opentdf/client\":\""* ]]
[[ $output == *"@opentdf/cli\":\""* ]]
}
8 changes: 4 additions & 4 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 35 additions & 7 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export const handleArgs = (args: string[]) => {
// AUTH OPTIONS
.option('kasEndpoint', {
demandOption: true,
group: 'KAS Endpoint:',
group: 'KAS Configuration',
type: 'string',
description: 'URL to non-default KAS instance (https://mykas.net)',
})
Expand All @@ -179,6 +179,12 @@ export const handleArgs = (args: string[]) => {
type: 'string',
description: 'URL to non-default OIDC IdP (https://myidp.net)',
})
.option('allowList', {
group: 'KAS Configuration',
desc: 'allowed KAS origins, comma separated; defaults to [kasEndpoint]',
type: 'string',
validate: (attributes: string) => attributes.split(','),
})
.option('auth', {
group: 'Authentication:',
type: 'string',
Expand Down Expand Up @@ -286,13 +292,19 @@ export const handleArgs = (args: string[]) => {
},
async (argv) => {
log('DEBUG', 'Running decrypt command');
const allowedKases = argv.allowList?.split(',');
const authProvider = await processAuth(argv);
log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`);

const kasEndpoint = argv.kasEndpoint;
if (argv.containerType === 'tdf3') {
log('DEBUG', `TDF3 Client`);
const client = new TDF3Client({ authProvider, kasEndpoint, dpopEnabled: argv.dpop });
const client = new TDF3Client({
allowedKases,
authProvider,
kasEndpoint,
dpopEnabled: argv.dpop,
});
log('SILLY', `Initialized client ${JSON.stringify(client)}`);
log('DEBUG', `About to decrypt [${argv.file}]`);
const ct = await client.decrypt(await tdf3DecryptParamsFor(argv));
Expand All @@ -306,8 +318,13 @@ export const handleArgs = (args: string[]) => {
const dpopEnabled = !!argv.dpop;
const client =
argv.containerType === 'nano'
? new NanoTDFClient({ authProvider, kasEndpoint, dpopEnabled })
: new NanoTDFDatasetClient({ authProvider, kasEndpoint, dpopEnabled });
? new NanoTDFClient({ allowedKases, authProvider, kasEndpoint, dpopEnabled })
: new NanoTDFDatasetClient({
allowedKases,
authProvider,
kasEndpoint,
dpopEnabled,
});
const buffer = await processDataIn(argv.file as string);

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

if ('tdf3' === argv.containerType) {
log('DEBUG', `TDF3 Client`);
const client = new TDF3Client({ authProvider, kasEndpoint, dpopEnabled: argv.dpop });
const client = new TDF3Client({
allowedKases,
authProvider,
kasEndpoint,
dpopEnabled: argv.dpop,
});
log('SILLY', `Initialized client ${JSON.stringify(client)}`);
const ct = await client.encrypt(await tdf3EncryptParamsFor(argv));
if (!ct) {
Expand All @@ -378,8 +401,13 @@ export const handleArgs = (args: string[]) => {
const dpopEnabled = !!argv.dpop;
const client =
argv.containerType === 'nano'
? new NanoTDFClient({ authProvider, dpopEnabled, kasEndpoint })
: new NanoTDFDatasetClient({ authProvider, dpopEnabled, kasEndpoint });
? new NanoTDFClient({ allowedKases, authProvider, dpopEnabled, kasEndpoint })
: new NanoTDFDatasetClient({
allowedKases,
authProvider,
dpopEnabled,
kasEndpoint,
});
log('SILLY', `Initialized client ${JSON.stringify(client)}`);

addParams(client, argv);
Expand Down
22 changes: 21 additions & 1 deletion lib/src/access.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type AuthProvider } from './auth/auth.js';
import { pemToCryptoPublicKey } from './utils.js';
import { pemToCryptoPublicKey, validateSecureUrl } from './utils.js';

export class RewrapRequest {
signedRequestToken = '';
Expand Down Expand Up @@ -60,3 +60,23 @@ export async function fetchECKasPubKey(kasEndpoint: string): Promise<CryptoKey>
const pem = await kasPubKeyResponse.json();
return pemToCryptoPublicKey(pem);
}

const origin = (u: string): string => {
try {
return new URL(u).origin;
} catch (e) {
console.log(`invalid kas url: [${u}]`);
throw e;
}
};

export class OriginAllowList {
origins: string[];
constructor(urls: string[]) {
this.origins = urls.map(origin);
urls.forEach(validateSecureUrl);
}
allows(url: string): boolean {
return this.origins.includes(origin(url));
}
}
23 changes: 11 additions & 12 deletions lib/src/nanotdf/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@ import * as base64 from '../encodings/base64.js';
import { generateKeyPair, keyAgreement } from '../nanotdf-crypto/index.js';
import getHkdfSalt from './helpers/getHkdfSalt.js';
import DefaultParams from './models/DefaultParams.js';
import { fetchWrappedKey } from '../access.js';
import { fetchWrappedKey, OriginAllowList } from '../access.js';
import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js';
import {
cryptoPublicToPem,
pemToCryptoPublicKey,
safeUrlCheck,
validateSecureUrl,
} from '../utils.js';
import { UnsafeUrlError } from '../errors.js';
import { cryptoPublicToPem, pemToCryptoPublicKey, validateSecureUrl } from '../utils.js';

export interface ClientConfig {
allowedKases?: string[];
authProvider: AuthProvider;
dpopEnabled?: boolean;
dpopKeys?: Promise<CryptoKeyPair>;
Expand Down Expand Up @@ -102,7 +99,7 @@ export default class Client {
static readonly INITIAL_RELEASE_IV_SIZE = 3;
static readonly IV_SIZE = 12;

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

if (ephemeralKeyPair) {
Expand All @@ -148,13 +145,13 @@ export default class Client {
}
this.iv = 1;
} else {
const { authProvider, dpopEnabled, dpopKeys, ephemeralKeyPair, kasEndpoint } =
const { allowedKases, authProvider, dpopEnabled, dpopKeys, ephemeralKeyPair, kasEndpoint } =
optsOrOldAuthProvider;
this.authProvider = authProvider;
// TODO Disallow http KAS. For now just log as error
validateSecureUrl(kasEndpoint);
this.kasUrl = kasEndpoint;
this.allowedKases = [kasEndpoint];
this.allowedKases = new OriginAllowList(allowedKases || [kasEndpoint]);
this.dpopEnabled = !!dpopEnabled;
if (dpopKeys) {
this.requestSignerKeyPair = dpopKeys;
Expand Down Expand Up @@ -215,7 +212,9 @@ export default class Client {
magicNumberVersion: TypedArray | ArrayBuffer,
clientVersion: string
): Promise<CryptoKey> {
safeUrlCheck(this.allowedKases, kasRewrapUrl);
if (!this.allowedKases.allows(kasRewrapUrl)) {
throw new UnsafeUrlError(`request URL ∉ ${this.allowedKases.origins};`, kasRewrapUrl);
}

// Ensure the ephemeral key pair has been set or generated (see createOidcServiceProvider)
await this.fetchOIDCToken();
Expand Down
18 changes: 0 additions & 18 deletions lib/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type AxiosResponseHeaders, type RawAxiosResponseHeaders } from 'axios';
import { UnsafeUrlError } from './errors.js';
import { base64 } from './encodings/index.js';
import { pemCertToCrypto, pemPublicToCrypto } from './nanotdf-crypto/index.js';

Expand Down Expand Up @@ -40,23 +39,6 @@ export function padSlashToUrl(u: string): string {
return `${u}/`;
}

const someStartsWith = (prefixes: string[], requestUrl: string): boolean =>
prefixes.some((prixfixe) => requestUrl.startsWith(padSlashToUrl(prixfixe)));

/**
* Checks that `testUrl` is prefixed with one of the given origin + path fragment URIs in urlPrefixes.
*
* Note this doesn't do anything special to queries or fragments and will fail to work properly if those are present on the prefixes
* @param urlPrefixes a list of origin parts of urls, possibly including some path fragment as well
* @param testUrl a url to see if it is prefixed by one or more of the `urlPrefixes` values
* @throws Error when testUrl is not present
*/
export const safeUrlCheck = (urlPrefixes: string[], testUrl: string): void | never => {
if (!someStartsWith(urlPrefixes, testUrl)) {
throw new UnsafeUrlError(`Invalid request URL: [${testUrl}] ∉ [${urlPrefixes}];`, testUrl);
}
};

export function isBrowser() {
return typeof window !== 'undefined'; // eslint-disable-line
}
Expand Down
20 changes: 10 additions & 10 deletions lib/tdf3/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ import {
type DecryptSource,
EncryptParamsBuilder,
} from './builders.js';
import * as defaultCryptoService from '../crypto/index.js';
import { AttributeSet, Policy, SplitKey } from '../models/index.js';
import { OriginAllowList } from '../../../src/access.js';
import { TdfError } from '../../../src/errors.js';
import { EntityObject } from '../../../src/tdf/EntityObject.js';
import { Binary } from '../binary.js';
import { EntityObject } from 'src/tdf/EntityObject.js';
import { AesGcmCipher } from '../ciphers/aes-gcm-cipher.js';
import { toCryptoKeyPair } from '../crypto/crypto-utils.js';
import * as defaultCryptoService from '../crypto/index.js';
import { AttributeSet, Policy, SplitKey } from '../models/index.js';

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

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

Expand Down Expand Up @@ -274,18 +275,17 @@ export class Client {

const kasOrigin = new URL(this.kasEndpoint).origin;
if (clientConfig.allowedKases) {
this.allowedKases = clientConfig.allowedKases.map((a) => new URL(a).origin);
if (!validateSecureUrl(this.kasEndpoint) && !this.allowedKases.includes(kasOrigin)) {
this.allowedKases = new OriginAllowList(clientConfig.allowedKases);
if (!validateSecureUrl(this.kasEndpoint) && !this.allowedKases.allows(kasOrigin)) {
throw new TdfError(`Invalid KAS endpoint [${this.kasEndpoint}]`);
}
this.allowedKases.forEach(validateSecureUrl);
} else {
if (!validateSecureUrl(this.kasEndpoint)) {
throw new TdfError(
`Invalid KAS endpoint [${this.kasEndpoint}]; to force, please list it among allowedKases`
);
}
this.allowedKases = [kasOrigin];
this.allowedKases = new OriginAllowList([kasOrigin]);
}

this.authProvider = config.authProvider;
Expand Down Expand Up @@ -405,7 +405,7 @@ export class Client {
);
const { keyForEncryption, keyForManifest } = await (keyMiddleware as EncryptKeyMiddleware)();
const ecfg: EncryptConfiguration = {
allowedKases: this.allowedKases,
allowList: this.allowedKases,
attributeSet,
byteLimit,
cryptoService: this.cryptoService,
Expand Down Expand Up @@ -482,7 +482,7 @@ export class Client {
// TODO: Write error event to stream and don't await.
return await (streamMiddleware as DecryptStreamMiddleware)(
await readStream({
allowedKases: this.allowedKases,
allowList: this.allowedKases,
authProvider: this.authProvider,
chunker,
cryptoService: this.cryptoService,
Expand Down
Loading

0 comments on commit 297cec6

Please sign in to comment.