From d13b13a8d3419c55802114bb9b22014a920a5183 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 20 Mar 2026 10:20:50 -0400 Subject: [PATCH 1/3] break up the crypto service into smaller files --- lib/tdf3/src/crypto/core/ec.ts | 108 ++ lib/tdf3/src/crypto/core/key-format.ts | 384 +++++++ lib/tdf3/src/crypto/core/keys.ts | 86 ++ lib/tdf3/src/crypto/core/rsa.ts | 132 +++ lib/tdf3/src/crypto/core/signing.ts | 187 ++++ lib/tdf3/src/crypto/core/symmetric.ts | 250 +++++ lib/tdf3/src/crypto/index.ts | 1320 ++---------------------- 7 files changed, 1214 insertions(+), 1253 deletions(-) create mode 100644 lib/tdf3/src/crypto/core/ec.ts create mode 100644 lib/tdf3/src/crypto/core/key-format.ts create mode 100644 lib/tdf3/src/crypto/core/keys.ts create mode 100644 lib/tdf3/src/crypto/core/rsa.ts create mode 100644 lib/tdf3/src/crypto/core/signing.ts create mode 100644 lib/tdf3/src/crypto/core/symmetric.ts diff --git a/lib/tdf3/src/crypto/core/ec.ts b/lib/tdf3/src/crypto/core/ec.ts new file mode 100644 index 000000000..274bd4441 --- /dev/null +++ b/lib/tdf3/src/crypto/core/ec.ts @@ -0,0 +1,108 @@ +import { + type ECCurve, + type HkdfParams, + type KeyAlgorithm, + type KeyPair, + type PrivateKey, + type PublicKey, + type SymmetricKey, +} from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; +import { unwrapKey, wrapPrivateKey, wrapPublicKey, wrapSymmetricKey } from './keys.js'; + +/** + * Map ECCurve to Web Crypto named curve. + */ +function curveToNamedCurve(curve: ECCurve): string { + switch (curve) { + case 'P-256': + return 'P-256'; + case 'P-384': + return 'P-384'; + case 'P-521': + return 'P-521'; + default: + throw new ConfigurationError(`Unsupported curve: ${curve}`); + } +} + +/** + * Generate an EC key pair for ECDH key agreement. + */ +export async function generateECKeyPair(curve: ECCurve = 'P-256'): Promise { + const namedCurve = curveToNamedCurve(curve); + + const keyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve }, true, [ + 'deriveBits', + ]); + + let algorithm: KeyAlgorithm; + switch (namedCurve) { + case 'P-256': + algorithm = 'ec:secp256r1'; + break; + case 'P-384': + algorithm = 'ec:secp384r1'; + break; + case 'P-521': + algorithm = 'ec:secp521r1'; + break; + default: + throw new ConfigurationError(`Unsupported curve: ${namedCurve}`); + } + + return { + publicKey: wrapPublicKey(keyPair.publicKey, algorithm), + privateKey: wrapPrivateKey(keyPair.privateKey, algorithm), + }; +} + +/** + * Perform ECDH key agreement followed by HKDF key derivation. + * Returns opaque symmetric key for symmetric encryption. + */ +export async function deriveKeyFromECDH( + privateKey: PrivateKey, + publicKey: PublicKey, + hkdfParams: HkdfParams +): Promise { + const privateKeyCrypto = unwrapKey(privateKey); + const publicKeyCrypto = unwrapKey(publicKey); + + const curve = publicKey.curve; + if (!curve) { + throw new ConfigurationError('EC curve not found on public key'); + } + + const curveBits: Record = { + 'P-256': 256, + 'P-384': 384, + 'P-521': 528, + }; + const bits = curveBits[curve]; + + const sharedSecret = await crypto.subtle.deriveBits( + { name: 'ECDH', public: publicKeyCrypto }, + privateKeyCrypto, + bits + ); + + const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']); + + const keyLength = hkdfParams.keyLength ?? 256; + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: hkdfParams.hash, + salt: hkdfParams.salt, + info: hkdfParams.info ?? new Uint8Array(0), + }, + hkdfKey, + { name: 'AES-GCM', length: keyLength }, + true, + ['encrypt', 'decrypt'] + ); + + const keyBytes = await crypto.subtle.exportKey('raw', derivedKey); + return wrapSymmetricKey(new Uint8Array(keyBytes)); +} diff --git a/lib/tdf3/src/crypto/core/key-format.ts b/lib/tdf3/src/crypto/core/key-format.ts new file mode 100644 index 000000000..87f961678 --- /dev/null +++ b/lib/tdf3/src/crypto/core/key-format.ts @@ -0,0 +1,384 @@ +import { + type KeyAlgorithm, + type KeyOptions, + MIN_ASYMMETRIC_KEY_SIZE_BITS, + type PrivateKey, + type PublicKey, + type PublicKeyInfo, +} from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; +import { formatAsPem, removePemFormatting } from '../crypto-utils.js'; +import { encodeArrayBuffer as hexEncode } from '../../../../src/encodings/hex.js'; +import { decodeArrayBuffer as base64Decode } from '../../../../src/encodings/base64.js'; +import { exportSPKI, importX509 } from 'jose'; +import { + guessAlgorithmName, + guessCurveName, + toJwsAlg, +} from '../../../../src/crypto/pemPublicToCrypto.js'; +import { unwrapKey, wrapPrivateKey, wrapPublicKey } from './keys.js'; +import { rsaOaepSha1 } from './rsa.js'; + +/** + * Extract PEM public key from X.509 certificate or return PEM key as-is. + */ +export async function extractPublicKeyPem( + certOrPem: string, + jwaAlgorithm?: string +): Promise { + if (certOrPem.includes('-----BEGIN CERTIFICATE-----')) { + let alg = jwaAlgorithm; + if (!alg) { + const certBody = certOrPem.replace(/-----(BEGIN|END) CERTIFICATE-----|\s/g, ''); + const certBytes = base64Decode(certBody); + const hex = hexEncode(certBytes); + alg = toJwsAlg(hex); + } + const cert = await importX509(certOrPem, alg, { extractable: true }); + return exportSPKI(cert); + } + + if (certOrPem.includes('-----BEGIN PUBLIC KEY-----')) { + return certOrPem; + } + + throw new ConfigurationError('Input must be a PEM-encoded certificate or public key'); +} + +const SUPPORTED_EC_CURVES = ['P-256', 'P-384', 'P-521'] as const; +type SupportedEcCurve = (typeof SUPPORTED_EC_CURVES)[number]; + +/** + * Decode base64url string and return byte length. + */ +function base64urlByteLength(base64url: string): number { + const padding = (4 - (base64url.length % 4)) % 4; + const padded = base64url + '='.repeat(padding); + return base64Decode(padded).byteLength; +} + +/** + * Extract EC curve from a public key by parsing ASN.1 OIDs. + */ +function extractEcCurveFromPublicKey(keyData: ArrayBuffer): SupportedEcCurve { + const hexKey = hexEncode(keyData); + const curveName = guessCurveName(hexKey); + return curveName as SupportedEcCurve; +} + +/** + * Extract RSA modulus bit length by importing key and exporting as JWK. + */ +async function extractRsaModulusBitLength(keyData: ArrayBuffer): Promise { + const key = await crypto.subtle.importKey( + 'spki', + keyData, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['encrypt'] + ); + const jwk = await crypto.subtle.exportKey('jwk', key); + if (!jwk.n) { + throw new ConfigurationError('Invalid RSA key: missing modulus'); + } + return base64urlByteLength(jwk.n) * 8; +} + +/** + * Import and validate a PEM public key, returning algorithm info. + */ +export async function parsePublicKeyPem(pem: string): Promise { + let publicKeyPem = pem; + if (pem.includes('-----BEGIN CERTIFICATE-----')) { + publicKeyPem = await extractPublicKeyPem(pem); + } + + if (!publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')) { + throw new ConfigurationError('Input must be a PEM-encoded public key or certificate'); + } + + const keyData = base64Decode(removePemFormatting(publicKeyPem)); + + try { + const modulusBits = await extractRsaModulusBitLength(keyData); + let algorithm: PublicKeyInfo['algorithm']; + if (modulusBits < MIN_ASYMMETRIC_KEY_SIZE_BITS) { + throw new ConfigurationError( + `RSA key size ${modulusBits} bits is below the minimum of ${MIN_ASYMMETRIC_KEY_SIZE_BITS} bits` + ); + } else if (modulusBits <= 2048) { + algorithm = 'rsa:2048'; + } else if (modulusBits <= 4096) { + algorithm = 'rsa:4096'; + } else { + throw new ConfigurationError(`Unsupported RSA key size: ${modulusBits} bits`); + } + return { algorithm, pem: publicKeyPem }; + } catch (error) { + if (error instanceof ConfigurationError) { + throw error; + } + } + + try { + const detectedCurve = extractEcCurveFromPublicKey(keyData); + const curveMap = { + 'P-256': 'ec:secp256r1', + 'P-384': 'ec:secp384r1', + 'P-521': 'ec:secp521r1', + } as const; + return { algorithm: curveMap[detectedCurve], pem: publicKeyPem }; + } catch { + // Not a valid EC key + } + + throw new ConfigurationError('Unable to determine public key algorithm - unsupported key type'); +} + +/** + * Convert a JWK (JSON Web Key) to PEM format. + */ +export async function jwkToPublicKeyPem(jwk: JsonWebKey): Promise { + let key: CryptoKey; + + if (jwk.kty === 'RSA') { + key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, true, [ + 'encrypt', + ]); + } else if (jwk.kty === 'EC') { + const crv = jwk.crv; + if (!crv || !['P-256', 'P-384', 'P-521'].includes(crv)) { + throw new ConfigurationError(`Unsupported EC curve: ${crv}`); + } + key = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDH', namedCurve: crv }, true, []); + } else { + throw new ConfigurationError(`Unsupported JWK key type: ${jwk.kty}`); + } + + const spkiBuffer = await crypto.subtle.exportKey('spki', key); + return formatAsPem(spkiBuffer, 'PUBLIC KEY'); +} + +/** + * Convert a PEM public key to JWK format. + * Returns only public key components (no private key data). + */ +export async function publicKeyPemToJwk(publicKeyPem: string): Promise { + const keyDataBase64 = removePemFormatting(publicKeyPem); + const keyBuffer = base64Decode(keyDataBase64); + const hex = hexEncode(keyBuffer); + + const algorithmName = guessAlgorithmName(hex); + + if (algorithmName === 'ECDH' || algorithmName === 'ECDSA') { + const namedCurve = guessCurveName(hex); + const key = await crypto.subtle.importKey( + 'spki', + keyBuffer, + { name: 'ECDSA', namedCurve }, + true, + ['verify'] + ); + const jwk = await crypto.subtle.exportKey('jwk', key); + const { kty, crv, x, y } = jwk; + return { kty, crv, x, y }; + } + + const key = await crypto.subtle.importKey( + 'spki', + keyBuffer, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + true, + ['verify'] + ); + const jwk = await crypto.subtle.exportKey('jwk', key); + const { kty, e, n } = jwk; + return { kty, e, n }; +} + +/** + * Import a PEM public key as an opaque key. + */ +export async function importPublicKey(pem: string, options: KeyOptions): Promise { + const { usage = 'encrypt', extractable = true, algorithmHint } = options; + + const keyInfo = await parsePublicKeyPem(pem); + const algorithm = algorithmHint || keyInfo.algorithm; + const keyData = removePemFormatting(keyInfo.pem); + const keyBuffer = base64Decode(keyData); + + let cryptoAlgorithm: RsaHashedImportParams | EcKeyImportParams; + let keyUsages: KeyUsage[]; + + if (algorithm.startsWith('rsa:')) { + if (usage === 'encrypt') { + cryptoAlgorithm = rsaOaepSha1(); + keyUsages = ['encrypt']; + } else if (usage === 'sign') { + cryptoAlgorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; + keyUsages = ['verify']; + } else { + throw new ConfigurationError('RSA keys only support usage: encrypt or sign'); + } + } else if (algorithm.startsWith('ec:')) { + const curve = algorithm.split(':')[1]; + const namedCurve = + curve === 'secp256r1' + ? 'P-256' + : curve === 'secp384r1' + ? 'P-384' + : curve === 'secp521r1' + ? 'P-521' + : (() => { + throw new ConfigurationError(`Unsupported EC curve: ${curve}`); + })(); + + if (usage === 'derive') { + cryptoAlgorithm = { name: 'ECDH', namedCurve }; + keyUsages = []; + } else if (usage === 'sign') { + cryptoAlgorithm = { name: 'ECDSA', namedCurve }; + keyUsages = ['verify']; + } else { + throw new ConfigurationError('EC keys only support usage: derive or sign'); + } + } else { + throw new ConfigurationError(`Unsupported algorithm: ${algorithm}`); + } + + const cryptoKey = await crypto.subtle.importKey( + 'spki', + keyBuffer, + cryptoAlgorithm, + extractable, + keyUsages + ); + + return wrapPublicKey(cryptoKey, algorithm); +} + +/** + * Import a PEM private key as an opaque key. + */ +export async function importPrivateKey(pem: string, options: KeyOptions): Promise { + const { usage = 'encrypt', extractable = true, algorithmHint } = options; + + let algorithm: KeyAlgorithm; + + const keyData = removePemFormatting(pem); + const keyBuffer = base64Decode(keyData); + + if (algorithmHint) { + algorithm = algorithmHint; + } else { + const hex = hexEncode(keyBuffer); + const algorithmName = guessAlgorithmName(hex); + if (algorithmName === 'ECDH' || algorithmName === 'ECDSA') { + const namedCurve = guessCurveName(hex); + const curveMap: Record = { + 'P-256': 'ec:secp256r1', + 'P-384': 'ec:secp384r1', + 'P-521': 'ec:secp521r1', + }; + const mapped = curveMap[namedCurve]; + if (!mapped) { + throw new ConfigurationError(`Unsupported EC curve in private key: ${namedCurve}`); + } + algorithm = mapped; + } else { + const tempKey = await crypto.subtle.importKey( + 'pkcs8', + keyBuffer, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + true, + ['sign'] + ); + const jwk = await crypto.subtle.exportKey('jwk', tempKey); + if (!jwk.n) { + throw new ConfigurationError('Invalid RSA private key: missing modulus'); + } + const modulusBits = base64urlByteLength(jwk.n) * 8; + if (modulusBits < MIN_ASYMMETRIC_KEY_SIZE_BITS) { + throw new ConfigurationError( + `RSA key size ${modulusBits} bits is below the minimum of ${MIN_ASYMMETRIC_KEY_SIZE_BITS} bits` + ); + } + algorithm = modulusBits <= 2048 ? 'rsa:2048' : 'rsa:4096'; + } + } + + let cryptoAlgorithm: RsaHashedImportParams | EcKeyImportParams; + let keyUsages: KeyUsage[]; + + if (algorithm.startsWith('rsa:')) { + if (usage === 'encrypt') { + cryptoAlgorithm = rsaOaepSha1(); + keyUsages = ['decrypt']; + } else if (usage === 'sign') { + cryptoAlgorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; + keyUsages = ['sign']; + } else { + throw new ConfigurationError('RSA keys only support usage: encrypt or sign'); + } + } else if (algorithm.startsWith('ec:')) { + const curve = algorithm.split(':')[1]; + const namedCurve = + curve === 'secp256r1' + ? 'P-256' + : curve === 'secp384r1' + ? 'P-384' + : curve === 'secp521r1' + ? 'P-521' + : (() => { + throw new ConfigurationError(`Unsupported EC curve: ${curve}`); + })(); + + if (usage === 'derive') { + cryptoAlgorithm = { name: 'ECDH', namedCurve }; + keyUsages = ['deriveBits']; + } else if (usage === 'sign') { + cryptoAlgorithm = { name: 'ECDSA', namedCurve }; + keyUsages = ['sign']; + } else { + throw new ConfigurationError('EC keys only support usage: derive or sign'); + } + } else { + throw new ConfigurationError(`Unsupported algorithm: ${algorithm}`); + } + + const cryptoKey = await crypto.subtle.importKey( + 'pkcs8', + keyBuffer, + cryptoAlgorithm, + extractable, + keyUsages + ); + + return wrapPrivateKey(cryptoKey, algorithm); +} + +/** + * Export an opaque public key to PEM format. + */ +export async function exportPublicKeyPem(key: PublicKey): Promise { + const cryptoKey = unwrapKey(key); + const keyBuffer = await crypto.subtle.exportKey('spki', cryptoKey); + return formatAsPem(keyBuffer, 'PUBLIC KEY'); +} + +/** + * Export an opaque private key to PEM format. + */ +export async function exportPrivateKeyPem(key: PrivateKey): Promise { + const cryptoKey = unwrapKey(key); + const keyBuffer = await crypto.subtle.exportKey('pkcs8', cryptoKey); + return formatAsPem(keyBuffer, 'PRIVATE KEY'); +} + +/** + * Export an opaque public key to JWK format. + */ +export async function exportPublicKeyJwk(key: PublicKey): Promise { + const cryptoKey = unwrapKey(key); + return await crypto.subtle.exportKey('jwk', cryptoKey); +} diff --git a/lib/tdf3/src/crypto/core/keys.ts b/lib/tdf3/src/crypto/core/keys.ts new file mode 100644 index 000000000..89b320cbb --- /dev/null +++ b/lib/tdf3/src/crypto/core/keys.ts @@ -0,0 +1,86 @@ +import { + type KeyAlgorithm, + type PrivateKey, + type PublicKey, + type SymmetricKey, +} from '../declarations.js'; + +/** + * Wrap a CryptoKey as an opaque PublicKey. + * @internal + */ +export function wrapPublicKey(key: CryptoKey, algorithm: KeyAlgorithm): PublicKey { + const result: any = { + _brand: 'PublicKey', + algorithm, + _internal: key, + }; + if (algorithm.startsWith('rsa:')) { + result.modulusBits = parseInt(algorithm.split(':')[1], 10); + } else if (algorithm.startsWith('ec:')) { + const curvePart = algorithm.split(':')[1]; + result.curve = + curvePart === 'secp256r1' + ? 'P-256' + : curvePart === 'secp384r1' + ? 'P-384' + : curvePart === 'secp521r1' + ? 'P-521' + : undefined; + } + return result as PublicKey; +} + +/** + * Wrap a CryptoKey as an opaque PrivateKey. + * @internal + */ +export function wrapPrivateKey(key: CryptoKey, algorithm: KeyAlgorithm): PrivateKey { + const result: any = { + _brand: 'PrivateKey', + algorithm, + _internal: key, + }; + if (algorithm.startsWith('rsa:')) { + result.modulusBits = parseInt(algorithm.split(':')[1], 10); + } else if (algorithm.startsWith('ec:')) { + const curvePart = algorithm.split(':')[1]; + result.curve = + curvePart === 'secp256r1' + ? 'P-256' + : curvePart === 'secp384r1' + ? 'P-384' + : curvePart === 'secp521r1' + ? 'P-521' + : undefined; + } + return result as PrivateKey; +} + +/** + * Unwrap an opaque key to get the internal CryptoKey. + * @internal + */ +export function unwrapKey(key: PublicKey | PrivateKey): CryptoKey { + return (key as any)._internal; +} + +/** + * Wrap raw key bytes as an opaque SymmetricKey. + * @internal + */ +export function wrapSymmetricKey(keyBytes: Uint8Array): SymmetricKey { + return { + _brand: 'SymmetricKey', + length: keyBytes.length * 8, + _internal: keyBytes, + } as SymmetricKey; +} + +/** + * Unwrap an opaque SymmetricKey to get raw bytes. + * @internal + */ +export function unwrapSymmetricKey(key: SymmetricKey): Uint8Array { + return (key as any)._internal; +} diff --git a/lib/tdf3/src/crypto/core/rsa.ts b/lib/tdf3/src/crypto/core/rsa.ts new file mode 100644 index 000000000..dac09910f --- /dev/null +++ b/lib/tdf3/src/crypto/core/rsa.ts @@ -0,0 +1,132 @@ +import { Binary } from '../../binary.js'; +import { + type KeyAlgorithm, + type KeyPair, + MIN_ASYMMETRIC_KEY_SIZE_BITS, + type PrivateKey, + type PublicKey, + type SymmetricKey, +} from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; +import { unwrapKey, unwrapSymmetricKey, wrapPrivateKey, wrapPublicKey } from './keys.js'; + +const ENC_DEC_METHODS: KeyUsage[] = ['encrypt', 'decrypt']; +const SIGN_VERIFY_METHODS: KeyUsage[] = ['sign', 'verify']; + +/** + * Get a DOMString representing the algorithm to use for an + * asymmetric key generation. + */ +export function rsaOaepSha1( + modulusLength: number = MIN_ASYMMETRIC_KEY_SIZE_BITS +): RsaHashedKeyGenParams { + if (!modulusLength || modulusLength < MIN_ASYMMETRIC_KEY_SIZE_BITS) { + throw new ConfigurationError('Invalid key size requested'); + } + return { + name: 'RSA-OAEP', + hash: { + name: 'SHA-1', + }, + modulusLength, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + }; +} + +export function rsaPkcs1Sha256( + modulusLength: number = MIN_ASYMMETRIC_KEY_SIZE_BITS +): RsaHashedKeyGenParams { + if (!modulusLength || modulusLength < MIN_ASYMMETRIC_KEY_SIZE_BITS) { + throw new ConfigurationError('Invalid key size requested'); + } + return { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256', + }, + modulusLength, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + }; +} + +/** + * Generate an RSA key pair + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey} + * @param size in bits + */ +export async function generateKeyPair(size?: number): Promise { + const keySize = size || MIN_ASYMMETRIC_KEY_SIZE_BITS; + const algoDomString = rsaOaepSha1(keySize); + const keyPair = await crypto.subtle.generateKey(algoDomString, true, ENC_DEC_METHODS); + + let algorithm: KeyAlgorithm; + if (keySize === 2048) { + algorithm = 'rsa:2048'; + } else if (keySize === 4096) { + algorithm = 'rsa:4096'; + } else { + throw new ConfigurationError( + `Unsupported RSA key size: ${keySize}. Only 2048 and 4096 are supported.` + ); + } + + return { + publicKey: wrapPublicKey(keyPair.publicKey, algorithm), + privateKey: wrapPrivateKey(keyPair.privateKey, algorithm), + }; +} + +/** + * Generate an RSA key pair suitable for signatures + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey} + */ +export async function generateSigningKeyPair(): Promise { + const rsaParams = rsaPkcs1Sha256(2048); + const keyPair = await crypto.subtle.generateKey(rsaParams, true, SIGN_VERIFY_METHODS); + + const algorithm: KeyAlgorithm = 'rsa:2048'; + return { + publicKey: wrapPublicKey(keyPair.publicKey, algorithm), + privateKey: wrapPrivateKey(keyPair.privateKey, algorithm), + }; +} + +/** + * Encrypt using a public key (RSA-OAEP). + * Accepts Binary or SymmetricKey for key wrapping. + */ +export async function encryptWithPublicKey( + payload: Binary | SymmetricKey, + publicKey: PublicKey +): Promise { + let payloadBuffer: BufferSource; + + if ('_brand' in payload && payload._brand === 'SymmetricKey') { + payloadBuffer = unwrapSymmetricKey(payload); + } else { + payloadBuffer = (payload as Binary).asArrayBuffer(); + } + + const cryptoKey = unwrapKey(publicKey); + const result = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, cryptoKey, payloadBuffer); + return Binary.fromArrayBuffer(result); +} + +/** + * Decrypt a public-key encrypted payload with a private key + */ +export async function decryptWithPrivateKey( + encryptedPayload: Binary, + privateKey: PrivateKey +): Promise { + console.assert(typeof encryptedPayload === 'object', 'encryptedPayload must be object'); + + const cryptoKey = unwrapKey(privateKey); + const payload = await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + cryptoKey, + encryptedPayload.asArrayBuffer() + ); + const bufferView = new Uint8Array(payload); + return Binary.fromArrayBuffer(bufferView.buffer); +} diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts new file mode 100644 index 000000000..e619c169e --- /dev/null +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -0,0 +1,187 @@ +import { + type AsymmetricSigningAlgorithm, + type PrivateKey, + type PublicKey, +} from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; +import { unwrapKey } from './keys.js'; + +/** + * Get the Web Crypto algorithm parameters for a signing algorithm. + */ +function getSigningAlgorithmParams(algorithm: AsymmetricSigningAlgorithm): { + importParams: RsaHashedImportParams | EcKeyImportParams; + signParams: AlgorithmIdentifier | EcdsaParams; +} { + switch (algorithm) { + case 'RS256': + return { + importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + signParams: 'RSASSA-PKCS1-v1_5', + }; + case 'ES256': + return { + importParams: { name: 'ECDSA', namedCurve: 'P-256' }, + signParams: { name: 'ECDSA', hash: 'SHA-256' } as EcdsaParams, + }; + case 'ES384': + return { + importParams: { name: 'ECDSA', namedCurve: 'P-384' }, + signParams: { name: 'ECDSA', hash: 'SHA-384' } as EcdsaParams, + }; + case 'ES512': + return { + importParams: { name: 'ECDSA', namedCurve: 'P-521' }, + signParams: { name: 'ECDSA', hash: 'SHA-512' } as EcdsaParams, + }; + default: + throw new ConfigurationError(`Unsupported signing algorithm: ${algorithm}`); + } +} + +/** + * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format (used by JWT). + * RS256 signatures don't need conversion. + */ +function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { + if (algorithm === 'RS256') { + return signature; + } + + const halfLen = signature.length / 2; + const r = signature.slice(0, halfLen); + const s = signature.slice(halfLen); + + const trimLeadingZeros = (arr: Uint8Array): Uint8Array => { + let index = 0; + while (index < arr.length - 1 && arr[index] === 0) index++; + return arr.slice(index); + }; + + let rTrimmed = trimLeadingZeros(r); + let sTrimmed = trimLeadingZeros(s); + + if (rTrimmed[0] & 0x80) { + const padded = new Uint8Array(rTrimmed.length + 1); + padded.set(rTrimmed, 1); + rTrimmed = padded; + } + if (sTrimmed[0] & 0x80) { + const padded = new Uint8Array(sTrimmed.length + 1); + padded.set(sTrimmed, 1); + sTrimmed = padded; + } + + const rDer = new Uint8Array([0x02, rTrimmed.length, ...rTrimmed]); + const sDer = new Uint8Array([0x02, sTrimmed.length, ...sTrimmed]); + + const seqLen = rDer.length + sDer.length; + const lenBytes = seqLen < 128 ? new Uint8Array([seqLen]) : new Uint8Array([0x81, seqLen]); + const result = new Uint8Array(1 + lenBytes.length + seqLen); + result[0] = 0x30; + result.set(lenBytes, 1); + result.set(rDer, 1 + lenBytes.length); + result.set(sDer, 1 + lenBytes.length + rDer.length); + + return result; +} + +/** + * Convert DER signature format (used by JWT) to IEEE P1363 format (used by WebCrypto ECDSA). + * RS256 signatures don't need conversion. + */ +function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { + if (algorithm === 'RS256') { + return signature; + } + + let componentLen: number; + switch (algorithm) { + case 'ES256': + componentLen = 32; + break; + case 'ES384': + componentLen = 48; + break; + case 'ES512': + componentLen = 66; + break; + default: + throw new ConfigurationError(`Unsupported algorithm for DER conversion: ${algorithm}`); + } + + if (signature[0] !== 0x30) { + throw new ConfigurationError('Invalid DER signature: expected SEQUENCE'); + } + + let offset = 1; + if (signature[offset] & 0x80) { + const lenBytesCount = signature[offset] & 0x7f; + if (lenBytesCount === 0 || lenBytesCount > 4) { + throw new ConfigurationError('Invalid DER signature: invalid long-form length'); + } + offset += 1 + lenBytesCount; + if (offset > signature.length) { + throw new ConfigurationError('Invalid DER signature: length bytes exceed signature length'); + } + } else { + offset += 1; + } + + if (signature[offset] !== 0x02) { + throw new ConfigurationError('Invalid DER signature: expected INTEGER for r'); + } + const rLen = signature[offset + 1]; + offset += 2; + let r = signature.slice(offset, offset + rLen); + offset += rLen; + + if (signature[offset] !== 0x02) { + throw new ConfigurationError('Invalid DER signature: expected INTEGER for s'); + } + const sLen = signature[offset + 1]; + offset += 2; + let s = signature.slice(offset, offset + sLen); + + if (r[0] === 0 && r.length > componentLen) { + r = r.slice(1); + } + if (s[0] === 0 && s.length > componentLen) { + s = s.slice(1); + } + + const result = new Uint8Array(componentLen * 2); + result.set(r, componentLen - r.length); + result.set(s, componentLen * 2 - s.length); + + return result; +} + +/** + * Sign data with an asymmetric private key. + */ +export async function sign( + data: Uint8Array, + privateKey: PrivateKey, + algorithm: AsymmetricSigningAlgorithm +): Promise { + const { signParams } = getSigningAlgorithmParams(algorithm); + const key = unwrapKey(privateKey); + const signature = await crypto.subtle.sign(signParams, key, data); + return ieeeP1363ToDer(new Uint8Array(signature), algorithm); +} + +/** + * Verify signature with an asymmetric public key. + */ +export async function verify( + data: Uint8Array, + signature: Uint8Array, + publicKey: PublicKey, + algorithm: AsymmetricSigningAlgorithm +): Promise { + const { signParams } = getSigningAlgorithmParams(algorithm); + const key = unwrapKey(publicKey); + const ieeeSignature = derToIeeeP1363(signature, algorithm); + return crypto.subtle.verify(signParams, key, ieeeSignature, data); +} diff --git a/lib/tdf3/src/crypto/core/symmetric.ts b/lib/tdf3/src/crypto/core/symmetric.ts new file mode 100644 index 000000000..013ac8500 --- /dev/null +++ b/lib/tdf3/src/crypto/core/symmetric.ts @@ -0,0 +1,250 @@ +import { Algorithms, type AlgorithmUrn } from '../../ciphers/algorithms.js'; +import { Binary } from '../../binary.js'; +import { + type CryptoService, + type DecryptResult, + type EncryptResult, + type HashAlgorithm, + type SymmetricKey, +} from '../declarations.js'; +import { ConfigurationError, DecryptError } from '../../../../src/errors.js'; +import { encodeArrayBuffer as hexEncode } from '../../../../src/encodings/hex.js'; +import { keyMerge, keySplit } from '../../utils/keysplit.js'; +import { unwrapSymmetricKey, wrapSymmetricKey } from './keys.js'; + +const ENC_DEC_METHODS: KeyUsage[] = ['encrypt', 'decrypt']; + +/** + * Generate a random symmetric key (opaque). + * @param length - Key length in bytes (default 32 for AES-256) + * @return Opaque symmetric key + */ +export async function generateKey(length?: number): Promise { + const keyBytes = await randomBytes(length || 32); + return wrapSymmetricKey(keyBytes); +} + +export async function randomBytes(byteLength: number): Promise { + const randomValues = new Uint8Array(byteLength); + crypto.getRandomValues(randomValues); + return randomValues; +} + +/** + * Returns a promise to the encryption key as a binary string. + */ +export async function randomBytesAsHex(length: number): Promise { + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + return hexEncode(randomValues.buffer); +} + +/** + * Decrypt content synchronously + */ +export function decrypt( + payload: Binary, + key: SymmetricKey, + iv: Binary, + algorithm?: AlgorithmUrn, + authTag?: Binary +): Promise { + return _doDecrypt(payload, key, iv, algorithm, authTag); +} + +/** + * Encrypt content synchronously + */ +export function encrypt( + payload: Binary | SymmetricKey, + key: SymmetricKey, + iv: Binary, + algorithm?: AlgorithmUrn +): Promise { + return _doEncrypt(payload, key, iv, algorithm); +} + +async function _doEncrypt( + payload: Binary | SymmetricKey, + key: SymmetricKey, + iv: Binary, + algorithm?: AlgorithmUrn +): Promise { + console.assert(payload != null); + console.assert(key != null); + console.assert(iv != null); + + let payloadBuffer: BufferSource; + if ('_brand' in payload && payload._brand === 'SymmetricKey') { + payloadBuffer = unwrapSymmetricKey(payload); + } else { + payloadBuffer = (payload as Binary).asArrayBuffer(); + } + + const algoDomString = getSymmetricAlgoDomString(iv, algorithm); + const keyBytes = unwrapSymmetricKey(key); + const importedKey = await _importKey(keyBytes, algoDomString); + const encrypted = await crypto.subtle.encrypt(algoDomString, importedKey, payloadBuffer); + if (algoDomString.name === 'AES-GCM') { + return { + payload: Binary.fromArrayBuffer(encrypted.slice(0, -16)), + authTag: Binary.fromArrayBuffer(encrypted.slice(-16)), + }; + } + return { + payload: Binary.fromArrayBuffer(encrypted), + }; +} + +async function _doDecrypt( + payload: Binary, + key: SymmetricKey, + iv: Binary, + algorithm?: AlgorithmUrn, + authTag?: Binary +): Promise { + console.assert(payload != null); + console.assert(key != null); + console.assert(iv != null); + + let payloadBuffer = payload.asArrayBuffer(); + + if (authTag) { + const authTagBuffer = authTag.asArrayBuffer(); + const gcmPayload = new Uint8Array(payloadBuffer.byteLength + authTagBuffer.byteLength); + gcmPayload.set(new Uint8Array(payloadBuffer), 0); + gcmPayload.set(new Uint8Array(authTagBuffer), payloadBuffer.byteLength); + payloadBuffer = gcmPayload.buffer; + } + + const algoDomString = getSymmetricAlgoDomString(iv, algorithm); + const keyBytes = unwrapSymmetricKey(key); + const importedKey = await _importKey(keyBytes, algoDomString); + algoDomString.iv = iv.asArrayBuffer(); + + const decrypted = await crypto.subtle + .decrypt(algoDomString, importedKey, payloadBuffer) + .catch((err) => { + if (err.name === 'OperationError') { + throw new DecryptError(err); + } + + throw err; + }); + return { payload: Binary.fromArrayBuffer(decrypted) }; +} + +function _importKey(keyBytes: Uint8Array, algorithm: AesCbcParams | AesGcmParams) { + return crypto.subtle.importKey('raw', keyBytes, algorithm, true, ENC_DEC_METHODS); +} + +/** + * Get a DOMString representing the algorithm to use for a crypto + * operation. Defaults to AES-CBC. + */ +function getSymmetricAlgoDomString( + iv: Binary, + algorithm?: AlgorithmUrn +): AesCbcParams | AesGcmParams { + let nativeAlgorithm = 'AES-CBC'; + if (algorithm === Algorithms.AES_256_GCM) { + nativeAlgorithm = 'AES-GCM'; + } + + return { + name: nativeAlgorithm, + iv: iv.asArrayBuffer(), + }; +} + +/** + * Create an ArrayBuffer from a hex string. + */ +export function hex2Ab(hex: string): ArrayBuffer { + const buffer = new ArrayBuffer(hex.length / 2); + const bufferView = new Uint8Array(buffer); + + for (let index = 0; index < hex.length; index += 2) { + bufferView[index / 2] = parseInt(hex.substr(index, 2), 16); + } + + return buffer; +} + +/** + * Compute hash digest. + */ +export async function digest(algorithm: HashAlgorithm, data: Uint8Array): Promise { + const validAlgorithms: HashAlgorithm[] = ['SHA-256', 'SHA-384', 'SHA-512']; + if (!validAlgorithms.includes(algorithm)) { + throw new ConfigurationError(`Unsupported hash algorithm: ${algorithm}`); + } + + const hashBuffer = await crypto.subtle.digest(algorithm, data); + return new Uint8Array(hashBuffer); +} + +/** + * Compute HMAC-SHA256 of data with a symmetric key. + */ +export async function hmac(data: Uint8Array, key: SymmetricKey): Promise { + const keyBytes = unwrapSymmetricKey(key); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', cryptoKey, data); + return new Uint8Array(signature); +} + +/** + * Verify HMAC-SHA256. + */ +export async function verifyHmac( + data: Uint8Array, + signature: Uint8Array, + key: SymmetricKey +): Promise { + const keyBytes = unwrapSymmetricKey(key); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + return crypto.subtle.verify('HMAC', cryptoKey, signature, data); +} + +/** + * Import raw key bytes as an opaque symmetric key. + */ +export async function importSymmetricKey(keyBytes: Uint8Array): Promise { + return wrapSymmetricKey(keyBytes); +} + +/** + * Split a symmetric key into N shares using XOR secret sharing. + */ +export async function splitSymmetricKey( + key: SymmetricKey, + numShares: number +): Promise { + const keyBytes = unwrapSymmetricKey(key); + const randomService = { randomBytes } as unknown as CryptoService; + const splits = await keySplit(keyBytes, numShares, randomService); + return splits.map(wrapSymmetricKey); +} + +/** + * Merge symmetric key shares back into the original key using XOR. + */ +export async function mergeSymmetricKeys(shares: SymmetricKey[]): Promise { + const splitBytes = shares.map(unwrapSymmetricKey); + const merged = keyMerge(splitBytes); + return wrapSymmetricKey(merged); +} diff --git a/lib/tdf3/src/crypto/index.ts b/lib/tdf3/src/crypto/index.ts index 80e0f2756..aeb794b0b 100644 --- a/lib/tdf3/src/crypto/index.ts +++ b/lib/tdf3/src/crypto/index.ts @@ -4,1266 +4,80 @@ * @private */ -import { Algorithms } from '../ciphers/index.js'; -import { Binary } from '../binary.js'; +import { type CryptoService } from './declarations.js'; import { - type AsymmetricSigningAlgorithm, - type CryptoService, - type DecryptResult, - type ECCurve, - type EncryptResult, - type HashAlgorithm, - type HkdfParams, - type KeyAlgorithm, - type KeyOptions, - type KeyPair, - MIN_ASYMMETRIC_KEY_SIZE_BITS, - type PrivateKey, - type PublicKey, - type PublicKeyInfo, - type SymmetricKey, -} from './declarations.js'; -import { ConfigurationError, DecryptError } from '../../../src/errors.js'; -import { formatAsPem, removePemFormatting } from './crypto-utils.js'; -import { encodeArrayBuffer as hexEncode } from '../../../src/encodings/hex.js'; -import { decodeArrayBuffer as base64Decode } from '../../../src/encodings/base64.js'; -import { AlgorithmUrn } from '../ciphers/algorithms.js'; -import { exportSPKI, importX509 } from 'jose'; + decrypt, + digest, + encrypt, + generateKey, + hex2Ab, + hmac, + importSymmetricKey, + mergeSymmetricKeys, + randomBytes, + randomBytesAsHex, + splitSymmetricKey, + verifyHmac, +} from './core/symmetric.js'; +import { + decryptWithPrivateKey, + encryptWithPublicKey, + generateKeyPair, + generateSigningKeyPair, + rsaOaepSha1, + rsaPkcs1Sha256, +} from './core/rsa.js'; +import { deriveKeyFromECDH, generateECKeyPair } from './core/ec.js'; +import { sign, verify } from './core/signing.js'; import { - toJwsAlg, - guessAlgorithmName, - guessCurveName, -} from '../../../src/crypto/pemPublicToCrypto.js'; -import { keySplit, keyMerge } from '../utils/keysplit.js'; + exportPrivateKeyPem, + exportPublicKeyJwk, + exportPublicKeyPem, + extractPublicKeyPem, + importPrivateKey, + importPublicKey, + jwkToPublicKeyPem, + parsePublicKeyPem, + publicKeyPemToJwk, +} from './core/key-format.js'; -// Used to pass into native crypto functions -const ENC_DEC_METHODS: KeyUsage[] = ['encrypt', 'decrypt']; -const SIGN_VERIFY_METHODS: KeyUsage[] = ['sign', 'verify']; export const isSupported = typeof globalThis?.crypto !== 'undefined'; - export const method = 'http://www.w3.org/2001/04/xmlenc#aes256-cbc'; export const name = 'BrowserNativeCryptoService'; -/** - * Get a DOMString representing the algorithm to use for an - * asymmetric key generation. - */ -export function rsaOaepSha1( - modulusLength: number = MIN_ASYMMETRIC_KEY_SIZE_BITS -): RsaHashedKeyGenParams { - if (!modulusLength || modulusLength < MIN_ASYMMETRIC_KEY_SIZE_BITS) { - throw new ConfigurationError('Invalid key size requested'); - } - return { - name: 'RSA-OAEP', - hash: { - name: 'SHA-1', - }, - modulusLength, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 24 bit representation of 65537 - }; -} - -export function rsaPkcs1Sha256( - modulusLength: number = MIN_ASYMMETRIC_KEY_SIZE_BITS -): RsaHashedKeyGenParams { - if (!modulusLength || modulusLength < MIN_ASYMMETRIC_KEY_SIZE_BITS) { - throw new ConfigurationError('Invalid key size requested'); - } - return { - name: 'RSASSA-PKCS1-v1_5', - hash: { - name: 'SHA-256', - }, - modulusLength, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 24 bit representation of 65537 - }; -} - -/** - * Generate a random symmetric key (opaque). - * @param length - Key length in bytes (default 32 for AES-256) - * @return Opaque symmetric key - */ -export async function generateKey(length?: number): Promise { - const keyBytes = await randomBytes(length || 32); - return wrapSymmetricKey(keyBytes); -} - -// ============================================================ -// Opaque Key Wrapping/Unwrapping Helpers -// ============================================================ - -/** - * Wrap a CryptoKey as an opaque PublicKey. - * @internal - */ -function wrapPublicKey(key: CryptoKey, algorithm: KeyAlgorithm): PublicKey { - const result: any = { - _brand: 'PublicKey', - algorithm, - _internal: key, - }; - if (algorithm.startsWith('rsa:')) { - result.modulusBits = parseInt(algorithm.split(':')[1], 10); - } else if (algorithm.startsWith('ec:')) { - const curvePart = algorithm.split(':')[1]; - result.curve = - curvePart === 'secp256r1' - ? 'P-256' - : curvePart === 'secp384r1' - ? 'P-384' - : curvePart === 'secp521r1' - ? 'P-521' - : undefined; - } - return result as PublicKey; -} - -/** - * Wrap a CryptoKey as an opaque PrivateKey. - * @internal - */ -function wrapPrivateKey(key: CryptoKey, algorithm: KeyAlgorithm): PrivateKey { - const result: any = { - _brand: 'PrivateKey', - algorithm, - _internal: key, - }; - if (algorithm.startsWith('rsa:')) { - result.modulusBits = parseInt(algorithm.split(':')[1], 10); - } else if (algorithm.startsWith('ec:')) { - const curvePart = algorithm.split(':')[1]; - result.curve = - curvePart === 'secp256r1' - ? 'P-256' - : curvePart === 'secp384r1' - ? 'P-384' - : curvePart === 'secp521r1' - ? 'P-521' - : undefined; - } - return result as PrivateKey; -} - -/** - * Unwrap an opaque key to get the internal CryptoKey. - * @internal - */ -function unwrapKey(key: PublicKey | PrivateKey): CryptoKey { - return (key as any)._internal; -} - -/** - * Wrap raw key bytes as an opaque SymmetricKey. - * @internal - */ -function wrapSymmetricKey(keyBytes: Uint8Array): SymmetricKey { - return { - _brand: 'SymmetricKey', - length: keyBytes.length * 8, // bits - _internal: keyBytes, - } as SymmetricKey; -} - -/** - * Unwrap an opaque SymmetricKey to get raw bytes. - * @internal - */ -function unwrapSymmetricKey(key: SymmetricKey): Uint8Array { - return (key as any)._internal; -} - -/** - * Generate an RSA key pair - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey} - * @param size in bits - */ -export async function generateKeyPair(size?: number): Promise { - const keySize = size || MIN_ASYMMETRIC_KEY_SIZE_BITS; - const algoDomString = rsaOaepSha1(keySize); - const keyPair = await crypto.subtle.generateKey(algoDomString, true, ENC_DEC_METHODS); - - // Map to supported algorithm sizes - let algorithm: KeyAlgorithm; - if (keySize === 2048) { - algorithm = 'rsa:2048'; - } else if (keySize === 4096) { - algorithm = 'rsa:4096'; - } else { - throw new ConfigurationError( - `Unsupported RSA key size: ${keySize}. Only 2048 and 4096 are supported.` - ); - } - - return { - publicKey: wrapPublicKey(keyPair.publicKey, algorithm), - privateKey: wrapPrivateKey(keyPair.privateKey, algorithm), - }; -} - -/** - * Generate an RSA key pair suitable for signatures - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey} - */ -export async function generateSigningKeyPair(): Promise { - const rsaParams = rsaPkcs1Sha256(2048); - const keyPair = await crypto.subtle.generateKey(rsaParams, true, SIGN_VERIFY_METHODS); - - const algorithm: KeyAlgorithm = 'rsa:2048'; - return { - publicKey: wrapPublicKey(keyPair.publicKey, algorithm), - privateKey: wrapPrivateKey(keyPair.privateKey, algorithm), - }; -} - -/** - * Encrypt using a public key (RSA-OAEP). - * Accepts Binary or SymmetricKey for key wrapping. - * @param payload Payload to encrypt (Binary) or symmetric key to wrap (SymmetricKey) - * @param publicKey Opaque public key - * @return Encrypted payload - */ -export async function encryptWithPublicKey( - payload: Binary | SymmetricKey, - publicKey: PublicKey -): Promise { - let payloadBuffer: BufferSource; - - // Handle SymmetricKey unwrapping - if ('_brand' in payload && payload._brand === 'SymmetricKey') { - // Pass Uint8Array directly — Web Crypto respects byteOffset/byteLength on typed array views. - payloadBuffer = unwrapSymmetricKey(payload); - } else { - // Binary payload - payloadBuffer = (payload as Binary).asArrayBuffer(); - } - - const cryptoKey = unwrapKey(publicKey); - const result = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, cryptoKey, payloadBuffer); - return Binary.fromArrayBuffer(result); -} - -export async function randomBytes(byteLength: number): Promise { - const r = new Uint8Array(byteLength); - crypto.getRandomValues(r); - return r; -} - -/** - * Returns a promise to the encryption key as a binary string. - * - * Note: This function should almost never fail as it includes a fallback - * if for some reason the native generate key fails. - * - * @param length The key length, defaults to 256 - * - * @returns The hex string. - */ -export async function randomBytesAsHex(length: number): Promise { - // Create a typed array of the correct length to fill - const r = new Uint8Array(length); - crypto.getRandomValues(r); - return hexEncode(r.buffer); -} - -/** - * Decrypt a public-key encrypted payload with a private key - * @param encryptedPayload Payload to decrypt - * @param privateKey Opaque private key - * @return Decrypted payload - */ -export async function decryptWithPrivateKey( - encryptedPayload: Binary, - privateKey: PrivateKey -): Promise { - console.assert(typeof encryptedPayload === 'object', 'encryptedPayload must be object'); - - const cryptoKey = unwrapKey(privateKey); - const payload = await crypto.subtle.decrypt( - { name: 'RSA-OAEP' }, - cryptoKey, - encryptedPayload.asArrayBuffer() - ); - const bufferView = new Uint8Array(payload); - return Binary.fromArrayBuffer(bufferView.buffer); -} - -/** - * Decrypt content synchronously - * @param payload The payload to decrypt - * @param key The symmetric encryption key (opaque) - * @param iv The initialization vector - * @param algorithm The algorithm to use for encryption - * @param authTag The authentication tag for authenticated crypto. - */ -export function decrypt( - payload: Binary, - key: SymmetricKey, - iv: Binary, - algorithm?: AlgorithmUrn, - authTag?: Binary -): Promise { - return _doDecrypt(payload, key, iv, algorithm, authTag); -} - -/** - * Encrypt content synchronously - * @param payload The payload to encrypt - * @param key The encryption key - * @param iv The initialization vector - * @param algorithm The algorithm to use for encryption - */ -export function encrypt( - payload: Binary | SymmetricKey, - key: SymmetricKey, - iv: Binary, - algorithm?: AlgorithmUrn -): Promise { - return _doEncrypt(payload, key, iv, algorithm); -} - -async function _doEncrypt( - payload: Binary | SymmetricKey, - key: SymmetricKey, - iv: Binary, - algorithm?: AlgorithmUrn -): Promise { - console.assert(payload != null); - console.assert(key != null); - console.assert(iv != null); - - // Handle both Binary and SymmetricKey payloads - let payloadBuffer: BufferSource; - if ('_brand' in payload && payload._brand === 'SymmetricKey') { - // Pass Uint8Array directly — Web Crypto respects byteOffset/byteLength on typed array views. - payloadBuffer = unwrapSymmetricKey(payload); - } else { - // Binary payload - payloadBuffer = (payload as Binary).asArrayBuffer(); - } - - const algoDomString = getSymmetricAlgoDomString(iv, algorithm); - - // Unwrap symmetric key to get raw bytes - const keyBytes = unwrapSymmetricKey(key); - const importedKey = await _importKey(keyBytes, algoDomString); - const encrypted = await crypto.subtle.encrypt(algoDomString, importedKey, payloadBuffer); - if (algoDomString.name === 'AES-GCM') { - return { - payload: Binary.fromArrayBuffer(encrypted.slice(0, -16)), - authTag: Binary.fromArrayBuffer(encrypted.slice(-16)), - }; - } - return { - payload: Binary.fromArrayBuffer(encrypted), - }; -} - -async function _doDecrypt( - payload: Binary, - key: SymmetricKey, - iv: Binary, - algorithm?: AlgorithmUrn, - authTag?: Binary -): Promise { - console.assert(payload != null); - console.assert(key != null); - console.assert(iv != null); - - let payloadBuffer = payload.asArrayBuffer(); - - // Concat the the auth tag to the payload for decryption - if (authTag) { - const authTagBuffer = authTag.asArrayBuffer(); - const gcmPayload = new Uint8Array(payloadBuffer.byteLength + authTagBuffer.byteLength); - gcmPayload.set(new Uint8Array(payloadBuffer), 0); - gcmPayload.set(new Uint8Array(authTagBuffer), payloadBuffer.byteLength); - payloadBuffer = gcmPayload.buffer; - } - - const algoDomString = getSymmetricAlgoDomString(iv, algorithm); - - // Unwrap symmetric key to get raw bytes - const keyBytes = unwrapSymmetricKey(key); - const importedKey = await _importKey(keyBytes, algoDomString); - algoDomString.iv = iv.asArrayBuffer(); - - const decrypted = await crypto.subtle - .decrypt(algoDomString, importedKey, payloadBuffer) - // Catching this error so we can specifically check for OperationError - .catch((err) => { - if (err.name === 'OperationError') { - throw new DecryptError(err); - } - - throw err; - }); - return { payload: Binary.fromArrayBuffer(decrypted) }; -} - -function _importKey(keyBytes: Uint8Array, algorithm: AesCbcParams | AesGcmParams) { - return crypto.subtle.importKey('raw', keyBytes, algorithm, true, ENC_DEC_METHODS); -} - -/** - * Get a DOMString representing the algorithm to use for a crypto - * operation. Defaults to AES-CBC. - * @param {String|undefined} algorithm - * @return {DOMString} Algorithm to use - */ -function getSymmetricAlgoDomString( - iv: Binary, - algorithm?: AlgorithmUrn -): AesCbcParams | AesGcmParams { - let nativeAlgorithm = 'AES-CBC'; - if (algorithm === Algorithms.AES_256_GCM) { - nativeAlgorithm = 'AES-GCM'; - } - - return { - name: nativeAlgorithm, - iv: iv.asArrayBuffer(), - }; -} - -/** - * Create a SHA256 hash. Code refrenced from MDN: - * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest - * @param content String content - * @return Hex hash - */ - -/** - * Create an ArrayBuffer from a hex string. - * https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String?hl=en - * @param hex - Hex string - */ -export function hex2Ab(hex: string): ArrayBuffer { - const buffer = new ArrayBuffer(hex.length / 2); - const bufferView = new Uint8Array(buffer); - - for (let i = 0; i < hex.length; i += 2) { - bufferView[i / 2] = parseInt(hex.substr(i, 2), 16); - } - - return buffer; -} - -/** - * Get the Web Crypto algorithm parameters for a signing algorithm. - */ -function getSigningAlgorithmParams(algorithm: AsymmetricSigningAlgorithm): { - importParams: RsaHashedImportParams | EcKeyImportParams; - signParams: AlgorithmIdentifier | EcdsaParams; -} { - switch (algorithm) { - case 'RS256': - return { - importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - signParams: 'RSASSA-PKCS1-v1_5', - }; - case 'ES256': - return { - importParams: { name: 'ECDSA', namedCurve: 'P-256' }, - signParams: { name: 'ECDSA', hash: 'SHA-256' } as EcdsaParams, - }; - case 'ES384': - return { - importParams: { name: 'ECDSA', namedCurve: 'P-384' }, - signParams: { name: 'ECDSA', hash: 'SHA-384' } as EcdsaParams, - }; - case 'ES512': - return { - importParams: { name: 'ECDSA', namedCurve: 'P-521' }, - signParams: { name: 'ECDSA', hash: 'SHA-512' } as EcdsaParams, - }; - default: - throw new ConfigurationError(`Unsupported signing algorithm: ${algorithm}`); - } -} - -/** - * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format (used by JWT). - * RS256 signatures don't need conversion. - */ -function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { - if (algorithm === 'RS256') { - return signature; - } - - // IEEE P1363: r || s where each is padded to key size - const halfLen = signature.length / 2; - const r = signature.slice(0, halfLen); - const s = signature.slice(halfLen); - - // Remove leading zeros but keep one if the high bit is set - const trimLeadingZeros = (arr: Uint8Array): Uint8Array => { - let i = 0; - while (i < arr.length - 1 && arr[i] === 0) i++; - return arr.slice(i); - }; - - let rTrimmed = trimLeadingZeros(r); - let sTrimmed = trimLeadingZeros(s); - - // Add leading zero if high bit is set (to keep positive in DER) - if (rTrimmed[0] & 0x80) { - const padded = new Uint8Array(rTrimmed.length + 1); - padded.set(rTrimmed, 1); - rTrimmed = padded; - } - if (sTrimmed[0] & 0x80) { - const padded = new Uint8Array(sTrimmed.length + 1); - padded.set(sTrimmed, 1); - sTrimmed = padded; - } - - // DER SEQUENCE: 0x30 [length] [r INTEGER] [s INTEGER] - // INTEGER: 0x02 [length] [value] - const rDer = new Uint8Array([0x02, rTrimmed.length, ...rTrimmed]); - const sDer = new Uint8Array([0x02, sTrimmed.length, ...sTrimmed]); - - const seqLen = rDer.length + sDer.length; - // DER length: short-form for < 128, long-form (0x81 nn) for 128-255. - // ECDSA sequences never exceed 255 bytes for any supported curve. - const lenBytes = seqLen < 128 ? new Uint8Array([seqLen]) : new Uint8Array([0x81, seqLen]); - const result = new Uint8Array(1 + lenBytes.length + seqLen); - result[0] = 0x30; - result.set(lenBytes, 1); - result.set(rDer, 1 + lenBytes.length); - result.set(sDer, 1 + lenBytes.length + rDer.length); - - return result; -} - -/** - * Convert DER signature format (used by JWT) to IEEE P1363 format (used by WebCrypto ECDSA). - * RS256 signatures don't need conversion. - */ -function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { - if (algorithm === 'RS256') { - return signature; - } - - // Determine the expected component length based on algorithm - let componentLen: number; - switch (algorithm) { - case 'ES256': - componentLen = 32; - break; - case 'ES384': - componentLen = 48; - break; - case 'ES512': - componentLen = 66; - break; - default: - throw new ConfigurationError(`Unsupported algorithm for DER conversion: ${algorithm}`); - } - - // Parse DER: SEQUENCE { INTEGER r, INTEGER s } - if (signature[0] !== 0x30) { - throw new ConfigurationError('Invalid DER signature: expected SEQUENCE'); - } - - // Skip SEQUENCE tag, then parse DER length (short- or long-form). - let offset = 1; - if (signature[offset] & 0x80) { - // Long-form: low 7 bits = number of subsequent length bytes. - const lenBytesCount = signature[offset] & 0x7f; - if (lenBytesCount === 0 || lenBytesCount > 4) { - throw new ConfigurationError('Invalid DER signature: invalid long-form length'); - } - offset += 1 + lenBytesCount; - if (offset > signature.length) { - throw new ConfigurationError('Invalid DER signature: length bytes exceed signature length'); - } - } else { - // Short-form: single length byte. - offset += 1; - } - - // Parse r INTEGER - if (signature[offset] !== 0x02) { - throw new ConfigurationError('Invalid DER signature: expected INTEGER for r'); - } - const rLen = signature[offset + 1]; - offset += 2; - let r = signature.slice(offset, offset + rLen); - offset += rLen; - - // Parse s INTEGER - if (signature[offset] !== 0x02) { - throw new ConfigurationError('Invalid DER signature: expected INTEGER for s'); - } - const sLen = signature[offset + 1]; - offset += 2; - let s = signature.slice(offset, offset + sLen); - - // Remove leading zero padding if present - if (r[0] === 0 && r.length > componentLen) { - r = r.slice(1); - } - if (s[0] === 0 && s.length > componentLen) { - s = s.slice(1); - } - - // Pad to component length - const result = new Uint8Array(componentLen * 2); - result.set(r, componentLen - r.length); - result.set(s, componentLen * 2 - s.length); - - return result; -} - -/** - * Sign data with an asymmetric private key. - */ -export async function sign( - data: Uint8Array, - privateKey: PrivateKey, - algorithm: AsymmetricSigningAlgorithm -): Promise { - const { signParams } = getSigningAlgorithmParams(algorithm); - - // Unwrap the internal CryptoKey - const key = unwrapKey(privateKey); - - // Sign the data - const signature = await crypto.subtle.sign(signParams, key, data); - - // Convert from IEEE P1363 to DER for EC algorithms - return ieeeP1363ToDer(new Uint8Array(signature), algorithm); -} - -/** - * Verify signature with an asymmetric public key. - */ -export async function verify( - data: Uint8Array, - signature: Uint8Array, - publicKey: PublicKey, - algorithm: AsymmetricSigningAlgorithm -): Promise { - const { signParams } = getSigningAlgorithmParams(algorithm); - - // Unwrap the internal CryptoKey - const key = unwrapKey(publicKey); - - // Convert from DER to IEEE P1363 for EC algorithms - const ieeeSignature = derToIeeeP1363(signature, algorithm); - - // Verify the signature - return crypto.subtle.verify(signParams, key, ieeeSignature, data); -} - -/** - * Compute hash digest. - */ -export async function digest(algorithm: HashAlgorithm, data: Uint8Array): Promise { - // Validate algorithm and map to Web Crypto name - const validAlgorithms: HashAlgorithm[] = ['SHA-256', 'SHA-384', 'SHA-512']; - if (!validAlgorithms.includes(algorithm)) { - throw new ConfigurationError(`Unsupported hash algorithm: ${algorithm}`); - } - - const hashBuffer = await crypto.subtle.digest(algorithm, data); - return new Uint8Array(hashBuffer); -} - -/** - * Extract PEM public key from X.509 certificate or return PEM key as-is. - * - * @param certOrPem - A PEM-encoded X.509 certificate or public key - * @param jwaAlgorithm - JWA algorithm hint for certificate parsing (RS256, RS512, ES256, ES384, ES512). - * If not provided for a certificate, will attempt to auto-detect from OIDs. - */ -export async function extractPublicKeyPem( - certOrPem: string, - jwaAlgorithm?: string -): Promise { - // If it's a certificate, extract the public key - if (certOrPem.includes('-----BEGIN CERTIFICATE-----')) { - let alg = jwaAlgorithm; - if (!alg) { - // Auto-detect algorithm from certificate OIDs - const certBody = certOrPem.replace(/-----(BEGIN|END) CERTIFICATE-----|\s/g, ''); - const certBytes = base64Decode(certBody); - const hex = hexEncode(certBytes); - alg = toJwsAlg(hex); - } - const cert = await importX509(certOrPem, alg, { extractable: true }); - return exportSPKI(cert); - } - - // If it's already a PEM public key, return as-is - if (certOrPem.includes('-----BEGIN PUBLIC KEY-----')) { - return certOrPem; - } - - throw new ConfigurationError('Input must be a PEM-encoded certificate or public key'); -} - -/** - * Map ECCurve to Web Crypto named curve. - */ -function curveToNamedCurve(curve: ECCurve): string { - switch (curve) { - case 'P-256': - return 'P-256'; - case 'P-384': - return 'P-384'; - case 'P-521': - return 'P-521'; - default: - throw new ConfigurationError(`Unsupported curve: ${curve}`); - } -} - -/** - * Generate an EC key pair for ECDH key agreement. - */ -export async function generateECKeyPair(curve: ECCurve = 'P-256'): Promise { - const namedCurve = curveToNamedCurve(curve); - - // Generate key pair for ECDH key agreement - const keyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve }, true, [ - 'deriveBits', - ]); - - // Map to KeyAlgorithm literal type - let algorithm: KeyAlgorithm; - switch (namedCurve) { - case 'P-256': - algorithm = 'ec:secp256r1'; - break; - case 'P-384': - algorithm = 'ec:secp384r1'; - break; - case 'P-521': - algorithm = 'ec:secp521r1'; - break; - default: - throw new ConfigurationError(`Unsupported curve: ${namedCurve}`); - } - - return { - publicKey: wrapPublicKey(keyPair.publicKey, algorithm), - privateKey: wrapPrivateKey(keyPair.privateKey, algorithm), - }; -} - -/** - * Supported EC curves. - */ -const SUPPORTED_EC_CURVES = ['P-256', 'P-384', 'P-521'] as const; -type SupportedEcCurve = (typeof SUPPORTED_EC_CURVES)[number]; - -/** - * Decode base64url string and return byte length. - * Uses the existing base64 decoder which handles both standard and URL-safe encoding. - */ -function base64urlByteLength(base64url: string): number { - // Add padding if needed (base64url omits padding) - const padding = (4 - (base64url.length % 4)) % 4; - const padded = base64url + '='.repeat(padding); - return base64Decode(padded).byteLength; -} - -/** - * Extract EC curve from a public key by parsing ASN.1 OIDs. - * Reuses the existing guessCurveName function that checks for curve OIDs. - */ -function extractEcCurveFromPublicKey(keyData: ArrayBuffer): SupportedEcCurve { - // Convert to hex for OID parsing - const hexKey = hexEncode(keyData); - - // Use existing OID parser (returns 'P-256', 'P-384', or 'P-521') - const curveName = guessCurveName(hexKey); - - return curveName as SupportedEcCurve; -} - -/** - * Perform ECDH key agreement followed by HKDF key derivation. - * Returns opaque symmetric key for symmetric encryption. - */ -export async function deriveKeyFromECDH( - privateKey: PrivateKey, - publicKey: PublicKey, - hkdfParams: HkdfParams -): Promise { - // Unwrap the internal CryptoKeys - const privateKeyCrypto = unwrapKey(privateKey); - const publicKeyCrypto = unwrapKey(publicKey); - - // Get curve from key metadata - const curve = publicKey.curve; - if (!curve) { - throw new ConfigurationError('EC curve not found on public key'); - } - - // Determine bits based on curve - const curveBits: Record = { - 'P-256': 256, - 'P-384': 384, - 'P-521': 528, // P-521 derives 528 bits (66 bytes) - }; - const bits = curveBits[curve]; - - // Perform ECDH to get shared secret - const sharedSecret = await crypto.subtle.deriveBits( - { name: 'ECDH', public: publicKeyCrypto }, - privateKeyCrypto, - bits - ); - - // Import shared secret as HKDF key material - const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']); - - // Derive the final key using HKDF - const keyLength = hkdfParams.keyLength ?? 256; - const derivedKey = await crypto.subtle.deriveKey( - { - name: 'HKDF', - hash: hkdfParams.hash, - salt: hkdfParams.salt, - info: hkdfParams.info ?? new Uint8Array(0), - }, - hkdfKey, - { name: 'AES-GCM', length: keyLength }, - true, - ['encrypt', 'decrypt'] - ); - - // Export the derived key as raw bytes and wrap as SymmetricKey - const keyBytes = await crypto.subtle.exportKey('raw', derivedKey); - return wrapSymmetricKey(new Uint8Array(keyBytes)); -} - -/** - * Compute HMAC-SHA256 of data with a symmetric key. - */ -export async function hmac(data: Uint8Array, key: SymmetricKey): Promise { - // Unwrap symmetric key to get raw bytes - const keyBytes = unwrapSymmetricKey(key); - const cryptoKey = await crypto.subtle.importKey( - 'raw', - keyBytes, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - - const signature = await crypto.subtle.sign('HMAC', cryptoKey, data); - return new Uint8Array(signature); -} - -/** - * Verify HMAC-SHA256. Standalone utility — not part of CryptoService interface. - */ -export async function verifyHmac( - data: Uint8Array, - signature: Uint8Array, - key: SymmetricKey -): Promise { - const keyBytes = unwrapSymmetricKey(key); - const cryptoKey = await crypto.subtle.importKey( - 'raw', - keyBytes, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ); - return crypto.subtle.verify('HMAC', cryptoKey, signature, data); -} - -/** - * Extract RSA modulus bit length by importing key and exporting as JWK. - * Uses Web Crypto's built-in ASN.1 parsing for robustness. - */ -async function extractRsaModulusBitLength(keyData: ArrayBuffer): Promise { - const key = await crypto.subtle.importKey( - 'spki', - keyData, - { name: 'RSA-OAEP', hash: 'SHA-256' }, - true, // extractable - ['encrypt'] - ); - const jwk = await crypto.subtle.exportKey('jwk', key); - if (!jwk.n) { - throw new ConfigurationError('Invalid RSA key: missing modulus'); - } - // JWK 'n' is base64url-encoded modulus - // Decode and count bytes, multiply by 8 for bits - return base64urlByteLength(jwk.n) * 8; -} - -/** - * Import and validate a PEM public key, returning algorithm info. - * Uses JWK export for robust key parameter detection. - */ -export async function parsePublicKeyPem(pem: string): Promise { - // First extract public key if it's a certificate - let publicKeyPem = pem; - if (pem.includes('-----BEGIN CERTIFICATE-----')) { - publicKeyPem = await extractPublicKeyPem(pem); - } - - if (!publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')) { - throw new ConfigurationError('Input must be a PEM-encoded public key or certificate'); - } - - const keyData = base64Decode(removePemFormatting(publicKeyPem)); - - // Try RSA first - use JWK export to get modulus size - try { - const modulusBits = await extractRsaModulusBitLength(keyData); - let algorithm: PublicKeyInfo['algorithm']; - if (modulusBits < MIN_ASYMMETRIC_KEY_SIZE_BITS) { - throw new ConfigurationError( - `RSA key size ${modulusBits} bits is below the minimum of ${MIN_ASYMMETRIC_KEY_SIZE_BITS} bits` - ); - } else if (modulusBits <= 2048) { - algorithm = 'rsa:2048'; - } else if (modulusBits <= 4096) { - algorithm = 'rsa:4096'; - } else { - throw new ConfigurationError(`Unsupported RSA key size: ${modulusBits} bits`); - } - return { algorithm, pem: publicKeyPem }; - } catch (e) { - // If it's our own ConfigurationError, rethrow - if (e instanceof ConfigurationError) { - throw e; - } - // Not an RSA key, try EC next - } - - // Try EC - parse curve from OID - try { - const detectedCurve = extractEcCurveFromPublicKey(keyData); - const curveMap = { - 'P-256': 'ec:secp256r1', - 'P-384': 'ec:secp384r1', - 'P-521': 'ec:secp521r1', - } as const; - return { algorithm: curveMap[detectedCurve], pem: publicKeyPem }; - } catch { - // Not a valid EC key - } - - throw new ConfigurationError('Unable to determine public key algorithm - unsupported key type'); -} - -/** - * Convert a JWK (JSON Web Key) to PEM format. - */ -export async function jwkToPublicKeyPem(jwk: JsonWebKey): Promise { - let key: CryptoKey; - - if (jwk.kty === 'RSA') { - // RSA key - key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, true, [ - 'encrypt', - ]); - } else if (jwk.kty === 'EC') { - // EC key - const crv = jwk.crv; - if (!crv || !['P-256', 'P-384', 'P-521'].includes(crv)) { - throw new ConfigurationError(`Unsupported EC curve: ${crv}`); - } - key = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDH', namedCurve: crv }, true, []); - } else { - throw new ConfigurationError(`Unsupported JWK key type: ${jwk.kty}`); - } - - const spkiBuffer = await crypto.subtle.exportKey('spki', key); - return formatAsPem(spkiBuffer, 'PUBLIC KEY'); -} - -/** - * Convert a PEM public key to JWK format. - * Returns only public key components (no private key data). - */ -export async function publicKeyPemToJwk(publicKeyPem: string): Promise { - const keyDataBase64 = removePemFormatting(publicKeyPem); - const keyBuffer = base64Decode(keyDataBase64); - const hex = hexEncode(keyBuffer); - - // Detect key type using OID - const algorithmName = guessAlgorithmName(hex); - - if (algorithmName === 'ECDH' || algorithmName === 'ECDSA') { - // EC key - detect curve from OID - const namedCurve = guessCurveName(hex); - const key = await crypto.subtle.importKey( - 'spki', - keyBuffer, - { name: 'ECDSA', namedCurve }, - true, - ['verify'] - ); - const jwk = await crypto.subtle.exportKey('jwk', key); - // Return only public key components - const { kty, crv, x, y } = jwk; - return { kty, crv, x, y }; - } else { - // RSA key - const key = await crypto.subtle.importKey( - 'spki', - keyBuffer, - { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - true, - ['verify'] - ); - const jwk = await crypto.subtle.exportKey('jwk', key); - // Return only public key components - const { kty, e, n } = jwk; - return { kty, e, n }; - } -} - -// ============================================================ -// Key Import Functions (PEM → Opaque) -// ============================================================ - -/** - * Import a PEM public key as an opaque key. - */ -export async function importPublicKey(pem: string, options: KeyOptions): Promise { - const { usage = 'encrypt', extractable = true, algorithmHint } = options; - - // Detect algorithm from PEM; also normalises certificates → plain SPKI PEM. - const keyInfo = await parsePublicKeyPem(pem); - const algorithm = algorithmHint || keyInfo.algorithm; - - // Use keyInfo.pem (normalised SPKI) not the original pem, which may be a certificate. - // Passing raw X.509 DER bytes to crypto.subtle.importKey('spki') would throw DataError. - const keyData = removePemFormatting(keyInfo.pem); - const keyBuffer = base64Decode(keyData); - - // Determine Web Crypto algorithm and usages based on key type and usage - let cryptoAlgorithm: RsaHashedImportParams | EcKeyImportParams; - let keyUsages: KeyUsage[]; - - if (algorithm.startsWith('rsa:')) { - if (usage === 'encrypt') { - cryptoAlgorithm = rsaOaepSha1(); - keyUsages = ['encrypt']; - } else if (usage === 'sign') { - cryptoAlgorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; - keyUsages = ['verify']; - } else { - throw new ConfigurationError('RSA keys only support usage: encrypt or sign'); - } - } else if (algorithm.startsWith('ec:')) { - const curve = algorithm.split(':')[1]; - const namedCurve = - curve === 'secp256r1' - ? 'P-256' - : curve === 'secp384r1' - ? 'P-384' - : curve === 'secp521r1' - ? 'P-521' - : (() => { - throw new ConfigurationError(`Unsupported EC curve: ${curve}`); - })(); - - if (usage === 'derive') { - cryptoAlgorithm = { name: 'ECDH', namedCurve }; - keyUsages = []; - } else if (usage === 'sign') { - cryptoAlgorithm = { name: 'ECDSA', namedCurve }; - keyUsages = ['verify']; - } else { - throw new ConfigurationError('EC keys only support usage: derive or sign'); - } - } else { - throw new ConfigurationError(`Unsupported algorithm: ${algorithm}`); - } - - // Import as CryptoKey - const cryptoKey = await crypto.subtle.importKey( - 'spki', - keyBuffer, - cryptoAlgorithm, - extractable, - keyUsages - ); - - return wrapPublicKey(cryptoKey, algorithm); -} - -/** - * Import a PEM private key as an opaque key. - */ -export async function importPrivateKey(pem: string, options: KeyOptions): Promise { - const { usage = 'encrypt', extractable = true, algorithmHint } = options; - - // Detect algorithm from PEM structure (similar to public key detection) - // For now, use algorithmHint if provided, otherwise detect from key structure - let algorithm: KeyAlgorithm; - - const keyData = removePemFormatting(pem); - const keyBuffer = base64Decode(keyData); - - if (algorithmHint) { - algorithm = algorithmHint; - } else { - // PKCS#8 PrivateKeyInfo embeds the same AlgorithmIdentifier OIDs as SPKI, - // so guessAlgorithmName / guessCurveName work on private key bytes too. - const hex = hexEncode(keyBuffer); - const algorithmName = guessAlgorithmName(hex); // throws on unrecognised OID - if (algorithmName === 'ECDH' || algorithmName === 'ECDSA') { - const namedCurve = guessCurveName(hex); - const curveMap: Record = { - 'P-256': 'ec:secp256r1', - 'P-384': 'ec:secp384r1', - 'P-521': 'ec:secp521r1', - }; - const mapped = curveMap[namedCurve]; - if (!mapped) - throw new ConfigurationError(`Unsupported EC curve in private key: ${namedCurve}`); - algorithm = mapped; - } else { - // RSA — determine key size by importing and reading modulus length from JWK - const tempKey = await crypto.subtle.importKey( - 'pkcs8', - keyBuffer, - { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - true, - ['sign'] - ); - const jwk = await crypto.subtle.exportKey('jwk', tempKey); - if (!jwk.n) { - throw new ConfigurationError('Invalid RSA private key: missing modulus'); - } - const modulusBits = base64urlByteLength(jwk.n) * 8; - if (modulusBits < MIN_ASYMMETRIC_KEY_SIZE_BITS) { - throw new ConfigurationError( - `RSA key size ${modulusBits} bits is below the minimum of ${MIN_ASYMMETRIC_KEY_SIZE_BITS} bits` - ); - } - algorithm = modulusBits <= 2048 ? 'rsa:2048' : 'rsa:4096'; - } - } - - // Determine Web Crypto algorithm and usages - let cryptoAlgorithm: RsaHashedImportParams | EcKeyImportParams; - let keyUsages: KeyUsage[]; - - if (algorithm.startsWith('rsa:')) { - if (usage === 'encrypt') { - cryptoAlgorithm = rsaOaepSha1(); - keyUsages = ['decrypt']; - } else if (usage === 'sign') { - cryptoAlgorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; - keyUsages = ['sign']; - } else { - throw new ConfigurationError('RSA keys only support usage: encrypt or sign'); - } - } else if (algorithm.startsWith('ec:')) { - const curve = algorithm.split(':')[1]; - const namedCurve = - curve === 'secp256r1' - ? 'P-256' - : curve === 'secp384r1' - ? 'P-384' - : curve === 'secp521r1' - ? 'P-521' - : (() => { - throw new ConfigurationError(`Unsupported EC curve: ${curve}`); - })(); - - if (usage === 'derive') { - cryptoAlgorithm = { name: 'ECDH', namedCurve }; - keyUsages = ['deriveBits']; - } else if (usage === 'sign') { - cryptoAlgorithm = { name: 'ECDSA', namedCurve }; - keyUsages = ['sign']; - } else { - throw new ConfigurationError('EC keys only support usage: derive or sign'); - } - } else { - throw new ConfigurationError(`Unsupported algorithm: ${algorithm}`); - } - - // Import as CryptoKey - const cryptoKey = await crypto.subtle.importKey( - 'pkcs8', - keyBuffer, - cryptoAlgorithm, - extractable, - keyUsages - ); - - return wrapPrivateKey(cryptoKey, algorithm); -} - -// ============================================================ -// Key Export Functions (Opaque → PEM/JWK) -// ============================================================ - -/** - * Export an opaque public key to PEM format. - */ -export async function exportPublicKeyPem(key: PublicKey): Promise { - const cryptoKey = unwrapKey(key); - const keyBuffer = await crypto.subtle.exportKey('spki', cryptoKey); - return formatAsPem(keyBuffer, 'PUBLIC KEY'); -} - -/** - * Export an opaque private key to PEM format. - * ONLY USE FOR TESTING/DEVELOPMENT. Private keys should NOT be exportable in secure environments. - */ -export async function exportPrivateKeyPem(key: PrivateKey): Promise { - const cryptoKey = unwrapKey(key); - const keyBuffer = await crypto.subtle.exportKey('pkcs8', cryptoKey); - return formatAsPem(keyBuffer, 'PRIVATE KEY'); -} - -/** - * Export an opaque public key to JWK format. - */ -export async function exportPublicKeyJwk(key: PublicKey): Promise { - const cryptoKey = unwrapKey(key); - return await crypto.subtle.exportKey('jwk', cryptoKey); -} - -/** - * Import raw key bytes as an opaque symmetric key. - * Used for external keys (e.g., unwrapped from KAS). - */ -export async function importSymmetricKey(keyBytes: Uint8Array): Promise { - return wrapSymmetricKey(keyBytes); -} - -/** - * Split a symmetric key into N shares using XOR secret sharing. - * Key bytes are extracted internally for splitting. - * HSM implementations cannot extract bytes and should throw ConfigurationError. - */ -export async function splitSymmetricKey( - key: SymmetricKey, - numShares: number -): Promise { - const keyBytes = unwrapSymmetricKey(key); - const splits = await keySplit(keyBytes, numShares, DefaultCryptoService); - return splits.map(wrapSymmetricKey); -} - -/** - * Merge symmetric key shares back into the original key using XOR. - * Key bytes are extracted internally for merging. - */ -export async function mergeSymmetricKeys(shares: SymmetricKey[]): Promise { - const splitBytes = shares.map(unwrapSymmetricKey); - const merged = keyMerge(splitBytes); - return wrapSymmetricKey(merged); -} +export { + decrypt, + decryptWithPrivateKey, + deriveKeyFromECDH, + digest, + encrypt, + encryptWithPublicKey, + exportPrivateKeyPem, + exportPublicKeyJwk, + exportPublicKeyPem, + extractPublicKeyPem, + generateECKeyPair, + generateKey, + generateKeyPair, + generateSigningKeyPair, + hex2Ab, + hmac, + importPrivateKey, + importPublicKey, + importSymmetricKey, + jwkToPublicKeyPem, + mergeSymmetricKeys, + parsePublicKeyPem, + publicKeyPemToJwk, + randomBytes, + randomBytesAsHex, + rsaOaepSha1, + rsaPkcs1Sha256, + sign, + splitSymmetricKey, + verify, + verifyHmac, +}; export const DefaultCryptoService: CryptoService = { name, From 63ceb7003ac11e06b7dfc103a54a0e37029ef176 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 20 Mar 2026 10:35:28 -0400 Subject: [PATCH 2/3] restore comments/docs --- lib/tdf3/src/crypto/core/ec.ts | 10 +++++++ lib/tdf3/src/crypto/core/key-format.ts | 37 ++++++++++++++++++++++++++ lib/tdf3/src/crypto/core/rsa.ts | 12 +++++++++ lib/tdf3/src/crypto/core/signing.ts | 27 +++++++++++++++++++ lib/tdf3/src/crypto/core/symmetric.ts | 31 +++++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/lib/tdf3/src/crypto/core/ec.ts b/lib/tdf3/src/crypto/core/ec.ts index 274bd4441..9ff5445a2 100644 --- a/lib/tdf3/src/crypto/core/ec.ts +++ b/lib/tdf3/src/crypto/core/ec.ts @@ -32,10 +32,12 @@ function curveToNamedCurve(curve: ECCurve): string { export async function generateECKeyPair(curve: ECCurve = 'P-256'): Promise { const namedCurve = curveToNamedCurve(curve); + // Generate key pair for ECDH key agreement const keyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve }, true, [ 'deriveBits', ]); + // Map to KeyAlgorithm literal type let algorithm: KeyAlgorithm; switch (namedCurve) { case 'P-256': @@ -66,29 +68,36 @@ export async function deriveKeyFromECDH( publicKey: PublicKey, hkdfParams: HkdfParams ): Promise { + // Unwrap the internal CryptoKeys const privateKeyCrypto = unwrapKey(privateKey); const publicKeyCrypto = unwrapKey(publicKey); + // Get curve from key metadata const curve = publicKey.curve; if (!curve) { throw new ConfigurationError('EC curve not found on public key'); } + // Determine bits based on curve const curveBits: Record = { 'P-256': 256, 'P-384': 384, + // P-521 derives 528 bits (66 bytes) 'P-521': 528, }; const bits = curveBits[curve]; + // Perform ECDH to get shared secret const sharedSecret = await crypto.subtle.deriveBits( { name: 'ECDH', public: publicKeyCrypto }, privateKeyCrypto, bits ); + // Import shared secret as HKDF key material const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']); + // Derive the final key using HKDF const keyLength = hkdfParams.keyLength ?? 256; const derivedKey = await crypto.subtle.deriveKey( { @@ -103,6 +112,7 @@ export async function deriveKeyFromECDH( ['encrypt', 'decrypt'] ); + // Export the derived key as raw bytes and wrap as SymmetricKey const keyBytes = await crypto.subtle.exportKey('raw', derivedKey); return wrapSymmetricKey(new Uint8Array(keyBytes)); } diff --git a/lib/tdf3/src/crypto/core/key-format.ts b/lib/tdf3/src/crypto/core/key-format.ts index 87f961678..d7420f8dc 100644 --- a/lib/tdf3/src/crypto/core/key-format.ts +++ b/lib/tdf3/src/crypto/core/key-format.ts @@ -26,9 +26,11 @@ export async function extractPublicKeyPem( certOrPem: string, jwaAlgorithm?: string ): Promise { + // If it's a certificate, extract the public key if (certOrPem.includes('-----BEGIN CERTIFICATE-----')) { let alg = jwaAlgorithm; if (!alg) { + // Auto-detect algorithm from certificate OIDs const certBody = certOrPem.replace(/-----(BEGIN|END) CERTIFICATE-----|\s/g, ''); const certBytes = base64Decode(certBody); const hex = hexEncode(certBytes); @@ -38,6 +40,7 @@ export async function extractPublicKeyPem( return exportSPKI(cert); } + // If it's already a PEM public key, return as-is if (certOrPem.includes('-----BEGIN PUBLIC KEY-----')) { return certOrPem; } @@ -50,8 +53,10 @@ type SupportedEcCurve = (typeof SUPPORTED_EC_CURVES)[number]; /** * Decode base64url string and return byte length. + * Uses the existing base64 decoder which handles both standard and URL-safe encoding. */ function base64urlByteLength(base64url: string): number { + // Add padding if needed (base64url omits padding) const padding = (4 - (base64url.length % 4)) % 4; const padded = base64url + '='.repeat(padding); return base64Decode(padded).byteLength; @@ -59,15 +64,19 @@ function base64urlByteLength(base64url: string): number { /** * Extract EC curve from a public key by parsing ASN.1 OIDs. + * Reuses the existing guessCurveName function that checks for curve OIDs. */ function extractEcCurveFromPublicKey(keyData: ArrayBuffer): SupportedEcCurve { + // Convert to hex for OID parsing const hexKey = hexEncode(keyData); + // Use existing OID parser (returns 'P-256', 'P-384', or 'P-521') const curveName = guessCurveName(hexKey); return curveName as SupportedEcCurve; } /** * Extract RSA modulus bit length by importing key and exporting as JWK. + * Uses Web Crypto's built-in ASN.1 parsing for robustness. */ async function extractRsaModulusBitLength(keyData: ArrayBuffer): Promise { const key = await crypto.subtle.importKey( @@ -81,13 +90,17 @@ async function extractRsaModulusBitLength(keyData: ArrayBuffer): Promise if (!jwk.n) { throw new ConfigurationError('Invalid RSA key: missing modulus'); } + // JWK 'n' is base64url-encoded modulus + // Decode and count bytes, multiply by 8 for bits return base64urlByteLength(jwk.n) * 8; } /** * Import and validate a PEM public key, returning algorithm info. + * Uses JWK export for robust key parameter detection. */ export async function parsePublicKeyPem(pem: string): Promise { + // First extract public key if it's a certificate let publicKeyPem = pem; if (pem.includes('-----BEGIN CERTIFICATE-----')) { publicKeyPem = await extractPublicKeyPem(pem); @@ -99,6 +112,7 @@ export async function parsePublicKeyPem(pem: string): Promise { const keyData = base64Decode(removePemFormatting(publicKeyPem)); + // Try RSA first - use JWK export to get modulus size try { const modulusBits = await extractRsaModulusBitLength(keyData); let algorithm: PublicKeyInfo['algorithm']; @@ -115,11 +129,14 @@ export async function parsePublicKeyPem(pem: string): Promise { } return { algorithm, pem: publicKeyPem }; } catch (error) { + // If it's our own ConfigurationError, rethrow if (error instanceof ConfigurationError) { throw error; } + // Not an RSA key, try EC next } + // Try EC - parse curve from OID try { const detectedCurve = extractEcCurveFromPublicKey(keyData); const curveMap = { @@ -142,10 +159,12 @@ export async function jwkToPublicKeyPem(jwk: JsonWebKey): Promise { let key: CryptoKey; if (jwk.kty === 'RSA') { + // RSA key key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, true, [ 'encrypt', ]); } else if (jwk.kty === 'EC') { + // EC key const crv = jwk.crv; if (!crv || !['P-256', 'P-384', 'P-521'].includes(crv)) { throw new ConfigurationError(`Unsupported EC curve: ${crv}`); @@ -168,9 +187,11 @@ export async function publicKeyPemToJwk(publicKeyPem: string): Promise { const { usage = 'encrypt', extractable = true, algorithmHint } = options; + // Detect algorithm from PEM; also normalises certificates → plain SPKI PEM. const keyInfo = await parsePublicKeyPem(pem); const algorithm = algorithmHint || keyInfo.algorithm; + // Use keyInfo.pem (normalised SPKI) not the original pem, which may be a certificate. + // Passing raw X.509 DER bytes to crypto.subtle.importKey('spki') would throw DataError. const keyData = removePemFormatting(keyInfo.pem); const keyBuffer = base64Decode(keyData); + // Determine Web Crypto algorithm and usages based on key type and usage let cryptoAlgorithm: RsaHashedImportParams | EcKeyImportParams; let keyUsages: KeyUsage[]; @@ -246,6 +274,7 @@ export async function importPublicKey(pem: string, options: KeyOptions): Promise throw new ConfigurationError(`Unsupported algorithm: ${algorithm}`); } + // Import as CryptoKey const cryptoKey = await crypto.subtle.importKey( 'spki', keyBuffer, @@ -263,6 +292,8 @@ export async function importPublicKey(pem: string, options: KeyOptions): Promise export async function importPrivateKey(pem: string, options: KeyOptions): Promise { const { usage = 'encrypt', extractable = true, algorithmHint } = options; + // Detect algorithm from PEM structure (similar to public key detection) + // For now, use algorithmHint if provided, otherwise detect from key structure let algorithm: KeyAlgorithm; const keyData = removePemFormatting(pem); @@ -271,6 +302,8 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis if (algorithmHint) { algorithm = algorithmHint; } else { + // PKCS#8 PrivateKeyInfo embeds the same AlgorithmIdentifier OIDs as SPKI, + // so guessAlgorithmName / guessCurveName work on private key bytes too. const hex = hexEncode(keyBuffer); const algorithmName = guessAlgorithmName(hex); if (algorithmName === 'ECDH' || algorithmName === 'ECDSA') { @@ -286,6 +319,7 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis } algorithm = mapped; } else { + // RSA — determine key size by importing and reading modulus length from JWK const tempKey = await crypto.subtle.importKey( 'pkcs8', keyBuffer, @@ -307,6 +341,7 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis } } + // Determine Web Crypto algorithm and usages let cryptoAlgorithm: RsaHashedImportParams | EcKeyImportParams; let keyUsages: KeyUsage[]; @@ -346,6 +381,7 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis throw new ConfigurationError(`Unsupported algorithm: ${algorithm}`); } + // Import as CryptoKey const cryptoKey = await crypto.subtle.importKey( 'pkcs8', keyBuffer, @@ -368,6 +404,7 @@ export async function exportPublicKeyPem(key: PublicKey): Promise { /** * Export an opaque private key to PEM format. + * ONLY USE FOR TESTING/DEVELOPMENT. Private keys should NOT be exportable in secure environments. */ export async function exportPrivateKeyPem(key: PrivateKey): Promise { const cryptoKey = unwrapKey(key); diff --git a/lib/tdf3/src/crypto/core/rsa.ts b/lib/tdf3/src/crypto/core/rsa.ts index dac09910f..b40ff9441 100644 --- a/lib/tdf3/src/crypto/core/rsa.ts +++ b/lib/tdf3/src/crypto/core/rsa.ts @@ -29,6 +29,7 @@ export function rsaOaepSha1( name: 'SHA-1', }, modulusLength, + // 24 bit representation of 65537 publicExponent: new Uint8Array([0x01, 0x00, 0x01]), }; } @@ -45,6 +46,7 @@ export function rsaPkcs1Sha256( name: 'SHA-256', }, modulusLength, + // 24 bit representation of 65537 publicExponent: new Uint8Array([0x01, 0x00, 0x01]), }; } @@ -59,6 +61,7 @@ export async function generateKeyPair(size?: number): Promise { const algoDomString = rsaOaepSha1(keySize); const keyPair = await crypto.subtle.generateKey(algoDomString, true, ENC_DEC_METHODS); + // Map to supported algorithm sizes let algorithm: KeyAlgorithm; if (keySize === 2048) { algorithm = 'rsa:2048'; @@ -94,6 +97,9 @@ export async function generateSigningKeyPair(): Promise { /** * Encrypt using a public key (RSA-OAEP). * Accepts Binary or SymmetricKey for key wrapping. + * @param payload Payload to encrypt (Binary) or symmetric key to wrap (SymmetricKey) + * @param publicKey Opaque public key + * @return Encrypted payload */ export async function encryptWithPublicKey( payload: Binary | SymmetricKey, @@ -101,9 +107,12 @@ export async function encryptWithPublicKey( ): Promise { let payloadBuffer: BufferSource; + // Handle SymmetricKey unwrapping if ('_brand' in payload && payload._brand === 'SymmetricKey') { + // Pass Uint8Array directly — Web Crypto respects byteOffset/byteLength on typed array views. payloadBuffer = unwrapSymmetricKey(payload); } else { + // Binary payload payloadBuffer = (payload as Binary).asArrayBuffer(); } @@ -114,6 +123,9 @@ export async function encryptWithPublicKey( /** * Decrypt a public-key encrypted payload with a private key + * @param encryptedPayload Payload to decrypt + * @param privateKey Opaque private key + * @return Decrypted payload */ export async function decryptWithPrivateKey( encryptedPayload: Binary, diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index e619c169e..1e3feee49 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -48,10 +48,12 @@ function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgor return signature; } + // IEEE P1363: r || s where each is padded to key size const halfLen = signature.length / 2; const r = signature.slice(0, halfLen); const s = signature.slice(halfLen); + // Remove leading zeros but keep one if the high bit is set const trimLeadingZeros = (arr: Uint8Array): Uint8Array => { let index = 0; while (index < arr.length - 1 && arr[index] === 0) index++; @@ -61,6 +63,7 @@ function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgor let rTrimmed = trimLeadingZeros(r); let sTrimmed = trimLeadingZeros(s); + // Add leading zero if high bit is set (to keep positive in DER) if (rTrimmed[0] & 0x80) { const padded = new Uint8Array(rTrimmed.length + 1); padded.set(rTrimmed, 1); @@ -72,10 +75,14 @@ function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgor sTrimmed = padded; } + // DER SEQUENCE: 0x30 [length] [r INTEGER] [s INTEGER] + // INTEGER: 0x02 [length] [value] const rDer = new Uint8Array([0x02, rTrimmed.length, ...rTrimmed]); const sDer = new Uint8Array([0x02, sTrimmed.length, ...sTrimmed]); const seqLen = rDer.length + sDer.length; + // DER length: short-form for < 128, long-form (0x81 nn) for 128-255. + // ECDSA sequences never exceed 255 bytes for any supported curve. const lenBytes = seqLen < 128 ? new Uint8Array([seqLen]) : new Uint8Array([0x81, seqLen]); const result = new Uint8Array(1 + lenBytes.length + seqLen); result[0] = 0x30; @@ -95,6 +102,7 @@ function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgor return signature; } + // Determine the expected component length based on algorithm let componentLen: number; switch (algorithm) { case 'ES256': @@ -114,8 +122,10 @@ function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgor throw new ConfigurationError('Invalid DER signature: expected SEQUENCE'); } + // Skip SEQUENCE tag, then parse DER length (short- or long-form). let offset = 1; if (signature[offset] & 0x80) { + // Long-form: low 7 bits = number of subsequent length bytes. const lenBytesCount = signature[offset] & 0x7f; if (lenBytesCount === 0 || lenBytesCount > 4) { throw new ConfigurationError('Invalid DER signature: invalid long-form length'); @@ -125,9 +135,11 @@ function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgor throw new ConfigurationError('Invalid DER signature: length bytes exceed signature length'); } } else { + // Short-form: single length byte. offset += 1; } + // Parse r INTEGER if (signature[offset] !== 0x02) { throw new ConfigurationError('Invalid DER signature: expected INTEGER for r'); } @@ -136,6 +148,7 @@ function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgor let r = signature.slice(offset, offset + rLen); offset += rLen; + // Parse s INTEGER if (signature[offset] !== 0x02) { throw new ConfigurationError('Invalid DER signature: expected INTEGER for s'); } @@ -143,6 +156,7 @@ function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgor offset += 2; let s = signature.slice(offset, offset + sLen); + // Remove leading zero padding if present if (r[0] === 0 && r.length > componentLen) { r = r.slice(1); } @@ -150,6 +164,7 @@ function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgor s = s.slice(1); } + // Pad to component length const result = new Uint8Array(componentLen * 2); result.set(r, componentLen - r.length); result.set(s, componentLen * 2 - s.length); @@ -166,8 +181,14 @@ export async function sign( algorithm: AsymmetricSigningAlgorithm ): Promise { const { signParams } = getSigningAlgorithmParams(algorithm); + + // Unwrap the internal CryptoKey const key = unwrapKey(privateKey); + + // Sign the data const signature = await crypto.subtle.sign(signParams, key, data); + + // Convert from IEEE P1363 to DER for EC algorithms return ieeeP1363ToDer(new Uint8Array(signature), algorithm); } @@ -181,7 +202,13 @@ export async function verify( algorithm: AsymmetricSigningAlgorithm ): Promise { const { signParams } = getSigningAlgorithmParams(algorithm); + + // Unwrap the internal CryptoKey const key = unwrapKey(publicKey); + + // Convert from DER to IEEE P1363 for EC algorithms const ieeeSignature = derToIeeeP1363(signature, algorithm); + + // Verify the signature return crypto.subtle.verify(signParams, key, ieeeSignature, data); } diff --git a/lib/tdf3/src/crypto/core/symmetric.ts b/lib/tdf3/src/crypto/core/symmetric.ts index 013ac8500..d5cd86944 100644 --- a/lib/tdf3/src/crypto/core/symmetric.ts +++ b/lib/tdf3/src/crypto/core/symmetric.ts @@ -32,8 +32,16 @@ export async function randomBytes(byteLength: number): Promise { /** * Returns a promise to the encryption key as a binary string. + * + * Note: This function should almost never fail as it includes a fallback + * if for some reason the native generate key fails. + * + * @param length The key length, defaults to 256 + * + * @returns The hex string. */ export async function randomBytesAsHex(length: number): Promise { + // Create a typed array of the correct length to fill const randomValues = new Uint8Array(length); crypto.getRandomValues(randomValues); return hexEncode(randomValues.buffer); @@ -41,6 +49,11 @@ export async function randomBytesAsHex(length: number): Promise { /** * Decrypt content synchronously + * @param payload The payload to decrypt + * @param key The symmetric encryption key (opaque) + * @param iv The initialization vector + * @param algorithm The algorithm to use for encryption + * @param authTag The authentication tag for authenticated crypto. */ export function decrypt( payload: Binary, @@ -54,6 +67,10 @@ export function decrypt( /** * Encrypt content synchronously + * @param payload The payload to encrypt + * @param key The encryption key + * @param iv The initialization vector + * @param algorithm The algorithm to use for encryption */ export function encrypt( payload: Binary | SymmetricKey, @@ -74,10 +91,13 @@ async function _doEncrypt( console.assert(key != null); console.assert(iv != null); + // Handle both Binary and SymmetricKey payloads let payloadBuffer: BufferSource; if ('_brand' in payload && payload._brand === 'SymmetricKey') { + // Pass Uint8Array directly — Web Crypto respects byteOffset/byteLength on typed array views. payloadBuffer = unwrapSymmetricKey(payload); } else { + // Binary payload payloadBuffer = (payload as Binary).asArrayBuffer(); } @@ -109,6 +129,7 @@ async function _doDecrypt( let payloadBuffer = payload.asArrayBuffer(); + // Concat the the auth tag to the payload for decryption if (authTag) { const authTagBuffer = authTag.asArrayBuffer(); const gcmPayload = new Uint8Array(payloadBuffer.byteLength + authTagBuffer.byteLength); @@ -124,6 +145,7 @@ async function _doDecrypt( const decrypted = await crypto.subtle .decrypt(algoDomString, importedKey, payloadBuffer) + // Catching this error so we can specifically check for OperationError .catch((err) => { if (err.name === 'OperationError') { throw new DecryptError(err); @@ -141,6 +163,8 @@ function _importKey(keyBytes: Uint8Array, algorithm: AesCbcParams | AesGcmParams /** * Get a DOMString representing the algorithm to use for a crypto * operation. Defaults to AES-CBC. + * @param {String|undefined} algorithm + * @return {DOMString} Algorithm to use */ function getSymmetricAlgoDomString( iv: Binary, @@ -159,6 +183,8 @@ function getSymmetricAlgoDomString( /** * Create an ArrayBuffer from a hex string. + * https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String?hl=en + * @param hex - Hex string */ export function hex2Ab(hex: string): ArrayBuffer { const buffer = new ArrayBuffer(hex.length / 2); @@ -203,6 +229,7 @@ export async function hmac(data: Uint8Array, key: SymmetricKey): Promise { return wrapSymmetricKey(keyBytes); @@ -229,6 +257,8 @@ export async function importSymmetricKey(keyBytes: Uint8Array): Promise { const splitBytes = shares.map(unwrapSymmetricKey); From e2ee0cb6c13362edc4181aea4486b92f25c53b25 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Fri, 20 Mar 2026 10:53:46 -0400 Subject: [PATCH 3/3] fix other small changes that occured on move --- lib/tdf3/src/crypto/core/key-format.ts | 37 +++++++++++++------------- lib/tdf3/src/crypto/core/keys.ts | 2 +- lib/tdf3/src/crypto/core/signing.ts | 6 ++--- lib/tdf3/src/crypto/core/symmetric.ts | 34 +++++++---------------- lib/tdf3/src/crypto/index.ts | 24 ++++++++++++++--- 5 files changed, 52 insertions(+), 51 deletions(-) diff --git a/lib/tdf3/src/crypto/core/key-format.ts b/lib/tdf3/src/crypto/core/key-format.ts index d7420f8dc..a8beb736f 100644 --- a/lib/tdf3/src/crypto/core/key-format.ts +++ b/lib/tdf3/src/crypto/core/key-format.ts @@ -128,10 +128,10 @@ export async function parsePublicKeyPem(pem: string): Promise { throw new ConfigurationError(`Unsupported RSA key size: ${modulusBits} bits`); } return { algorithm, pem: publicKeyPem }; - } catch (error) { + } catch (e) { // If it's our own ConfigurationError, rethrow - if (error instanceof ConfigurationError) { - throw error; + if (e instanceof ConfigurationError) { + throw e; } // Not an RSA key, try EC next } @@ -204,20 +204,20 @@ export async function publicKeyPemToJwk(publicKeyPem: string): Promise = { @@ -314,9 +314,8 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis 'P-521': 'ec:secp521r1', }; const mapped = curveMap[namedCurve]; - if (!mapped) { + if (!mapped) throw new ConfigurationError(`Unsupported EC curve in private key: ${namedCurve}`); - } algorithm = mapped; } else { // RSA — determine key size by importing and reading modulus length from JWK diff --git a/lib/tdf3/src/crypto/core/keys.ts b/lib/tdf3/src/crypto/core/keys.ts index 89b320cbb..cc0ff87af 100644 --- a/lib/tdf3/src/crypto/core/keys.ts +++ b/lib/tdf3/src/crypto/core/keys.ts @@ -72,7 +72,7 @@ export function unwrapKey(key: PublicKey | PrivateKey): CryptoKey { export function wrapSymmetricKey(keyBytes: Uint8Array): SymmetricKey { return { _brand: 'SymmetricKey', - length: keyBytes.length * 8, + length: keyBytes.length * 8, // bits _internal: keyBytes, } as SymmetricKey; } diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index 1e3feee49..c3b824f9d 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -55,9 +55,9 @@ function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgor // Remove leading zeros but keep one if the high bit is set const trimLeadingZeros = (arr: Uint8Array): Uint8Array => { - let index = 0; - while (index < arr.length - 1 && arr[index] === 0) index++; - return arr.slice(index); + let i = 0; + while (i < arr.length - 1 && arr[i] === 0) i++; + return arr.slice(i); }; let rTrimmed = trimLeadingZeros(r); diff --git a/lib/tdf3/src/crypto/core/symmetric.ts b/lib/tdf3/src/crypto/core/symmetric.ts index d5cd86944..0f016dea7 100644 --- a/lib/tdf3/src/crypto/core/symmetric.ts +++ b/lib/tdf3/src/crypto/core/symmetric.ts @@ -1,7 +1,6 @@ import { Algorithms, type AlgorithmUrn } from '../../ciphers/algorithms.js'; import { Binary } from '../../binary.js'; import { - type CryptoService, type DecryptResult, type EncryptResult, type HashAlgorithm, @@ -9,7 +8,7 @@ import { } from '../declarations.js'; import { ConfigurationError, DecryptError } from '../../../../src/errors.js'; import { encodeArrayBuffer as hexEncode } from '../../../../src/encodings/hex.js'; -import { keyMerge, keySplit } from '../../utils/keysplit.js'; +import { keyMerge } from '../../utils/keysplit.js'; import { unwrapSymmetricKey, wrapSymmetricKey } from './keys.js'; const ENC_DEC_METHODS: KeyUsage[] = ['encrypt', 'decrypt']; @@ -25,9 +24,9 @@ export async function generateKey(length?: number): Promise { } export async function randomBytes(byteLength: number): Promise { - const randomValues = new Uint8Array(byteLength); - crypto.getRandomValues(randomValues); - return randomValues; + const r = new Uint8Array(byteLength); + crypto.getRandomValues(r); + return r; } /** @@ -42,9 +41,9 @@ export async function randomBytes(byteLength: number): Promise { */ export async function randomBytesAsHex(length: number): Promise { // Create a typed array of the correct length to fill - const randomValues = new Uint8Array(length); - crypto.getRandomValues(randomValues); - return hexEncode(randomValues.buffer); + const r = new Uint8Array(length); + crypto.getRandomValues(r); + return hexEncode(r.buffer); } /** @@ -190,8 +189,8 @@ export function hex2Ab(hex: string): ArrayBuffer { const buffer = new ArrayBuffer(hex.length / 2); const bufferView = new Uint8Array(buffer); - for (let index = 0; index < hex.length; index += 2) { - bufferView[index / 2] = parseInt(hex.substr(index, 2), 16); + for (let i = 0; i < hex.length; i += 2) { + bufferView[i / 2] = parseInt(hex.substr(i, 2), 16); } return buffer; @@ -255,21 +254,6 @@ export async function importSymmetricKey(keyBytes: Uint8Array): Promise { - const keyBytes = unwrapSymmetricKey(key); - const randomService = { randomBytes } as unknown as CryptoService; - const splits = await keySplit(keyBytes, numShares, randomService); - return splits.map(wrapSymmetricKey); -} - /** * Merge symmetric key shares back into the original key using XOR. * Key bytes are extracted internally for merging. diff --git a/lib/tdf3/src/crypto/index.ts b/lib/tdf3/src/crypto/index.ts index aeb794b0b..cb24fa03e 100644 --- a/lib/tdf3/src/crypto/index.ts +++ b/lib/tdf3/src/crypto/index.ts @@ -4,7 +4,7 @@ * @private */ -import { type CryptoService } from './declarations.js'; +import { type CryptoService, type SymmetricKey } from './declarations.js'; import { decrypt, digest, @@ -16,9 +16,10 @@ import { mergeSymmetricKeys, randomBytes, randomBytesAsHex, - splitSymmetricKey, verifyHmac, } from './core/symmetric.js'; +import { keySplit } from '../utils/keysplit.js'; +import { unwrapSymmetricKey, wrapSymmetricKey } from './core/keys.js'; import { decryptWithPrivateKey, encryptWithPublicKey, @@ -45,6 +46,24 @@ export const isSupported = typeof globalThis?.crypto !== 'undefined'; export const method = 'http://www.w3.org/2001/04/xmlenc#aes256-cbc'; export const name = 'BrowserNativeCryptoService'; +/** + * Split a symmetric key into N shares using XOR secret sharing. + * Key bytes are extracted internally for splitting. + * HSM implementations cannot extract bytes and should throw ConfigurationError. + * + * NOTE: This wrapper lives in index.ts (instead of core/symmetric.ts) because it + * needs DefaultCryptoService for keySplit() randomness. Moving it to core/symmetric + * would require importing DefaultCryptoService and create a circular dependency. + */ +export async function splitSymmetricKey( + key: SymmetricKey, + numShares: number +): Promise { + const keyBytes = unwrapSymmetricKey(key); + const splits = await keySplit(keyBytes, numShares, DefaultCryptoService); + return splits.map(wrapSymmetricKey); +} + export { decrypt, decryptWithPrivateKey, @@ -74,7 +93,6 @@ export { rsaOaepSha1, rsaPkcs1Sha256, sign, - splitSymmetricKey, verify, verifyHmac, };