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

chore: adds multi-file encrypt and decrypt to web-app #279

Closed
wants to merge 8 commits into from
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,5 @@ dist

# temporary folders
**/temp/
/web-app/tests/smallfiles
.DS_Store
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ remote-store/opentdf-remote-store-$(version).tgz: lib/opentdf-client-$(version).
(cd remote-store && npm ci ../lib/opentdf-client-$(version).tgz && npm pack)

web-app/opentdf-web-app-$(version).tgz: lib/opentdf-client-$(version).tgz $(shell find web-app -not -path '*/dist*' -and -not -path '*/coverage*' -and -not -path '*/node_modules*')
(cd web-app && npm ci ../lib/opentdf-client-$(version).tgz && npm pack)
(cd web-app && npm ci ../lib/opentdf-client-$(version).tgz && npm pack && npm run build)

lib/opentdf-client-$(version).tgz: $(shell find lib -not -path '*/dist*' -and -not -path '*/coverage*' -and -not -path '*/node_modules*')
(cd lib && npm ci --including=dev && npm pack)
Expand Down
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
Loading
Loading