diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index dc1df0c49..8398ae5e9 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -8,7 +8,14 @@ import { } from '../access.js'; import { type AuthProvider } from '../auth/auth.js'; -import { ConfigurationError, NetworkError } from '../errors.js'; +import { + ConfigurationError, + InvalidFileError, + NetworkError, + PermissionDeniedError, + ServiceError, + UnauthenticatedError, +} from '../errors.js'; import { PlatformClient } from '../platform.js'; import { RewrapResponse } from '../platform/kas/kas_pb.js'; import { ListKeyAccessServersResponse } from '../platform/policy/kasregistry/key_access_server_registry_pb.js'; @@ -19,6 +26,7 @@ import { validateSecureUrl, } from '../utils.js'; import { X_REWRAP_ADDITIONAL_CONTEXT } from './constants.js'; +import { ConnectError, Code } from '@connectrpc/connect'; /** * Get a rewrapped access key to the document, if possible @@ -42,11 +50,66 @@ export async function fetchWrappedKey( [X_REWRAP_ADDITIONAL_CONTEXT]: rewrapAdditionalContextHeader, }; } + let response: RewrapResponse; try { - return await platform.v1.access.rewrap({ signedRequestToken }, options); + response = await platform.v1.access.rewrap({ signedRequestToken }, options); } catch (e) { - throw new NetworkError(`[${platformUrl}] [Rewrap] ${extractRpcErrorMessage(e)}`); + handleRpcRewrapError(e, platformUrl); + } + return response; +} + +export function handleRpcRewrapError(e: unknown, platformUrl: string): never { + if (e instanceof ConnectError) { + console.log('Error is a ConnectError with code:', e.code); + switch (e.code) { + case Code.InvalidArgument: // 400 Bad Request + throw new InvalidFileError(`400 for [${platformUrl}]: rewrap bad request [${e.message}]`); + case Code.PermissionDenied: // 403 Forbidden + throw new PermissionDeniedError(`403 for [${platformUrl}]; rewrap permission denied`); + case Code.Unauthenticated: // 401 Unauthorized + throw new UnauthenticatedError(`401 for [${platformUrl}]; rewrap auth failure`); + case Code.Internal: + case Code.Unimplemented: + case Code.DataLoss: + case Code.Unknown: + case Code.DeadlineExceeded: + case Code.Unavailable: // >=500 Server Error + throw new ServiceError( + `${e.code} for [${platformUrl}]: rewrap failure due to service error [${e.message}]` + ); + default: + throw new NetworkError(`[${platformUrl}] [Rewrap] ${e.message}`); + } + } + throw new NetworkError(`[${platformUrl}] [Rewrap] ${extractRpcErrorMessage(e)}`); +} + +export function handleRpcRewrapErrorString(e: string, platformUrl: string): never { + if (e.includes(Code[Code.InvalidArgument])) { + // 400 Bad Request + throw new InvalidFileError(`400 for [${platformUrl}]: rewrap bad request [${e}]`); + } + if (e.includes(Code[Code.PermissionDenied])) { + // 403 Forbidden + throw new PermissionDeniedError(`403 for [${platformUrl}]; rewrap permission denied`); + } + if (e.includes(Code[Code.Unauthenticated])) { + // 401 Unauthorized + throw new UnauthenticatedError(`401 for [${platformUrl}]; rewrap auth failure`); + } + if ( + e.includes(Code[Code.Internal]) || + e.includes(Code[Code.Unimplemented]) || + e.includes(Code[Code.DataLoss]) || + e.includes(Code[Code.Unknown]) || + e.includes(Code[Code.DeadlineExceeded]) || + e.includes(Code[Code.Unavailable]) + ) { + // >=500 + throw new ServiceError(`500+ [${platformUrl}]: rewrap failure due to service error [${e}]`); } + throw new NetworkError(`[${platformUrl}] [Rewrap] ${e}`); } export async function fetchKeyAccessServers( diff --git a/lib/src/nanotdf/Client.ts b/lib/src/nanotdf/Client.ts index 771666d58..230b694f5 100644 --- a/lib/src/nanotdf/Client.ts +++ b/lib/src/nanotdf/Client.ts @@ -1,4 +1,8 @@ -import * as base64 from '../encodings/base64.js'; +import { create, toJsonString } from '@bufbuild/protobuf'; +import { + UnsignedRewrapRequest_WithPolicyRequestSchema, + UnsignedRewrapRequestSchema, +} from '../platform/kas/kas_pb.js'; import { generateKeyPair, keyAgreement } from '../nanotdf-crypto/index.js'; import getHkdfSalt from './helpers/getHkdfSalt.js'; import DefaultParams from './models/DefaultParams.js'; @@ -8,13 +12,16 @@ import { KasPublicKeyInfo, OriginAllowList, } from '../access.js'; +import { handleRpcRewrapErrorString } from '../../src/access/access-rpc.js'; import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js'; import { ConfigurationError, DecryptError, TdfError, UnsafeUrlError } from '../errors.js'; import { cryptoPublicToPem, getRequiredObligationFQNs, pemToCryptoPublicKey, + upgradeRewrapResponseV1, validateSecureUrl, + getPlatformUrlFromKasEndpoint, } from '../utils.js'; export interface ClientConfig { @@ -260,18 +267,35 @@ export default class Client { throw new ConfigurationError('Signer key has not been set or generated'); } - const requestBodyStr = JSON.stringify({ - algorithm: DefaultParams.defaultECAlgorithm, - // nano keyAccess minimum, header is used for nano + const unsignedRequest = create(UnsignedRewrapRequestSchema, { + clientPublicKey: await cryptoPublicToPem(ephemeralKeyPair.publicKey), + requests: [ + create(UnsignedRewrapRequest_WithPolicyRequestSchema, { + keyAccessObjects: [ + { + keyAccessObjectId: 'kao-0', + keyAccessObject: { + header: new Uint8Array(nanoTdfHeader), + kasUrl: '', + protocol: Client.KAS_PROTOCOL, + keyType: Client.KEY_ACCESS_REMOTE, + }, + }, + ], + algorithm: DefaultParams.defaultECAlgorithm, + }), + ], keyAccess: { - type: Client.KEY_ACCESS_REMOTE, - url: '', + header: new Uint8Array(nanoTdfHeader), + kasUrl: '', protocol: Client.KAS_PROTOCOL, - header: base64.encodeArrayBuffer(nanoTdfHeader), + keyType: Client.KEY_ACCESS_REMOTE, }, - clientPublicKey: await cryptoPublicToPem(ephemeralKeyPair.publicKey), + algorithm: DefaultParams.defaultECAlgorithm, }); + const requestBodyStr = toJsonString(UnsignedRewrapRequestSchema, unsignedRequest); + const jwtPayload = { requestBody: requestBodyStr }; const signedRequestToken = await reqSignature(jwtPayload, requestSignerKeyPair.privateKey, { @@ -285,9 +309,28 @@ export default class Client { this.authProvider, this.fulfillableObligationFQNs ); + upgradeRewrapResponseV1(rewrapResp); + + // Assume only one response and one result for now (V1 style) + const result = rewrapResp.responses[0].results[0]; + let entityWrappedKey: Uint8Array; + switch (result.result.case) { + case 'kasWrappedKey': { + entityWrappedKey = result.result.value; + break; + } + case 'error': { + handleRpcRewrapErrorString( + result.result.value, + getPlatformUrlFromKasEndpoint(kasRewrapUrl) + ); + } + default: { + throw new DecryptError('KAS rewrap response missing wrapped key'); + } + } // Extract the iv and ciphertext - const entityWrappedKey = rewrapResp.entityWrappedKey; const ivLength = clientVersion == Client.SDK_INITIAL_RELEASE ? Client.INITIAL_RELEASE_IV_SIZE : Client.IV_SIZE; const iv = entityWrappedKey.subarray(0, ivLength); diff --git a/lib/src/utils.ts b/lib/src/utils.ts index 16434806c..136b8c9e9 100644 --- a/lib/src/utils.ts +++ b/lib/src/utils.ts @@ -3,7 +3,12 @@ import { exportSPKI, importX509 } from 'jose'; import { base64 } from './encodings/index.js'; import { pemCertToCrypto, pemPublicToCrypto } from './nanotdf-crypto/pemPublicToCrypto.js'; import { ConfigurationError } from './errors.js'; -import { RewrapResponse } from './platform/kas/kas_pb.js'; +import { + RewrapResponse, + PolicyRewrapResultSchema, + KeyAccessRewrapResultSchema, +} from './platform/kas/kas_pb.js'; +import { create } from '@bufbuild/protobuf'; import { ConnectError } from '@connectrpc/connect'; const REQUIRED_OBLIGATIONS_METADATA_KEY = 'X-Required-Obligations'; @@ -255,3 +260,31 @@ export function getRequiredObligationFQNs(response: RewrapResponse) { return [...requiredObligations.values()]; } + +/** + * Upgrades a RewrapResponse from v1 format to v2. + */ +export function upgradeRewrapResponseV1(response: RewrapResponse) { + if (response.responses.length > 0) { + return; + } + if (response.entityWrappedKey.length === 0) { + return; + } + + response.responses = [ + create(PolicyRewrapResultSchema, { + policyId: 'policy', + results: [ + create(KeyAccessRewrapResultSchema, { + keyAccessObjectId: 'kao-0', + status: 'permit', + result: { + case: 'kasWrappedKey', + value: response.entityWrappedKey, + }, + }), + ], + }), + ]; +} diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 937f0f44c..8bf7477e9 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -8,7 +8,16 @@ import { fetchWrappedKey, publicKeyAlgorithmToJwa, } from '../../src/access.js'; +import { create, toJsonString } from '@bufbuild/protobuf'; +import { + KeyAccessSchema, + UnsignedRewrapRequestSchema, + UnsignedRewrapRequest_WithPolicyRequestSchema, + UnsignedRewrapRequest_WithPolicySchema, + UnsignedRewrapRequest_WithKeyAccessObjectSchema, +} from '../../src/platform/kas/kas_pb.js'; import { type AuthProvider, reqSignature } from '../../src/auth/auth.js'; +import { handleRpcRewrapErrorString } from '../../src/access/access-rpc.js'; import { allPool, anyPool } from '../../src/concurrency.js'; import { base64, hex } from '../../src/encodings/index.js'; import { @@ -55,7 +64,11 @@ import { ZipReader, ZipWriter, keyMerge, concatUint8, buffToString } from './uti import { CentralDirectory } from './utils/zip-reader.js'; import { ztdfSalt } from './crypto/salt.js'; import { Payload } from './models/payload.js'; -import { getRequiredObligationFQNs } from '../../src/utils.js'; +import { + getRequiredObligationFQNs, + upgradeRewrapResponseV1, + getPlatformUrlFromKasEndpoint, +} from '../../src/utils.js'; // TODO: input validation on manifest JSON const DEFAULT_SEGMENT_SIZE = 1024 * 1024; @@ -189,11 +202,6 @@ export type RewrapRequest = { export type KasPublicKeyFormat = 'pkcs8' | 'jwks'; -export type RewrapResponse = { - entityWrappedKey: string; - sessionPublicKey: string; -}; - /** * If we have KAS url but not public key we can fetch it from KAS, fetching * the value from `${kas}/kas_public_key`. @@ -783,13 +791,50 @@ async function unwrapKey({ const clientPublicKey = ephemeralEncryptionKeys.publicKey; - const requestBodyStr = JSON.stringify({ + // Convert keySplitInfo to protobuf KeyAccess + const keyAccessProto = create(KeyAccessSchema, { + ...(keySplitInfo.type && { keyType: keySplitInfo.type }), + ...(keySplitInfo.url && { kasUrl: keySplitInfo.url }), + ...(keySplitInfo.protocol && { protocol: keySplitInfo.protocol }), + ...(keySplitInfo.wrappedKey && { + wrappedKey: new Uint8Array(base64.decodeArrayBuffer(keySplitInfo.wrappedKey)), + }), + ...(keySplitInfo.policyBinding && { policyBinding: keySplitInfo.policyBinding }), + ...(keySplitInfo.kid && { kid: keySplitInfo.kid }), + ...(keySplitInfo.sid && { splitId: keySplitInfo.sid }), + ...(keySplitInfo.encryptedMetadata && { encryptedMetadata: keySplitInfo.encryptedMetadata }), + ...(keySplitInfo.ephemeralPublicKey && { + ephemeralPublicKey: keySplitInfo.ephemeralPublicKey, + }), + }); + + // Create the protobuf request + const unsignedRequest = create(UnsignedRewrapRequestSchema, { + clientPublicKey, + requests: [ + create(UnsignedRewrapRequest_WithPolicyRequestSchema, { + keyAccessObjects: [ + create(UnsignedRewrapRequest_WithKeyAccessObjectSchema, { + keyAccessObjectId: 'kao-0', + keyAccessObject: keyAccessProto, + }), + ], + ...(manifest.encryptionInformation.policy && { + policy: create(UnsignedRewrapRequest_WithPolicySchema, { + id: 'policy', + body: manifest.encryptionInformation.policy, + }), + }), + }), + ], + // include deprecated fields for backward compatibility algorithm: 'RS256', - keyAccess: keySplitInfo, + keyAccess: keyAccessProto, policy: manifest.encryptionInformation.policy, - clientPublicKey, }); + const requestBodyStr = toJsonString(UnsignedRewrapRequestSchema, unsignedRequest); + const jwtPayload = { requestBody: requestBodyStr }; const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey); @@ -799,39 +844,57 @@ async function unwrapKey({ authProvider, fulfillableObligations ); - const { entityWrappedKey, metadata, sessionPublicKey } = rewrapResp; + upgradeRewrapResponseV1(rewrapResp); + const { sessionPublicKey } = rewrapResp; const requiredObligations = getRequiredObligationFQNs(rewrapResp); + // Assume only one response and one result for now (V1 style) + const result = rewrapResp.responses[0].results[0]; + const metadata = result.metadata; + // Handle the different cases of result.result + switch (result.result.case) { + case 'kasWrappedKey': { + const entityWrappedKey = result.result.value; + + if (wrappingKeyAlgorithm === 'ec:secp256r1') { + const serverEphemeralKey: CryptoKey = await pemPublicToCrypto(sessionPublicKey); + const ekr = ephemeralEncryptionKeysRaw as CryptoKeyPair; + const kek = await keyAgreement(ekr.privateKey, serverEphemeralKey, { + hkdfSalt: await ztdfSalt, + hkdfHash: 'SHA-256', + }); + const wrappedKeyAndNonce = entityWrappedKey; + const iv = wrappedKeyAndNonce.slice(0, 12); + const wrappedKey = wrappedKeyAndNonce.slice(12); - if (wrappingKeyAlgorithm === 'ec:secp256r1') { - const serverEphemeralKey: CryptoKey = await pemPublicToCrypto(sessionPublicKey); - const ekr = ephemeralEncryptionKeysRaw as CryptoKeyPair; - const kek = await keyAgreement(ekr.privateKey, serverEphemeralKey, { - hkdfSalt: await ztdfSalt, - hkdfHash: 'SHA-256', - }); - const wrappedKeyAndNonce = entityWrappedKey; - const iv = wrappedKeyAndNonce.slice(0, 12); - const wrappedKey = wrappedKeyAndNonce.slice(12); - - const dek = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey); - - return { - key: new Uint8Array(dek), - metadata, - requiredObligations, - }; - } - const key = Binary.fromArrayBuffer(entityWrappedKey); - const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey( - key, - ephemeralEncryptionKeys.privateKey - ); + const dek = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey); - return { - key: new Uint8Array(decryptedKeyBinary.asByteArray()), - metadata, - requiredObligations, - }; + return { + key: new Uint8Array(dek), + metadata, + requiredObligations, + }; + } + const key = Binary.fromArrayBuffer(entityWrappedKey); + const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey( + key, + ephemeralEncryptionKeys.privateKey + ); + + return { + key: new Uint8Array(decryptedKeyBinary.asByteArray()), + metadata, + requiredObligations, + }; + } + + case 'error': { + handleRpcRewrapErrorString(result.result.value, getPlatformUrlFromKasEndpoint(url)); + } + + default: { + throw new DecryptError('KAS rewrap response missing wrapped key'); + } + } } let poolSize = 1; diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 4bd79c6ec..c88519ab8 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -9,11 +9,19 @@ import { keyAgreement, pemPublicToCrypto } from '../src/nanotdf-crypto/index.js' import { generateRandomNumber } from '../src/nanotdf-crypto/generateRandomNumber.js'; import { removePemFormatting } from '../tdf3/src/crypto/crypto-utils.js'; import { Binary } from '../tdf3/index.js'; -import { type KeyAccessObject } from '../tdf3/src/models/index.js'; import { valueFor } from './web/policy/mock-attrs.js'; import { AttributeAndValue } from '../src/policy/attributes.js'; import { ztdfSalt } from '../tdf3/src/crypto/salt.js'; +import { create, toJsonString, fromJson } from '@bufbuild/protobuf'; +import { ValueSchema } from '@bufbuild/protobuf/wkt'; +import { + PolicyRewrapResultSchema, + KeyAccessRewrapResultSchema, + RewrapResponseSchema, + UnsignedRewrapRequestSchema, +} from '../src/platform/kas/kas_pb.js'; + const Mocks = getMocks(); function range(start: number, end: number): Uint8Array { @@ -24,18 +32,6 @@ function range(start: number, end: number): Uint8Array { return new Uint8Array(result); } -type RewrapBody = { - algorithm: 'RS256' | 'ec:secp256r1'; - keyAccess: KeyAccessObject & { - header?: string; - }; - policy: string; - clientPublicKey: string; - // testing only - invalidKey: string; - invalidField: string; -}; - function concat(b: ArrayBufferView[]) { const length = b.reduce((lk, ak) => lk + ak.byteLength, 0); const buf = new Uint8Array(length); @@ -164,7 +160,7 @@ const kas: RequestListener = async (req, res) => { return; } - const rewrap = JSON.parse(requestBody as string) as RewrapBody; + const rewrap = fromJson(UnsignedRewrapRequestSchema, JSON.parse(requestBody as string)); console.log('[INFO]: rewrap request body: ', rewrap); const clientPublicKey = await pemPublicToCrypto(rewrap.clientPublicKey); if (!clientPublicKey || clientPublicKey.type !== 'public') { @@ -172,25 +168,27 @@ const kas: RequestListener = async (req, res) => { res.end('{"error": "Invalid client public key"}'); return; } - const isZTDF = !rewrap.keyAccess.header; + const kaoheader = rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObject?.header; + const isZTDF = !kaoheader || kaoheader.length === 0; if (isZTDF) { - if (!rewrap.keyAccess.wrappedKey) { + const wk = rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObject?.wrappedKey; + if (!wk || wk.length === 0) { res.writeHead(400); res.end('{"error": "Invalid wrapped key"}'); return; } - const wk = base64.decodeArrayBuffer(rewrap.keyAccess.wrappedKey); - const isECWrapped = rewrap.keyAccess.kid == 'e1'; + const isECWrapped = + rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObject?.kid == 'e1'; // Decrypt the wrapped key from TDF3 let dek: Binary; if (isECWrapped) { - if (!rewrap.keyAccess.ephemeralPublicKey) { + if (!rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObject?.ephemeralPublicKey) { res.writeHead(400); res.end('{"error": "Nil ephemeral public key"}'); return; } const ephemeralKey: CryptoKey = await pemPublicToCrypto( - rewrap.keyAccess.ephemeralPublicKey + rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObject?.ephemeralPublicKey ); const kasPrivateKeyBytes = base64.decodeArrayBuffer( removePemFormatting(Mocks.kasECPrivateKey) @@ -215,13 +213,30 @@ const kas: RequestListener = async (req, res) => { } if (clientPublicKey.algorithm.name == 'RSA-OAEP') { const cek = await encryptWithPublicKey(dek, rewrap.clientPublicKey); - const reply = { - entityWrappedKey: base64.encodeArrayBuffer(cek.asArrayBuffer()), - metadata: { hello: 'world' }, - }; + const reply = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + hello: create(ValueSchema, { + kind: { case: 'stringValue', value: 'world' }, + }), + }, + result: { + case: 'kasWrappedKey', + value: new Uint8Array(cek.asArrayBuffer()), + }, + keyAccessObjectId: + rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObjectId || '', + }), + ], + }), + ], + }); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(reply)); + res.end(toJsonString(RewrapResponseSchema, reply)); return; } const sessionKeyPair = await crypto.subtle.generateKey( @@ -241,20 +256,36 @@ const kas: RequestListener = async (req, res) => { const entityWrappedKey = new Uint8Array(iv.length + cek.byteLength); entityWrappedKey.set(iv); entityWrappedKey.set(new Uint8Array(cek), iv.length); - const reply = { - entityWrappedKey: base64.encodeArrayBuffer(entityWrappedKey), - metadata: { hello: 'world' }, - }; + const reply = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + hello: create(ValueSchema, { + kind: { case: 'stringValue', value: 'world' }, + }), + }, + result: { + case: 'kasWrappedKey', + value: entityWrappedKey, + }, + keyAccessObjectId: + rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObjectId || '', + }), + ], + }), + ], + }); + res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(reply)); + res.end(toJsonString(RewrapResponseSchema, reply)); return; } // nanotdf console.log('[INFO] nano rewrap request body: ', rewrap); - const { header } = Header.parse( - new Uint8Array(base64.decodeArrayBuffer(rewrap?.keyAccess?.header || '')) - ); + const { header } = Header.parse(kaoheader || new Uint8Array(base64.decodeArrayBuffer(''))); // TODO convert header.ephemeralCurveName to namedCurve const nanoPublicKey = await crypto.subtle.importKey( 'raw', @@ -309,14 +340,32 @@ const kas: RequestListener = async (req, res) => { const entityWrappedKey = new Uint8Array(iv.length + cekBytes.length); entityWrappedKey.set(iv); entityWrappedKey.set(cekBytes, iv.length); - const reply = { - entityWrappedKey: base64.encodeArrayBuffer(entityWrappedKey), + const reply = create(RewrapResponseSchema, { sessionPublicKey: Mocks.kasECCert, - metadata: { hello: 'people of earth' }, - }; + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + hello: create(ValueSchema, { + kind: { case: 'stringValue', value: 'people of earth' }, + }), + }, + result: { + case: 'kasWrappedKey', + value: entityWrappedKey, + }, + keyAccessObjectId: + rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObjectId || '', + }), + ], + }), + ], + }); + res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(reply)); + res.end(toJsonString(RewrapResponseSchema, reply)); return; } else if (url.pathname === '/file') { if (req.method !== 'GET') {