Skip to content

Commit

Permalink
feat!: lets nanoTDF client take options instead
Browse files Browse the repository at this point in the history
  • Loading branch information
dmihalcik-virtru committed May 9, 2024
1 parent 13936cf commit 19642e1
Show file tree
Hide file tree
Showing 14 changed files with 322 additions and 154 deletions.
2 changes: 1 addition & 1 deletion lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ TDF3 with JSON envelopes.
oidcOrigin: keycloakUrl,
}
const authProvider = await AuthProviders.refreshAuthProvider(oidcCredentials);
const client = new NanoTDFClient(authProvider, access);
const client = new NanoTDFClient({authProvider, kasEndpoint});
const cipherText = await client.encrypt(plainText);
const clearText = await client.decrypt(cipherText);
```
Expand Down
48 changes: 26 additions & 22 deletions lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from './nanotdf/index.js';
import { keyAgreement, extractPublicFromCertToCrypto } from './nanotdf-crypto/index.js';
import { TypedArray, createAttribute, Policy } from './tdf/index.js';
import { type AuthProvider } from './auth/auth.js';
import { ClientConfig } from './nanotdf/Client.js';

async function fetchKasPubKey(kasUrl: string): Promise<string> {
const kasPubKeyResponse = await fetch(`${kasUrl}/kas_public_key?algorithm=ec:secp256r1`);
Expand All @@ -33,13 +33,14 @@ async function fetchKasPubKey(kasUrl: string): Promise<string> {
* const KAS_URL = 'http://localhost:65432/api/kas/';
*
* const ciphertext = '...';
* const client = new NanoTDFClient(
* await clientSecretAuthProvider({
* const client = new NanoTDFClient({
* authProvider: await clientSecretAuthProvider({
* clientId: 'tdf-client',
* clientSecret: '123-456',
* oidcOrigin: OIDC_ENDPOINT,
* }),
* KAS_URL
* kasEndpoint: KAS_URL
* }
* );
* client.decrypt(ciphertext)
* .then(plaintext => {
Expand Down Expand Up @@ -120,9 +121,9 @@ export class NanoTDFClient extends Client {
*/
async encrypt(data: string | TypedArray | ArrayBuffer): Promise<ArrayBuffer> {
// For encrypt always generate the client ephemeralKeyPair
const ephemeralKeyPair = await this.generateEphemeralKeyPair();

const ephemeralKeyPair = await this.ephemeralKeyPair;
const initializationVector = this.iv;

if (typeof initializationVector !== 'number') {
throw new Error('NanoTDF clients are single use. Please generate a new client and keypair.');
}
Expand Down Expand Up @@ -174,6 +175,10 @@ export class NanoTDFClient extends Client {
}
}

export type DatasetConfig = ClientConfig & {
maxKeyIterations?: number;
};

/**
* NanoTDF Dataset SDK Client
*
Expand All @@ -186,15 +191,15 @@ export class NanoTDFClient extends Client {
* const KAS_URL = 'http://localhost:65432/api/kas/';
*
* const ciphertext = '...';
* const client = new NanoTDFDatasetClient.default(
* await clientSecretAuthProvider({
* const client = new NanoTDFDatasetClient({
* authProvider: await clientSecretAuthProvider({
* clientId: 'tdf-client',
* clientSecret: '123-456',
* exchange: 'client',
* oidcOrigin: OIDC_ENDPOINT,
* }),
* KAS_URL
* );
* kasEndpoint: KAS_URL,
* });
* const plaintext = client.decrypt(ciphertext);
* console.log('Plaintext', plaintext);
* ```
Expand Down Expand Up @@ -223,19 +228,18 @@ export class NanoTDFDatasetClient extends Client {
* @param ephemeralKeyPair (optional) ephemeral key pair to use
* @param maxKeyIterations Max iteration to performe without a key rotation
*/
constructor(
authProvider: AuthProvider,
kasUrl: string,
maxKeyIterations: number = NanoTDFDatasetClient.NTDF_MAX_KEY_ITERATIONS,
ephemeralKeyPair?: Required<Readonly<CryptoKeyPair>>
) {
if (maxKeyIterations > NanoTDFDatasetClient.NTDF_MAX_KEY_ITERATIONS) {
throw new Error('Key iteration exceeds max iterations(8388606)');
constructor(opts: DatasetConfig) {
if (
opts.maxKeyIterations &&
opts.maxKeyIterations > NanoTDFDatasetClient.NTDF_MAX_KEY_ITERATIONS
) {
throw new Error(
`Key iteration exceeds max iterations(${NanoTDFDatasetClient.NTDF_MAX_KEY_ITERATIONS})`
);
}
super(opts);

super(authProvider, kasUrl, ephemeralKeyPair);

this.maxKeyIteration = maxKeyIterations;
this.maxKeyIteration = opts.maxKeyIterations || NanoTDFDatasetClient.NTDF_MAX_KEY_ITERATIONS;
this.keyIterationCount = 0;
}

Expand All @@ -250,7 +254,7 @@ export class NanoTDFDatasetClient extends Client {
// Intial encrypt
if (this.keyIterationCount == 0) {
// For encrypt always generate the client ephemeralKeyPair
const ephemeralKeyPair = await this.generateEphemeralKeyPair();
const ephemeralKeyPair = await this.ephemeralKeyPair;

if (!this.kasPubKey) {
this.kasPubKey = await fetchKasPubKey(this.kasUrl);
Expand Down
154 changes: 86 additions & 68 deletions lib/src/nanotdf/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,59 @@ import { cryptoPublicToPem, safeUrlCheck, validateSecureUrl } from '../utils.js'

const { KeyUsageType, AlgorithmName, NamedCurve } = cryptoEnums;

export interface ClientConfig {
authProvider: AuthProvider;
dpopEnabled?: boolean;
dpopKeys?: Promise<CryptoKeyPair>;
ephemeralKeyPair?: Promise<CryptoKeyPair>;
kasEndpoint: string;
}

function toJWSAlg(c: CryptoKey): string {
const { algorithm } = c;
switch (algorithm.name) {
case 'RSASSA-PKCS1-v1_5':
case 'RSA-PSS':
case 'RSA-OAEP': {
const r = algorithm as RsaHashedKeyGenParams;
switch (r.modulusLength) {
case 2048:
return 'RS256';
case 3072:
return 'RS384';
case 3072:
return 'RS512';
}
}
case 'ECDSA':
case 'ECDH': {
return 'ES256';
}
}
throw new Error(`Unsupported key algorithm ${JSON.stringify(algorithm)}`);
}

async function generateEphemeralKeyPair(): Promise<CryptoKeyPair> {
const { publicKey, privateKey } = await generateKeyPair();
if (!privateKey || !publicKey) {
throw Error('Key pair generation failed');
}
return { publicKey, privateKey };
}

async function generateSignerKeyPair(): Promise<CryptoKeyPair> {
const { publicKey, privateKey } = await generateKeyPair({
type: AlgorithmName.ECDSA,
curve: NamedCurve.P256,
keyUsages: [KeyUsageType.Sign, KeyUsageType.Verify],
isExtractable: true,
});
if (!privateKey || !publicKey) {
throw Error('Signer key pair generation failed');
}
return { publicKey, privateKey };
}

/**
* A Client encapsulates sessions interacting with TDF3 and nanoTDF backends, KAS and any
* plugin-based sessions like identity and further attribute control. Most importantly, it is responsible
Expand Down Expand Up @@ -63,8 +116,8 @@ export default class Client {
readonly dpopEnabled: boolean;
dissems: string[] = [];
dataAttributes: string[] = [];
protected ephemeralKeyPair?: Required<Readonly<CryptoKeyPair>>;
protected requestSignerKeyPair?: Required<Readonly<CryptoKeyPair>>;
protected ephemeralKeyPair: Promise<CryptoKeyPair>;
protected requestSignerKeyPair: Promise<CryptoKeyPair>;
protected iv?: number;

/**
Expand All @@ -74,59 +127,32 @@ export default class Client {
* cannot be changed. If a new ephemeral key is desired it a new client should be initialized.
* There is no performance impact for creating a new client IFF the ephemeral key pair is provided.
*/
constructor(
authProvider: AuthProvider,
kasUrl: string,
ephemeralKeyPair?: Required<Readonly<CryptoKeyPair>>,
dpopEnabled = false
) {
constructor({
authProvider,
ephemeralKeyPair,
kasEndpoint,
dpopEnabled,
dpopKeys,
}: ClientConfig) {
this.authProvider = authProvider;
// TODO Disallow http KAS. For now just log as error
validateSecureUrl(kasUrl);
this.kasUrl = kasUrl;
this.allowedKases = [kasUrl];
validateSecureUrl(kasEndpoint);
this.kasUrl = kasEndpoint;
this.allowedKases = [kasEndpoint];
this.kasPubKey = '';
this.dpopEnabled = dpopEnabled;
this.dpopEnabled = !!dpopEnabled;
if (dpopKeys) {
this.requestSignerKeyPair = dpopKeys;
} else {
this.requestSignerKeyPair = generateSignerKeyPair();
}

if (ephemeralKeyPair) {
this.ephemeralKeyPair = ephemeralKeyPair;
this.iv = 1;
} else {
this.ephemeralKeyPair = generateEphemeralKeyPair();
}
}

/**
* Get ephemeral key pair
*
* Returns the ephemeral key pair to be used in other clients or undefined if not set or generated
*
* @security allow returning ephemeral key pair has unknown security risks.
*/
getEphemeralKeyPair(): CryptoKeyPair | undefined {
return this.ephemeralKeyPair;
}

async generateEphemeralKeyPair(): Promise<Required<Readonly<CryptoKeyPair>>> {
const { publicKey, privateKey } = await generateKeyPair();
if (!privateKey || !publicKey) {
throw Error('Key pair generation failed');
}
this.ephemeralKeyPair = { publicKey, privateKey };
this.iv = 1;
return { publicKey, privateKey };
}

async generateSignerKeyPair(): Promise<Required<Readonly<CryptoKeyPair>>> {
const { publicKey, privateKey } = await generateKeyPair({
type: AlgorithmName.ECDSA,
curve: NamedCurve.P256,
keyUsages: [KeyUsageType.Sign, KeyUsageType.Verify],
isExtractable: true,
});
if (!privateKey || !publicKey) {
throw Error('Signer key pair generation failed');
}
this.requestSignerKeyPair = { publicKey, privateKey };
return { publicKey, privateKey };
}

/**
Expand All @@ -150,18 +176,7 @@ export default class Client {
* either be set on the first call or passed in the constructor.
*/
async fetchOIDCToken(): Promise<void> {
// Generate the ephemeral key pair if not set
const promises: Promise<Required<Readonly<CryptoKeyPair>>>[] = [];
if (!this.ephemeralKeyPair) {
promises.push(this.generateEphemeralKeyPair());
}

if (!this.requestSignerKeyPair) {
promises.push(this.generateSignerKeyPair());
}
await Promise.all(promises);

const signer = this.requestSignerKeyPair;
const signer = await this.requestSignerKeyPair;
if (!signer) {
throw new Error('Unexpected state');
}
Expand Down Expand Up @@ -190,13 +205,15 @@ export default class Client {

// Ensure the ephemeral key pair has been set or generated (see createOidcServiceProvider)
await this.fetchOIDCToken();
const ephemeralKeyPair = await this.ephemeralKeyPair;
const requestSignerKeyPair = await this.requestSignerKeyPair;

// Ensure the ephemeral key pair has been set or generated (see fetchEntityObject)
if (!this.ephemeralKeyPair?.privateKey) {
if (!ephemeralKeyPair?.privateKey) {
throw new Error('Ephemeral key has not been set or generated');
}

if (!this.requestSignerKeyPair?.privateKey) {
if (!requestSignerKeyPair?.privateKey) {
throw new Error('Signer key has not been set or generated');
}

Expand All @@ -210,13 +227,13 @@ export default class Client {
protocol: Client.KAS_PROTOCOL,
header: base64.encodeArrayBuffer(nanoTdfHeader),
},
clientPublicKey: await cryptoPublicToPem(this.ephemeralKeyPair.publicKey),
clientPublicKey: await cryptoPublicToPem(ephemeralKeyPair.publicKey),
});

const jwtPayload = { requestBody: requestBodyStr };
const requestBody = {
signedRequestToken: await reqSignature(jwtPayload, this.requestSignerKeyPair.privateKey, {
alg: AlgorithmName.ES256,
signedRequestToken: await reqSignature(jwtPayload, requestSignerKeyPair.privateKey, {
alg: toJWSAlg(requestSignerKeyPair.publicKey),
}),
};

Expand All @@ -239,10 +256,10 @@ export default class Client {
const iv = entityWrappedKey.subarray(0, ivLength);
const encryptedSharedKey = entityWrappedKey.subarray(ivLength);

let publicKey;
let kasPublicKey;
try {
// Get session public key as crypto key
publicKey = await pemPublicToCrypto(wrappedKey.sessionPublicKey);
kasPublicKey = await pemPublicToCrypto(wrappedKey.sessionPublicKey);
} catch (cause) {
throw new Error(
`PEM Public Key to crypto public key failed. Is PEM formatted correctly?\n Caused by: ${cause.message}`,
Expand All @@ -257,12 +274,13 @@ export default class Client {
} catch (e) {
throw new Error(`Salting hkdf failed\n Caused by: ${e.message}`);
}
const { privateKey } = await this.ephemeralKeyPair;

// Get the unwrapping key
const unwrappingKey = await keyAgreement(
// Ephemeral private key
this.ephemeralKeyPair.privateKey,
publicKey,
privateKey,
kasPublicKey,
hkdfSalt
);

Expand Down
2 changes: 2 additions & 0 deletions lib/tdf3/src/client/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IllegalArgumentError } from '../../../src/errors.js';
import { PemKeyPair } from '../crypto/declarations.js';
import { EntityObject } from '../../../src/tdf/EntityObject.js';
import { DecoratedReadableStream } from './DecoratedReadableStream.js';
import { type Chunker } from '../utils/chunkers.js';

export const DEFAULT_SEGMENT_SIZE: number = 1024 * 1024;
export type Scope = {
Expand Down Expand Up @@ -470,6 +471,7 @@ export type DecryptStreamMiddleware = (

export type DecryptSource =
| { type: 'buffer'; location: Uint8Array }
| { type: 'chunker'; location: Chunker }
| { type: 'remote'; location: string }
| { type: 'stream'; location: ReadableStream<Uint8Array> }
| { type: 'file-browser'; location: Blob };
Expand Down
22 changes: 14 additions & 8 deletions lib/tdf3/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,20 @@ const makeChunkable = async (source: DecryptSource) => {
// we don't support streams anyways (see zipreader.js)
let initialChunker: Chunker;
let buf = null;
if (source.type === 'stream') {
buf = await streamToBuffer(source.location);
initialChunker = fromBuffer(buf);
} else if (source.type === 'buffer') {
buf = source.location;
initialChunker = fromBuffer(buf);
} else {
initialChunker = await fromDataSource(source);
switch (source.type) {
case 'stream':
buf = await streamToBuffer(source.location);
initialChunker = fromBuffer(buf);
break;
case 'buffer':
buf = source.location;
initialChunker = fromBuffer(buf);
break;
case 'chunker':
initialChunker = source.location;
break;
default:
initialChunker = await fromDataSource(source);
}

const magic: string = await getFirstTwoBytes(initialChunker);
Expand Down
Loading

0 comments on commit 19642e1

Please sign in to comment.