diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 74ba5aad..70b96dd9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -2,8 +2,9 @@ name: Build, Test, and Deliver Client env: do_sonarscan: >- - ${{ github.event_name == 'push' || - github.event.pull_request.head.repo.full_name == github.repository }} + ${{ (github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository) && + github.actor != 'dependabot[bot]' }} on: pull_request: @@ -192,7 +193,7 @@ jobs: with: name: opentdf-ctl path: cli/ - - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 with: go-version: '1.22' - name: Git clone backend @@ -268,7 +269,7 @@ jobs: - run: echo "- [Client Library](https://github.com/opentdf/web-sdk/pkgs/npm/client)">>$GITHUB_STEP_SUMMARY - run: echo "- [Command Line Tool](https://github.com/opentdf/web-sdk/pkgs/npm/cli)">>$GITHUB_STEP_SUMMARY - name: Publish documentation to gh-pages - uses: JamesIves/github-pages-deploy-action@v4.6.0 + uses: JamesIves/github-pages-deploy-action@v4.7.3 with: branch: gh-pages folder: lib/dist/docs diff --git a/cli/src/cli.ts b/cli/src/cli.ts index de980614..4aee8d9b 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -16,6 +16,7 @@ import { tdfSpecVersion, OpenTDF, DecoratedStream, + isPublicKeyAlgorithm, } from '@opentdf/sdk'; import { CLIError, Level, log } from './logger.js'; import { webcrypto } from 'crypto'; @@ -174,6 +175,12 @@ async function parseReadOptions(argv: Partial): Promise { argv.assertionVerificationKeys ); } + if (argv.rewrapKeyType?.length) { + if (!isPublicKeyAlgorithm(argv.rewrapKeyType)) { + throw new CLIError('CRITICAL', `Unsupported rewrap key algorithm: [${argv.rewrapKeyType}]`); + } + r.wrappingKeyAlgorithm = argv.rewrapKeyType; + } if (argv.concurrencyLimit) { r.concurrencyLimit = argv.concurrencyLimit; } else { @@ -272,6 +279,12 @@ async function parseCreateZTDFOptions(argv: Partial): Promise): Promise): Promise { description: 'Container format', default: 'nano', }, + encapKeyType: { + alias: 'encapsulation-algorithm', + group: 'Encrypt Options:', + desc: 'Key type for wrapping keys', + type: 'string', + default: 'rsa:2048', + }, policyBinding: { group: 'Encrypt Options:', choices: bindingTypes, @@ -464,6 +486,13 @@ export const handleArgs = (args: string[]) => { type: 'string', default: '', }, + rewrapKeyType: { + alias: 'rewrap-encapsulation-algorithm', + group: 'Decrypt Options:', + desc: 'Key type for rewrap', + type: 'string', + default: 'rsa:2048', + }, userId: { group: 'Encrypt Options:', type: 'string', diff --git a/lib/src/access.ts b/lib/src/access.ts index 8b952ae8..4b311140 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -87,6 +87,42 @@ export async function fetchWrappedKey( export type KasPublicKeyAlgorithm = 'ec:secp256r1' | 'rsa:2048'; +export const isPublicKeyAlgorithm = (a: string): a is KasPublicKeyAlgorithm => { + return a === 'ec:secp256r1' || a === 'rsa:2048'; +}; + +export const keyAlgorithmToPublicKeyAlgorithm = (a: KeyAlgorithm): KasPublicKeyAlgorithm => { + if (a.name === 'ECDSA' || a.name === 'ECDH') { + const eca = a as EcKeyAlgorithm; + if (eca.namedCurve === 'P-256') { + return 'ec:secp256r1'; + } + throw new Error(`unsupported EC curve: ${eca.namedCurve}`); + } + if (a.name === 'RSA-OAEP') { + const rsaa = a as RsaHashedKeyAlgorithm; + if (rsaa.modulusLength === 2048) { + // if (rsaa.hash.name !== 'RSASSA-PKCS1-v1_5') { + // throw new Error(`unsupported RSA hash: ${rsaa.hash.name}`); + // } + if (rsaa.publicExponent.toString() !== '1,0,1') { + throw new Error(`unsupported RSA public exponent: ${rsaa.publicExponent}`); + } + return 'rsa:2048'; + } + } + throw new Error(`unsupported key algorithm: ${a.name}`); +}; + +export const publicKeyAlgorithmToJwa = (a: KasPublicKeyAlgorithm): string => { + switch (a) { + case 'ec:secp256r1': + return 'ES256'; + case 'rsa:2048': + return 'RS256'; + } +}; + /** * Information about one of a KAS's published public keys. * A KAS may publish multiple keys with a given algorithm type. @@ -188,7 +224,7 @@ export async function fetchKasPubKey( key: noteInvalidPublicKey(pkUrlV2, pemToCryptoPublicKey(publicKey)), publicKey, url: kasEndpoint, - algorithm: 'ec:secp256r1', + algorithm: algorithm || 'rsa:2048', ...(kid && { kid }), }; } diff --git a/lib/src/nanotdf-crypto/generateKeyPair.ts b/lib/src/nanotdf-crypto/generateKeyPair.ts index 97fc09a7..dedb5de6 100644 --- a/lib/src/nanotdf-crypto/generateKeyPair.ts +++ b/lib/src/nanotdf-crypto/generateKeyPair.ts @@ -7,7 +7,7 @@ interface GenerateKeyPairOptions { isExtractable: boolean; } -export default async function generateKeyPair( +export async function generateKeyPair( { type: name, curve: namedCurve, keyUsages, isExtractable }: GenerateKeyPairOptions = { type: AlgorithmName.ECDH, curve: NamedCurve.P256, diff --git a/lib/src/nanotdf-crypto/generateRandomNumber.ts b/lib/src/nanotdf-crypto/generateRandomNumber.ts index 906d8857..61b57265 100644 --- a/lib/src/nanotdf-crypto/generateRandomNumber.ts +++ b/lib/src/nanotdf-crypto/generateRandomNumber.ts @@ -1,7 +1,7 @@ /** * Generate a random number of given length */ -export default function generateRandomNumber(length: number): Uint8Array { +export function generateRandomNumber(length: number): Uint8Array { const byteArray = new Uint8Array(length); crypto.getRandomValues(byteArray); return byteArray; diff --git a/lib/src/nanotdf-crypto/importRawKey.ts b/lib/src/nanotdf-crypto/importRawKey.ts deleted file mode 100644 index d865e0b4..00000000 --- a/lib/src/nanotdf-crypto/importRawKey.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CipherType, KeyFormat, KeyUsageType } from './enums.js'; - -/** - * Import raw key - * - * A specific implementation of the importKey method to import raw keys. Specifies some defaults - * to ensure security. - * - * @param key Key which needs to be imported - * @param keyUsages How the key will be used - * @param isExtractable Whether key is extractable - */ -export default async function importRawKey( - key: ArrayBuffer, - keyUsages: Array, - isExtractable = false -): Promise { - return crypto.subtle.importKey(KeyFormat.Raw, key, CipherType.AesGcm, isExtractable, keyUsages); -} diff --git a/lib/src/nanotdf-crypto/index.ts b/lib/src/nanotdf-crypto/index.ts index 5165f4f9..1904862a 100644 --- a/lib/src/nanotdf-crypto/index.ts +++ b/lib/src/nanotdf-crypto/index.ts @@ -2,10 +2,9 @@ export { Ciphers } from './ciphers.js'; export { default as decrypt } from './decrypt.js'; export { default as digest } from './digest.js'; export { default as encrypt } from './encrypt.js'; -export { default as generateKeyPair } from './generateKeyPair.js'; -export { default as importRawKey } from './importRawKey.js'; +export { generateKeyPair } from './generateKeyPair.js'; export { keyAgreement } from './keyAgreement.js'; export { default as exportCryptoKey } from './exportCryptoKey.js'; -export { default as generateRandomNumber } from './generateRandomNumber.js'; +export { generateRandomNumber } from './generateRandomNumber.js'; export { pemPublicToCrypto, pemCertToCrypto } from './pemPublicToCrypto.js'; export * as enums from './enums.js'; diff --git a/lib/src/nanotdf-crypto/keyAgreement.ts b/lib/src/nanotdf-crypto/keyAgreement.ts index 9b76590c..40be7d42 100644 --- a/lib/src/nanotdf-crypto/keyAgreement.ts +++ b/lib/src/nanotdf-crypto/keyAgreement.ts @@ -27,6 +27,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { keyAlgorithmToPublicKeyAlgorithm } from '../access.js'; import { ConfigurationError } from '../errors.js'; import { AlgorithmName, CipherType, HashType, KeyFormat, KeyType, KeyUsageType } from './enums.js'; @@ -69,19 +70,25 @@ export async function keyAgreement( isExtractable: true, } ): Promise { - if ( - publicKey?.algorithm?.name !== AlgorithmName.ECDSA && - publicKey?.algorithm?.name !== AlgorithmName.ECDH - ) { - throw new ConfigurationError('CryptoKey is expected to be of type ECDSA or ECDH'); + for (const k of [privateKey, publicKey]) { + const mechanism = keyAlgorithmToPublicKeyAlgorithm(k.algorithm); + if (mechanism !== 'ec:secp256r1') { + throw new ConfigurationError( + `${k.type} CryptoKey is expected to be of type ECDSA or ECDH, not [${k.algorithm?.name}]` + ); + } } if (privateKey.type !== KeyType.Private) { - throw new ConfigurationError('Expected input of privateKey to be a CryptoKey of type private'); + throw new ConfigurationError( + `Expected input of privateKey to be a CryptoKey of type private, not [${privateKey.type}]` + ); } if (publicKey.type !== KeyType.Public) { - throw new ConfigurationError('Expected input of publicKey to be a CryptoKey of type public'); + throw new ConfigurationError( + `Expected input of publicKey to be a CryptoKey of type public, not [${publicKey.type}]` + ); } const { diff --git a/lib/src/opentdf.ts b/lib/src/opentdf.ts index d0cfb311..69446522 100644 --- a/lib/src/opentdf.ts +++ b/lib/src/opentdf.ts @@ -9,9 +9,11 @@ import Header from './nanotdf/models/Header.js'; import { fromSource, sourceToStream, type Source } from './seekable.js'; import { Client as TDF3Client } from '../tdf3/src/client/index.js'; import { AssertionConfig, AssertionVerificationKeys } from '../tdf3/src/assertions.js'; -import { OriginAllowList } from './access.js'; +import { type KasPublicKeyAlgorithm, OriginAllowList, isPublicKeyAlgorithm } from './access.js'; import { type Manifest } from '../tdf3/src/models/manifest.js'; +export { type KasPublicKeyAlgorithm, isPublicKeyAlgorithm }; + export type Keys = { [keyID: string]: CryptoKey | CryptoKeyPair; }; @@ -91,6 +93,9 @@ export type CreateZTDFOptions = CreateOptions & { // The segment size for the content; smaller is slower, but allows faster random access. // The current default is 1 MiB (2^20 bytes). windowSize?: number; + + // Preferred algorithm to use for Key Access Objects. + wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; }; // Settings for decrypting any variety of TDF file. @@ -108,6 +113,9 @@ export type ReadOptions = { // If set, prevents more than this number of concurrent requests to the KAS. concurrencyLimit?: number; + + // Type of key to use for wrapping responses. + wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; }; // Defaults and shared settings that are relevant to creating TDF objects. @@ -138,7 +146,7 @@ export type OpenTDFOptions = { export type DecoratedStream = ReadableStream & { // If the source is a TDF3/ZTDF, and includes metadata, and it has been read. - metadata?: Promise; + metadata?: Promise; manifest?: Promise; // If the source is a NanoTDF, this will be set. header?: Header; @@ -300,6 +308,7 @@ export class OpenTDF { }, splitPlan: opts.splitPlan, windowSize: opts.windowSize, + wrappingKeyAlgorithm: opts.wrappingKeyAlgorithm, }); const stream: DecoratedStream = oldStream.stream; stream.manifest = Promise.resolve(oldStream.manifest); @@ -323,6 +332,7 @@ export class OpenTDF { allowList, assertionVerificationKeys: opts.assertionVerificationKeys, noVerifyAssertions: opts.noVerify, + wrappingKeyAlgorithm: opts.wrappingKeyAlgorithm, }); const stream: DecoratedStream = oldStream.stream; stream.metadata = Promise.resolve(oldStream.metadata); diff --git a/lib/tdf3/src/client/builders.ts b/lib/tdf3/src/client/builders.ts index 5d0d5033..bd87a01b 100644 --- a/lib/tdf3/src/client/builders.ts +++ b/lib/tdf3/src/client/builders.ts @@ -9,7 +9,7 @@ import { DecoratedReadableStream } from './DecoratedReadableStream.js'; import { type Chunker } from '../../../src/seekable.js'; import { AssertionConfig, AssertionVerificationKeys } from '../assertions.js'; import { Value } from '../../../src/policy/attributes.js'; -import { OriginAllowList } from '../../../src/access.js'; +import { KasPublicKeyAlgorithm, OriginAllowList } from '../../../src/access.js'; export const DEFAULT_SEGMENT_SIZE: number = 1024 * 1024; export type Scope = { @@ -52,6 +52,9 @@ export type EncryptParams = { assertionConfigs?: AssertionConfig[]; defaultKASEndpoint?: string; + // Preferred wrapping key algorithm. Used when KID resolution is not available. + wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; + // Unsupported asHtml?: boolean; // Unsupported @@ -517,6 +520,7 @@ export type DecryptParams = { assertionVerificationKeys?: AssertionVerificationKeys; concurrencyLimit?: number; noVerifyAssertions?: boolean; + wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; }; /** diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index 7d03d662..389cbf05 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -7,7 +7,7 @@ import { import { base64 } from '../../../src/encodings/index.js'; import { buildKeyAccess, - EncryptConfiguration, + type EncryptConfiguration, fetchKasPublicKey, loadTDFStream, validatePolicyObject, @@ -22,13 +22,13 @@ import { type AuthProvider, HttpRequest, withHeaders } from '../../../src/auth/a import { pemToCryptoPublicKey, rstrip, validateSecureUrl } from '../../../src/utils.js'; import { - EncryptParams, - DecryptParams, + type EncryptParams, + type DecryptParams, type Scope, - DecryptStreamMiddleware, - EncryptKeyMiddleware, - EncryptStreamMiddleware, - SplitStep, + type DecryptStreamMiddleware, + type EncryptKeyMiddleware, + type EncryptStreamMiddleware, + type SplitStep, } from './builders.js'; import { DecoratedReadableStream } from './DecoratedReadableStream.js'; @@ -38,13 +38,22 @@ import { type DecryptSource, EncryptParamsBuilder, } from './builders.js'; -import { KasPublicKeyInfo, OriginAllowList } from '../../../src/access.js'; +import { + type KasPublicKeyInfo, + keyAlgorithmToPublicKeyAlgorithm, + OriginAllowList, +} from '../../../src/access.js'; import { ConfigurationError } from '../../../src/errors.js'; import { Binary } from '../binary.js'; import { AesGcmCipher } from '../ciphers/aes-gcm-cipher.js'; import { toCryptoKeyPair } from '../crypto/crypto-utils.js'; import * as defaultCryptoService from '../crypto/index.js'; -import { type AttributeObject, type Policy, SplitKey } from '../models/index.js'; +import { + type AttributeObject, + type KeyAccessType, + type Policy, + SplitKey, +} from '../models/index.js'; import { plan } from '../../../src/policy/granter.js'; import { attributeFQNsAsValues } from '../../../src/policy/api.js'; import { type Value } from '../../../src/policy/attributes.js'; @@ -57,6 +66,23 @@ const defaultClientConfig = { oidcOrigin: '', cryptoService: defaultCryptoServic const getFirstTwoBytes = async (chunker: Chunker) => new TextDecoder().decode(await chunker(0, 2)); +// Convert a PEM string to a CryptoKey +export const resolveKasInfo = async ( + pem: string, + uri: string, + kid?: string +): Promise => { + const k: CryptoKey = await pemToCryptoPublicKey(pem); + const algorithm = keyAlgorithmToPublicKeyAlgorithm(k.algorithm); + return { + key: Promise.resolve(k), + publicKey: pem, + url: uri, + algorithm, + kid: kid, + }; +}; + const makeChunkable = async (source: DecryptSource) => { if (!source) { throw new ConfigurationError('invalid source'); @@ -213,7 +239,7 @@ export class Client { */ readonly allowedKases: OriginAllowList; - readonly kasKeys: Record> = {}; + readonly kasKeys: Record[]> = {}; readonly easEndpoint?: string; @@ -319,12 +345,9 @@ export class Client { dpopKeys: clientConfig.dpopKeys, }); if (clientConfig.kasPublicKey) { - this.kasKeys[this.kasEndpoint] = Promise.resolve({ - url: this.kasEndpoint, - algorithm: 'rsa:2048', - key: pemToCryptoPublicKey(clientConfig.kasPublicKey), - publicKey: clientConfig.kasPublicKey, - }); + this.kasKeys[this.kasEndpoint] = [ + resolveKasInfo(clientConfig.kasPublicKey, this.kasEndpoint), + ]; } } @@ -358,6 +381,7 @@ export class Client { windowSize = DEFAULT_SEGMENT_SIZE, keyMiddleware = defaultKeyMiddleware, streamMiddleware = async (stream: DecoratedReadableStream) => stream, + wrappingKeyAlgorithm = 'rsa:2048', } = opts; const scope = opts.scope ?? { attributes: [], dissem: [] }; @@ -403,18 +427,9 @@ export class Client { splitPlan = detailedPlan.map((kat) => { const { kas, sid } = kat; if (kas?.publicKey?.cached?.keys && !(kas.uri in this.kasKeys)) { - const keys = kas.publicKey.cached.keys.filter( - ({ alg }) => alg == 'KAS_PUBLIC_KEY_ALG_ENUM_RSA_2048' - ); + const keys = kas.publicKey.cached.keys; if (keys?.length) { - const key = keys[0]; - this.kasKeys[kas.uri] = Promise.resolve({ - key: pemToCryptoPublicKey(key.pem), - publicKey: key.pem, - url: kas.uri, - algorithm: 'rsa:2048', - kid: key.kid, - }); + this.kasKeys[kas.uri] = keys.map((key) => resolveKasInfo(key.pem, kas.uri, key.kid)); } } return { kas: kas.uri, sid }; @@ -435,11 +450,28 @@ export class Client { encryptionInformation.keyAccess = await Promise.all( splits.map(async ({ kas, sid }) => { if (!(kas in this.kasKeys)) { - this.kasKeys[kas] = fetchKasPublicKey(kas); + this.kasKeys[kas] = [fetchKasPublicKey(kas, wrappingKeyAlgorithm)]; + } + const kasPublicKey = await Promise.any(this.kasKeys[kas]); + if (kasPublicKey.algorithm !== wrappingKeyAlgorithm) { + console.warn( + `Mismatched wrapping key algorithm: [${kasPublicKey.algorithm}] is not requested type, [${wrappingKeyAlgorithm}]` + ); + } + let type: KeyAccessType; + switch (kasPublicKey.algorithm) { + case 'rsa:2048': + type = 'wrapped'; + break; + case 'ec:secp256r1': + type = 'ec-wrapped'; + break; + default: + throw new ConfigurationError(`Unsupported algorithm ${kasPublicKey.algorithm}`); } - const kasPublicKey = await this.kasKeys[kas]; return buildKeyAccess({ - type: 'wrapped', + alg: kasPublicKey.algorithm, + type, url: kasPublicKey.url, kid: kasPublicKey.kid, publicKey: kasPublicKey.publicKey, @@ -490,6 +522,7 @@ export class Client { assertionVerificationKeys, noVerifyAssertions, concurrencyLimit = 1, + wrappingKeyAlgorithm, }: DecryptParams): Promise { const dpopKeys = await this.dpopKeys; if (!this.authProvider) { @@ -515,6 +548,7 @@ export class Client { progressHandler: this.clientConfig.progressHandler, assertionVerificationKeys, noVerifyAssertions, + wrappingKeyAlgorithm, }) ); } diff --git a/lib/tdf3/src/models/encryption-information.ts b/lib/tdf3/src/models/encryption-information.ts index 20b99743..6aeca079 100644 --- a/lib/tdf3/src/models/encryption-information.ts +++ b/lib/tdf3/src/models/encryption-information.ts @@ -78,7 +78,7 @@ export class SplitKey { } async getKeyAccessObjects(policy: Policy, keyInfo: KeyInfo): Promise { - const splitIds = [...new Set(this.keyAccess.map(({ sid }) => sid))].sort((a, b) => + const splitIds = [...new Set(this.keyAccess.map(({ sid }) => sid))].sort((a = '', b = '') => a.localeCompare(b) ); const unwrappedKeySplitBuffers = await keySplit( @@ -93,7 +93,7 @@ export class SplitKey { const keyAccessObjects = []; for (const item of this.keyAccess) { // use the key split to encrypt metadata for each key access object - const unwrappedKeySplitBuffer = splitsByName[item.sid]; + const unwrappedKeySplitBuffer = splitsByName[item.sid || '']; const unwrappedKeySplitBinary = Binary.fromArrayBuffer(unwrappedKeySplitBuffer.buffer); const metadata = item.metadata || ''; diff --git a/lib/tdf3/src/models/key-access.ts b/lib/tdf3/src/models/key-access.ts index d93d067a..e7155b25 100644 --- a/lib/tdf3/src/models/key-access.ts +++ b/lib/tdf3/src/models/key-access.ts @@ -1,14 +1,86 @@ -import { Binary } from '../binary.js'; import { base64, hex } from '../../../src/encodings/index.js'; +import { generateRandomNumber } from '../../../src/nanotdf-crypto/generateRandomNumber.js'; +import { keyAgreement } from '../../../src/nanotdf-crypto/keyAgreement.js'; +import { pemPublicToCrypto } from '../../../src/nanotdf-crypto/pemPublicToCrypto.js'; +import { cryptoPublicToPem } from '../../../src/utils.js'; +import { Binary } from '../binary.js'; import * as cryptoService from '../crypto/index.js'; import { Policy } from './policy.js'; -export type KeyAccessType = 'remote' | 'wrapped'; +export type KeyAccessType = 'remote' | 'wrapped' | 'ec-wrapped'; export const schemaVersion = '1.0'; -export function isRemote(keyAccessJSON: KeyAccess | KeyAccessObject): boolean { - return keyAccessJSON.type === 'remote'; +export class ECWrapped { + readonly type = 'ec-wrapped'; + readonly ephemeralKeyPair: Promise; + keyAccessObject?: KeyAccessObject; + + constructor( + public readonly url: string, + public readonly kid: string | undefined, + public readonly publicKey: string, + public readonly metadata: unknown, + public readonly sid?: string + ) { + this.ephemeralKeyPair = crypto.subtle.generateKey( + { + name: 'ECDH', + namedCurve: 'P-256', + }, + false, + ['deriveBits', 'deriveKey'] + ); + } + + async write( + policy: Policy, + dek: Uint8Array, + encryptedMetadataStr: string + ): Promise { + const policyStr = JSON.stringify(policy); + const [ek, clientPublicKey] = await Promise.all([ + this.ephemeralKeyPair, + pemPublicToCrypto(this.publicKey), + ]); + const kek = await keyAgreement(ek.privateKey, clientPublicKey, { + hkdfSalt: new TextEncoder().encode('salt'), + hkdfHash: 'SHA-256', + }); + const iv = generateRandomNumber(12); + const cek = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, kek, dek); + const entityWrappedKey = new Uint8Array(iv.length + cek.byteLength); + entityWrappedKey.set(iv); + entityWrappedKey.set(new Uint8Array(cek), iv.length); + + const policyBinding = await cryptoService.hmac( + hex.encodeArrayBuffer(dek), + base64.encode(policyStr) + ); + + const ephemeralPublicKeyPEM = await cryptoPublicToPem(ek.publicKey); + const kao: KeyAccessObject = { + type: 'ec-wrapped', + url: this.url, + protocol: 'kas', + wrappedKey: base64.encodeArrayBuffer(entityWrappedKey), + encryptedMetadata: base64.encode(encryptedMetadataStr), + policyBinding: { + alg: 'HS256', + hash: base64.encode(policyBinding), + }, + schemaVersion, + ephemeralPublicKey: ephemeralPublicKeyPEM, + }; + if (this.kid) { + kao.kid = this.kid; + } + if (this.sid?.length) { + kao.sid = this.sid; + } + this.keyAccessObject = kao; + return kao; + } } export class Wrapped { @@ -20,7 +92,7 @@ export class Wrapped { public readonly kid: string | undefined, public readonly publicKey: string, public readonly metadata: unknown, - public readonly sid: string + public readonly sid?: string ) {} async write( @@ -63,7 +135,7 @@ export class Wrapped { } } -export type KeyAccess = Wrapped; +export type KeyAccess = ECWrapped | Wrapped; /** * A KeyAccess object stores all information about how an object key OR one key split is stored. @@ -130,4 +202,9 @@ export type KeyAccessObject = { * Version information for the KAO format. */ schemaVersion?: string; + + /** + * PEM encoded ephemeral public key, if wrapped with a KAS EC key. + */ + ephemeralPublicKey?: string; }; diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 26a24aaf..b0583247 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -6,6 +6,7 @@ import { OriginAllowList, fetchKasPubKey as fetchKasPubKeyV2, fetchWrappedKey, + publicKeyAlgorithmToJwa, } from '../../src/access.js'; import { type AuthProvider, reqSignature } from '../../src/auth/auth.js'; import { allPool, anyPool } from '../../src/concurrency.js'; @@ -19,6 +20,9 @@ import { UnsafeUrlError, UnsupportedFeatureError as UnsupportedError, } from '../../src/errors.js'; +import { generateKeyPair } from '../../src/nanotdf-crypto/generateKeyPair.js'; +import { keyAgreement } from '../../src/nanotdf-crypto/keyAgreement.js'; +import { pemPublicToCrypto } from '../../src/nanotdf-crypto/pemPublicToCrypto.js'; import { type Chunker } from '../../src/seekable.js'; import { PolicyObject } from '../../src/tdf/PolicyObject.js'; import { tdfSpecVersion } from '../../src/version.js'; @@ -29,14 +33,20 @@ import { AesGcmCipher } from './ciphers/aes-gcm-cipher.js'; import { SymmetricCipher } from './ciphers/symmetric-cipher-base.js'; import { DecryptParams } from './client/builders.js'; import { DecoratedReadableStream } from './client/DecoratedReadableStream.js'; -import { type CryptoService, type DecryptResult } from './crypto/declarations.js'; import { + AnyKeyPair, + PemKeyPair, + type CryptoService, + type DecryptResult, +} from './crypto/declarations.js'; +import { + ECWrapped, KeyAccessType, KeyInfo, Manifest, Policy, SplitKey, - Wrapped as KeyAccessWrapped, + Wrapped, KeyAccess, KeyAccessObject, SplitType, @@ -71,6 +81,7 @@ export type Metadata = { export type BuildKeyAccess = { type: KeyAccessType; + alg?: KasPublicKeyAlgorithm; url?: string; kid?: string; publicKey: string; @@ -155,6 +166,7 @@ export type DecryptConfiguration = { assertionVerificationKeys?: AssertionVerificationKeys; noVerifyAssertions?: boolean; concurrencyLimit?: number; + wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; }; export type UpsertConfiguration = { @@ -191,13 +203,17 @@ export async function fetchKasPublicKey( return fetchKasPubKeyV2(kas, algorithm || 'rsa:2048'); } -export async function extractPemFromKeyString(keyString: string): Promise { +export async function extractPemFromKeyString( + keyString: string, + alg: KasPublicKeyAlgorithm +): Promise { let pem: string = keyString; // Skip the public key extraction if we find that the KAS url provides a // PEM-encoded key instead of certificate if (keyString.includes('CERTIFICATE')) { - const cert = await importX509(keyString, 'RS256', { extractable: true }); + const a = publicKeyAlgorithmToJwa(alg); + const cert = await importX509(keyString, a, { extractable: true }); pem = await exportSPKI(cert); } @@ -224,30 +240,34 @@ export async function buildKeyAccess({ kid, metadata, sid = '', + alg = 'rsa:2048', }: BuildKeyAccess): Promise { - /** Internal function to keep it DRY */ - function createKeyAccess( - type: KeyAccessType, - kasUrl: string, - kasKeyIdentifier: string | undefined, - pubKey: string, - metadata?: Metadata - ) { - switch (type) { - case 'wrapped': - return new KeyAccessWrapped(kasUrl, kasKeyIdentifier, pubKey, metadata, sid); - default: - throw new ConfigurationError(`buildKeyAccess: Key access type [${type}] is unsupported`); - } - } - // if url and pulicKey are specified load the key access object with them - if (url && publicKey) { - return createKeyAccess(type, url, kid, await extractPemFromKeyString(publicKey), metadata); + if (!url && !publicKey) { + throw new ConfigurationError('TDF.buildKeyAccess: No source for kasUrl or pubKey'); + } else if (!url) { + throw new ConfigurationError('TDF.buildKeyAccess: No kasUrl'); + } else if (!publicKey) { + throw new ConfigurationError('TDF.buildKeyAccess: No kas public key'); } - // All failed. Raise an error. - throw new ConfigurationError('TDF.buildKeyAccess: No source for kasUrl or pubKey'); + let pubKey: string; + try { + pubKey = await extractPemFromKeyString(publicKey, alg); + } catch (e) { + throw new ConfigurationError( + `TDF.buildKeyAccess: Invalid public key [${publicKey}], caused by [${e}]`, + e + ); + } + switch (type) { + case 'wrapped': + return new Wrapped(url, kid, pubKey, metadata, sid); + case 'ec-wrapped': + return new ECWrapped(url, kid, pubKey, metadata, sid); + default: + throw new ConfigurationError(`buildKeyAccess: Key access type [${type}] is unsupported`); + } } export function validatePolicyObject(policy: Policy): void { @@ -632,6 +652,7 @@ async function unwrapKey({ dpopKeys, concurrencyLimit, cryptoService, + wrappingKeyAlgorithm, }: { manifest: Manifest; allowedKases: OriginAllowList; @@ -639,6 +660,7 @@ async function unwrapKey({ concurrencyLimit?: number; dpopKeys: CryptoKeyPair; cryptoService: CryptoService; + wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; }) { if (authProvider === undefined) { throw new ConfigurationError( @@ -650,9 +672,18 @@ async function unwrapKey({ async function tryKasRewrap(keySplitInfo: KeyAccessObject): Promise { const url = `${keySplitInfo.url}/v2/rewrap`; - const ephemeralEncryptionKeys = await cryptoService.cryptoToPemPair( - await cryptoService.generateKeyPair() - ); + let ephemeralEncryptionKeysRaw: AnyKeyPair; + let ephemeralEncryptionKeys: PemKeyPair; + if (wrappingKeyAlgorithm === 'ec:secp256r1') { + ephemeralEncryptionKeysRaw = await generateKeyPair(); + ephemeralEncryptionKeys = await cryptoService.cryptoToPemPair(ephemeralEncryptionKeysRaw); + } else if (wrappingKeyAlgorithm === 'rsa:2048' || !wrappingKeyAlgorithm) { + ephemeralEncryptionKeysRaw = await cryptoService.generateKeyPair(); + ephemeralEncryptionKeys = await cryptoService.cryptoToPemPair(ephemeralEncryptionKeysRaw); + } else { + throw new ConfigurationError(`Unsupported wrapping key algorithm [${wrappingKeyAlgorithm}]`); + } + const clientPublicKey = ephemeralEncryptionKeys.publicKey; const requestBodyStr = JSON.stringify({ @@ -665,13 +696,31 @@ async function unwrapKey({ const jwtPayload = { requestBody: requestBodyStr }; const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey); - const { entityWrappedKey, metadata } = await fetchWrappedKey( + const { entityWrappedKey, metadata, sessionPublicKey } = await fetchWrappedKey( url, { signedRequestToken }, authProvider, '0.0.1' ); + if (wrappingKeyAlgorithm === 'ec:secp256r1') { + const serverEphemeralKey: CryptoKey = await pemPublicToCrypto(sessionPublicKey); + const ekr = ephemeralEncryptionKeysRaw as CryptoKeyPair; + const kek = await keyAgreement(ekr.privateKey, serverEphemeralKey, { + hkdfSalt: new TextEncoder().encode('salt'), + hkdfHash: 'SHA-256', + }); + const wrappedKeyAndNonce = base64.decodeArrayBuffer(entityWrappedKey); + const iv = wrappedKeyAndNonce.slice(0, 12); + const wrappedKey = wrappedKeyAndNonce.slice(12); + + const dek = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey); + + return { + key: new Uint8Array(dek), + metadata, + }; + } const key = Binary.fromString(base64.decode(entityWrappedKey)); const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey( key, diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 4ff48033..f2344554 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -2,6 +2,7 @@ import { assert, expect } from 'chai'; import { getMocks } from '../mocks/index.js'; +import { KasPublicKeyAlgorithm } from '../../src/access.js'; import { AuthProvider, HttpRequest } from '../../src/auth/auth.js'; import { AesGcmCipher, KeyInfo, SplitKey, WebCryptoService } from '../../tdf3/index.js'; import { Client } from '../../tdf3/src/index.js'; @@ -209,6 +210,9 @@ describe('rewrap error cases', function () { assert.fail('Expected NetworkError'); } catch (error) { expect(() => { + if (error instanceof AggregateError) { + throw error.errors[0]; + } throw error; }).to.throw(NetworkError); } @@ -250,127 +254,135 @@ describe('encrypt decrypt test', async function () { const expectedVal = 'hello world'; const kasUrl = `http://localhost:3000`; - it('encrypt-decrypt stream source happy path', async function () { - const cipher = new AesGcmCipher(WebCryptoService); - const encryptionInformation = new SplitKey(cipher); - const key1 = await encryptionInformation.generateKey(); - const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); - - // sandbox.spy(tdf1, '_generateManifest'); - // sandbox.stub(tdf1, 'unwrapKey').callsFake(async () => { - // // @ts-ignore - // const keyInfo = tdf1._generateManifest.lastCall.args[0]; - // return { - // reconstructedKeyBinary: keyInfo.unwrappedKeyBinary as Binary, - // metadata: undefined, - // }; - // }); - const client = new Client.Client({ - kasEndpoint: kasUrl, - dpopKeys: Mocks.entityKeyPair(), - clientId: 'id', - authProvider, - }); - const keyPair = await crypto.subtle.generateKey( - { - name: 'RSASSA-PKCS1-v1_5', - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: { name: 'SHA-256' }, - }, - true, - ['sign', 'verify'] - ); - const publicKey = keyPair.publicKey; - const scope: Scope = { - dissem: ['user@domain.com'], - attributes: [], - }; - - // Generate a random HS256 key - const hs256Key = new Uint8Array(32); - crypto.getRandomValues(hs256Key); - - const encryptedStream = await client.encrypt({ - metadata: Mocks.getMetadataObject(), - offline: true, - scope, - keyMiddleware, - source: new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(expectedVal)); - controller.close(); - }, - }), - assertionConfigs: [ - { - id: 'assertion1', - type: 'handling', - scope: 'tdo', - statement: { - format: 'json', - schema: 'https://example.com/schema', - value: '{"example": "value"}', + for (const encapKeyType of ['ec:secp256r1', 'rsa:2048'] as KasPublicKeyAlgorithm[]) { + for (const rewrapKeyType of ['ec:secp256r1', 'rsa:2048'] as KasPublicKeyAlgorithm[]) { + it(`encrypt-decrypt stream source happy path {encap: ${encapKeyType}, rewrap: ${rewrapKeyType}}`, async function () { + const cipher = new AesGcmCipher(WebCryptoService); + const encryptionInformation = new SplitKey(cipher); + const key1 = await encryptionInformation.generateKey(); + const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); + + // sandbox.spy(tdf1, '_generateManifest'); + // sandbox.stub(tdf1, 'unwrapKey').callsFake(async () => { + // // @ts-ignore + // const keyInfo = tdf1._generateManifest.lastCall.args[0]; + // return { + // reconstructedKeyBinary: keyInfo.unwrappedKeyBinary as Binary, + // metadata: undefined, + // }; + // }); + const client = new Client.Client({ + kasEndpoint: kasUrl, + dpopKeys: Mocks.entityKeyPair(), + clientId: 'id', + authProvider, + }); + const assertionKeys = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, }, - appliesToState: 'encrypted', - signingKey: { - alg: 'HS256', - key: hs256Key, + true, + ['sign', 'verify'] + ); + const assertionPublicKey = assertionKeys.publicKey; + const scope: Scope = { + dissem: ['user@domain.com'], + attributes: [], + }; + + // Generate a random HS256 key + const hs256Key = new Uint8Array(32); + crypto.getRandomValues(hs256Key); + + console.log('ASDF about to encrypt'); + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + wrappingKeyAlgorithm: encapKeyType, + offline: true, + scope, + keyMiddleware, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + assertionConfigs: [ + { + id: 'assertion1', + type: 'handling', + scope: 'tdo', + statement: { + format: 'json', + schema: 'https://example.com/schema', + value: '{"example": "value"}', + }, + appliesToState: 'encrypted', + signingKey: { + alg: 'HS256', + key: hs256Key, + }, + }, + { + id: 'assertion2', + type: 'handling', + scope: 'tdo', + statement: { + format: 'json', + schema: 'https://example.com/schema', + value: '{"example": "value"}', + }, + appliesToState: 'encrypted', + signingKey: { + alg: 'RS256', + key: assertionKeys.privateKey, + }, + }, + { + id: 'assertion3', + type: 'handling', + scope: 'tdo', + statement: { + format: 'json', + schema: 'https://example.com/schema', + value: '{"example": "value"}', + }, + appliesToState: 'encrypted', + }, + // Add more assertion configs as needed + ] as AssertionConfig[], + }); + + // Create AssertionVerificationKeys for verification + const assertionVerificationKeys: AssertionVerificationKeys = { + Keys: { + assertion1: { + alg: 'HS256', + key: hs256Key, + }, + assertion2: { + alg: 'RS256', + key: assertionPublicKey, + }, }, - }, - { - id: 'assertion2', - type: 'handling', - scope: 'tdo', - statement: { - format: 'json', - schema: 'https://example.com/schema', - value: '{"example": "value"}', - }, - appliesToState: 'encrypted', - signingKey: { - alg: 'RS256', - key: keyPair.privateKey, - }, - }, - { - id: 'assertion3', - type: 'handling', - scope: 'tdo', - statement: { - format: 'json', - schema: 'https://example.com/schema', - value: '{"example": "value"}', - }, - appliesToState: 'encrypted', - }, - // Add more assertion configs as needed - ] as AssertionConfig[], - }); + }; - // Create AssertionVerificationKeys for verification - const assertionVerificationKeys: AssertionVerificationKeys = { - Keys: { - assertion1: { - alg: 'HS256', - key: hs256Key, - }, - assertion2: { - alg: 'RS256', - key: publicKey, - }, - }, - }; - - const decryptStream = await client.decrypt({ - source: { - type: 'stream', - location: encryptedStream.stream, - }, - assertionVerificationKeys, - }); + const decryptStream = await client.decrypt({ + source: { + type: 'stream', + location: encryptedStream.stream, + }, + assertionVerificationKeys, + wrappingKeyAlgorithm: rewrapKeyType, + }); - const { value: decryptedText } = await decryptStream.stream.getReader().read(); - assert.equal(new TextDecoder().decode(decryptedText), expectedVal); - }); + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + }); + } + } }); diff --git a/lib/tests/mocha/unit/key-access.test.ts b/lib/tests/mocha/unit/key-access.test.ts new file mode 100644 index 00000000..97fd7a00 --- /dev/null +++ b/lib/tests/mocha/unit/key-access.test.ts @@ -0,0 +1,80 @@ +import { expect } from 'chai'; + +import { ECWrapped, Wrapped } from '../../../tdf3/src/models/key-access.js'; +import { Policy } from '../../../tdf3/src/models/policy.js'; +import { base64 } from '../../../src/encodings/index.js'; + +describe('ECWrapped', () => { + const url = 'https://example.com'; + const kid = 'test-kid'; + const publicKey = 'test-public-key'; + const metadata = { key: 'value' }; + const sid = 'test-sid'; + const policy: Policy = { uuid: 'test-policy' }; + const dek = new Uint8Array([1, 2, 3, 4, 5]); + const encryptedMetadataStr = 'encrypted-metadata'; + + ['ECWrapped', 'Wrapped'].forEach((type) => { + describe(type, () => { + it(`should write and return a KeyAccessObject for ${type}`, async () => { + const wrappedInstance = new (type === 'ECWrapped' ? ECWrapped : Wrapped)( + url, + kid, + publicKey, + metadata, + sid + ); + + const keyAccessObject = await wrappedInstance.write(policy, dek, encryptedMetadataStr); + + expect(keyAccessObject).to.have.property('type', type.toLowerCase()); + expect(keyAccessObject).to.have.property('url', url); + expect(keyAccessObject).to.have.property('protocol', 'kas'); + expect(keyAccessObject).to.have.property('wrappedKey'); + expect(keyAccessObject).to.have.property( + 'encryptedMetadata', + base64.encode(encryptedMetadataStr) + ); + expect(keyAccessObject).to.have.property('policyBinding'); + expect(keyAccessObject.policyBinding).to.have.property('alg', 'HS256'); + expect(keyAccessObject.policyBinding).to.have.property('hash'); + expect(keyAccessObject).to.have.property('schemaVersion', '1.0'); + expect(keyAccessObject).to.have.property('ephemeralPublicKey', 'ephemeral-public-key-pem'); + expect(keyAccessObject).to.have.property('kid', kid); + expect(keyAccessObject).to.have.property('sid', sid); + }); + }); + }); + + it('should initialize ECWrapped with correct properties', async () => { + const ecWrapped = new ECWrapped(url, kid, publicKey, metadata, sid); + expect(ecWrapped.type).to.equal('ec-wrapped'); + const ek = await ecWrapped.ephemeralKeyPair; + expect(ek).to.have('publicKey'); + }); + + it('should initialize Wrapped with correct properties', async () => { + const wrapped = new Wrapped(url, kid, publicKey, metadata, sid); + expect(wrapped.type).to.equal('wrapped'); + }); + + it(`should handle undefined kid for ECWrapped`, () => { + const wrappedInstance = new ECWrapped(url, undefined, publicKey, metadata); + expect(wrappedInstance.kid).to.be.undefined; + }); + + it(`should handle undefined kid for Wrapped`, () => { + const wrappedInstance = new Wrapped(url, undefined, publicKey, metadata); + expect(wrappedInstance.kid).to.be.undefined; + }); + + it(`should handle undefined sid for ECWrapped`, () => { + const wrappedInstance = new ECWrapped(url, kid, publicKey, metadata); + expect(wrappedInstance.sid).to.be.undefined; + }); + + it(`should handle undefined sid for Wrapped`, () => { + const wrappedInstance = new Wrapped(url, kid, publicKey, metadata); + expect(wrappedInstance.sid).to.be.undefined; + }); +}); diff --git a/lib/tests/mocha/unit/tdf.spec.ts b/lib/tests/mocha/unit/tdf.spec.ts index 0767f5fb..b16bbf1d 100644 --- a/lib/tests/mocha/unit/tdf.spec.ts +++ b/lib/tests/mocha/unit/tdf.spec.ts @@ -5,6 +5,7 @@ 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'; +import { getMocks } from '../../mocks/index.js'; const sampleCert = ` -----BEGIN CERTIFICATE----- @@ -42,9 +43,11 @@ HJg= -----END CERTIFICATE----- `.trim(); +const { kasECCert } = getMocks(); + describe('TDF', () => { it('should return key', async () => { - const pem = await TDF.extractPemFromKeyString(sampleCert); + const pem = await TDF.extractPemFromKeyString(sampleCert, 'rsa:2048'); expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); expect(pem).to.include('-----END PUBLIC KEY-----'); }); @@ -53,9 +56,15 @@ describe('TDF', () => { const sampleKey = sampleCert .replace('BEGIN CERTIFICATE', 'BEGIN PUBLIC KEY') .replace('END CERTIFICATE', 'END PUBLIC KEY'); - const pem = await TDF.extractPemFromKeyString(sampleKey); + const pem = await TDF.extractPemFromKeyString(sampleKey, 'rsa:2048'); expect(pem).to.equal(sampleKey); }); + + it('should return ec pem', async () => { + const pem = await TDF.extractPemFromKeyString(kasECCert, 'ec:secp256r1'); + expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); + expect(pem).to.include('-----END PUBLIC KEY-----'); + }); }); describe('fetchKasPublicKey', async () => { diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 1bdd0d96..f05f5664 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -6,7 +6,7 @@ import { decryptWithPrivateKey, encryptWithPublicKey } from '../tdf3/src/crypto/ import { getMocks } from './mocks/index.js'; import { getHkdfSalt, Header } from '../src/nanotdf/index.js'; import { keyAgreement, pemPublicToCrypto } from '../src/nanotdf-crypto/index.js'; -import generateRandomNumber from '../src/nanotdf-crypto/generateRandomNumber.js'; +import { generateRandomNumber } from '../src/nanotdf-crypto/generateRandomNumber.js'; import { removePemFormatting } from '../tdf3/src/crypto/crypto-utils.js'; import { Binary } from '../tdf3/index.js'; import { type KeyAccessObject } from '../tdf3/src/models/index.js'; @@ -147,41 +147,33 @@ const kas: RequestListener = async (req, res) => { // NOTE: Real KAS will verify JWT here const { requestBody } = jose.decodeJwt(signedRequestToken); const rewrap = JSON.parse(requestBody as string) as RewrapBody; - switch (rewrap?.algorithm) { - case 'RS256': { - // Decrypt the wrapped key from TDF3 - console.log('[INFO]: rewrap request body: ', rewrap); - const dek = await decryptWithPrivateKey( - Binary.fromArrayBuffer(base64.decodeArrayBuffer(rewrap.keyAccess.wrappedKey || '')), - Mocks.kasPrivateKey - ); - const cek = await encryptWithPublicKey(dek, rewrap.clientPublicKey); - const reply = { - entityWrappedKey: base64.encodeArrayBuffer(cek.asArrayBuffer()), - metadata: { hello: 'world' }, - }; - res.writeHead(200); - res.end(JSON.stringify(reply)); + console.log('[INFO]: rewrap request body: ', rewrap); + const clientPublicKey = await pemPublicToCrypto(rewrap.clientPublicKey); + if (!clientPublicKey || clientPublicKey.type !== 'public') { + res.writeHead(400); + res.end('{"error": "Invalid client public key"}'); + return; + } + const isZTDF = !rewrap.keyAccess.header; + if (isZTDF) { + if (!rewrap.keyAccess.wrappedKey) { + res.writeHead(400); + res.end('{"error": "Invalid wrapped key"}'); return; } - case 'ec:secp256r1': { - console.log('[INFO] nano rewrap request body: ', rewrap); - const { header } = Header.parse( - new Uint8Array(base64.decodeArrayBuffer(rewrap?.keyAccess?.header || '')) - ); - // TODO convert header.ephemeralCurveName to namedCurve - const nanoPublicKey = await crypto.subtle.importKey( - 'raw', - header.ephemeralPublicKey, - { - name: 'ECDH', - namedCurve: 'P-256', - }, - true, - [] + const wk = base64.decodeArrayBuffer(rewrap.keyAccess.wrappedKey); + const isECWrapped = rewrap.keyAccess.kid == 'e1'; + // Decrypt the wrapped key from TDF3 + let dek: Binary; + if (isECWrapped) { + if (!rewrap.keyAccess.ephemeralPublicKey) { + res.writeHead(400); + res.end('{"error": "Nil ephemeral public key"}'); + return; + } + const ephemeralKey: CryptoKey = await pemPublicToCrypto( + rewrap.keyAccess.ephemeralPublicKey ); - - const clientPublicKey = await pemPublicToCrypto(rewrap.clientPublicKey); const kasPrivateKeyBytes = base64.decodeArrayBuffer( removePemFormatting(Mocks.kasECPrivateKey) ); @@ -192,53 +184,119 @@ const kas: RequestListener = async (req, res) => { false, ['deriveBits', 'deriveKey'] ); - console.log('Imported kas private key!'); - const hkdfSalt = await getHkdfSalt(header.magicNumberVersion); - const dek = await keyAgreement(kasPrivateKey, nanoPublicKey, hkdfSalt); - const kek = await keyAgreement(kasPrivateKey, clientPublicKey, hkdfSalt); - const dekBits = await crypto.subtle.exportKey('raw', dek); - console.log( - `agreeeed! dek = [${new Uint8Array(dekBits)}], kek = [${new Uint8Array( - await crypto.subtle.exportKey('raw', kek) - )}], byteLength = [${dekBits.byteLength}]` - ); - const iv = generateRandomNumber(12); - const cek = await crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv, - tagLength: 128, - }, - kek, - dekBits - ); - const cekBytes = new Uint8Array(cek); - console.log(`responding! cek = [${cekBytes}], iv = [${iv}], tagLength = [${128}]`); - // const doublecheck = await crypto.subtle.decrypt( - // { name: 'AES-GCM', iv, tagLength: 128 }, - // kek, - // cek - // ); - // console.log(`doublecheck success! dek = [${new Uint8Array(doublecheck)}]`); - - const entityWrappedKey = new Uint8Array(iv.length + cekBytes.length); - entityWrappedKey.set(iv); - entityWrappedKey.set(cekBytes, iv.length); + const kek = await keyAgreement(kasPrivateKey, ephemeralKey, { + hkdfSalt: new TextEncoder().encode('salt'), + hkdfHash: 'SHA-256', + }); + const iv = wk.slice(0, 12); + const wrappedKey = wk.slice(12); + const dekab = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey); + dek = Binary.fromArrayBuffer(dekab); + } else { + dek = await decryptWithPrivateKey(Binary.fromArrayBuffer(wk), Mocks.kasPrivateKey); + } + if (clientPublicKey.algorithm.name == 'RSA-OAEP') { + const cek = await encryptWithPublicKey(dek, rewrap.clientPublicKey); const reply = { - entityWrappedKey: base64.encodeArrayBuffer(entityWrappedKey), - sessionPublicKey: Mocks.kasECCert, - metadata: { hello: 'people of earth' }, + entityWrappedKey: base64.encodeArrayBuffer(cek.asArrayBuffer()), + metadata: { hello: 'world' }, }; res.writeHead(200); res.end(JSON.stringify(reply)); return; } - default: - console.log(`[DEBUG] invalid rewrap algorithm [${JSON.stringify(rewrap)}]`); - res.writeHead(400); - res.end(`{"error": "Invalid algorithm [${rewrap?.algorithm}]"}`); - return; + const sessionKeyPair = await crypto.subtle.generateKey( + { + name: 'ECDH', + namedCurve: 'P-256', + }, + false, + ['deriveBits', 'deriveKey'] + ); + const kek = await keyAgreement(sessionKeyPair.privateKey, clientPublicKey, { + hkdfSalt: new TextEncoder().encode('salt'), + hkdfHash: 'SHA-256', + }); + const iv = generateRandomNumber(12); + const cek = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, dek.asArrayBuffer()); + const entityWrappedKey = new Uint8Array(iv.length + cek.byteLength); + entityWrappedKey.set(iv); + entityWrappedKey.set(new Uint8Array(cek), iv.length); + const reply = { + entityWrappedKey: base64.encodeArrayBuffer(entityWrappedKey), + metadata: { hello: 'world' }, + }; + res.writeHead(200); + res.end(JSON.stringify(reply)); + return; } + // nanotdf + console.log('[INFO] nano rewrap request body: ', rewrap); + const { header } = Header.parse( + new Uint8Array(base64.decodeArrayBuffer(rewrap?.keyAccess?.header || '')) + ); + // TODO convert header.ephemeralCurveName to namedCurve + const nanoPublicKey = await crypto.subtle.importKey( + 'raw', + header.ephemeralPublicKey, + { + name: 'ECDH', + namedCurve: 'P-256', + }, + true, + [] + ); + + const kasPrivateKeyBytes = base64.decodeArrayBuffer( + removePemFormatting(Mocks.kasECPrivateKey) + ); + const kasPrivateKey = await crypto.subtle.importKey( + 'pkcs8', + kasPrivateKeyBytes, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveBits', 'deriveKey'] + ); + console.log('Imported kas private key!'); + const hkdfSalt = await getHkdfSalt(header.magicNumberVersion); + const dek = await keyAgreement(kasPrivateKey, nanoPublicKey, hkdfSalt); + const kek = await keyAgreement(kasPrivateKey, clientPublicKey, hkdfSalt); + const dekBits = await crypto.subtle.exportKey('raw', dek); + console.log( + `agreeeed! dek = [${new Uint8Array(dekBits)}], kek = [${new Uint8Array( + await crypto.subtle.exportKey('raw', kek) + )}], byteLength = [${dekBits.byteLength}]` + ); + const iv = generateRandomNumber(12); + const cek = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + tagLength: 128, + }, + kek, + dekBits + ); + const cekBytes = new Uint8Array(cek); + console.log(`responding! cek = [${cekBytes}], iv = [${iv}], tagLength = [${128}]`); + // const doublecheck = await crypto.subtle.decrypt( + // { name: 'AES-GCM', iv, tagLength: 128 }, + // kek, + // cek + // ); + // console.log(`doublecheck success! dek = [${new Uint8Array(doublecheck)}]`); + + const entityWrappedKey = new Uint8Array(iv.length + cekBytes.length); + entityWrappedKey.set(iv); + entityWrappedKey.set(cekBytes, iv.length); + const reply = { + entityWrappedKey: base64.encodeArrayBuffer(entityWrappedKey), + sessionPublicKey: Mocks.kasECCert, + metadata: { hello: 'people of earth' }, + }; + res.writeHead(200); + res.end(JSON.stringify(reply)); + return; } else if (url.pathname === '/file') { if (req.method !== 'GET') { res.writeHead(405);