Skip to content

Commit 63721f6

Browse files
fix(nano): Store kid (#334)
* fix(nano): Fixes `url` property when with kid - Adds a several tests for ResourceLocator - Since the unit tests I've added use the hex encoding for ease of reading, this change also updates the hex encoding functions and greatly increases test coverage for them * fix(nano): actually store kid * Update utils.ts * Update access.ts * 🤖 🎨 Autoformat * Update access.ts * Update server.ts * fixes * 🤖 🎨 Autoformat * fix for v1 kas public key fallback --------- Co-authored-by: dmihalcik-virtru <[email protected]> Co-authored-by: Tyler Biscoe <[email protected]>
1 parent fdd3fa7 commit 63721f6

File tree

16 files changed

+275
-84
lines changed

16 files changed

+275
-84
lines changed

lib/src/access.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,61 @@ export async function fetchWrappedKey(
5050
return response.json();
5151
}
5252

53-
export async function fetchECKasPubKey(kasEndpoint: string): Promise<CryptoKey> {
54-
const kasPubKeyResponse = await fetch(`${kasEndpoint}/kas_public_key?algorithm=ec:secp256r1`);
53+
export type KasPublicKeyAlgorithm = 'ec:secp256r1' | 'rsa:2048';
54+
55+
export type KasPublicKeyInfo = {
56+
url: string;
57+
algorithm: KasPublicKeyAlgorithm;
58+
kid?: string;
59+
publicKey: string;
60+
key: Promise<CryptoKey>;
61+
};
62+
63+
/**
64+
* If we have KAS url but not public key we can fetch it from KAS, fetching
65+
* the value from `${kas}/kas_public_key`.
66+
*/
67+
68+
export async function fetchECKasPubKey(kasEndpoint: string): Promise<KasPublicKeyInfo> {
69+
validateSecureUrl(kasEndpoint);
70+
const pkUrlV2 = `${kasEndpoint}/v2/kas_public_key?algorithm=ec:secp256r1&v=2`;
71+
const kasPubKeyResponse = await fetch(pkUrlV2);
5572
if (!kasPubKeyResponse.ok) {
56-
throw new Error(
57-
`Unable to validate KAS [${kasEndpoint}]. Received [${kasPubKeyResponse.status}:${kasPubKeyResponse.statusText}]`
58-
);
73+
if (kasPubKeyResponse.status != 404) {
74+
throw new Error(
75+
`unable to load KAS public key from [${pkUrlV2}]. Received [${kasPubKeyResponse.status}:${kasPubKeyResponse.statusText}]`
76+
);
77+
}
78+
console.log('falling back to v1 key');
79+
// most likely a server that does not implement v2 endpoint, so no key identifier
80+
const pkUrlV1 = `${kasEndpoint}/kas_public_key?algorithm=ec:secp256r1`;
81+
const r2 = await fetch(pkUrlV1);
82+
if (!r2.ok) {
83+
throw new Error(
84+
`unable to load KAS public key from [${pkUrlV1}]. Received [${r2.status}:${r2.statusText}]`
85+
);
86+
}
87+
const pem = await r2.json();
88+
console.log('pem returned', pem);
89+
return {
90+
key: pemToCryptoPublicKey(pem),
91+
publicKey: pem,
92+
url: kasEndpoint,
93+
algorithm: 'ec:secp256r1',
94+
};
95+
}
96+
const jsonContent = await kasPubKeyResponse.json();
97+
const { publicKey, kid }: KasPublicKeyInfo = jsonContent;
98+
if (!publicKey) {
99+
throw new Error(`Invalid response from public key endpoint [${JSON.stringify(jsonContent)}]`);
59100
}
60-
const pem = await kasPubKeyResponse.json();
61-
return pemToCryptoPublicKey(pem);
101+
return {
102+
key: pemToCryptoPublicKey(publicKey),
103+
publicKey,
104+
url: kasEndpoint,
105+
algorithm: 'ec:secp256r1',
106+
...(kid && { kid }),
107+
};
62108
}
63109

64110
const origin = (u: string): string => {

lib/src/encodings/hex.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,51 @@
1+
import { InvalidCharacterError } from './base64.js';
2+
13
export function encode(str: string): string {
24
let hex = '';
35
for (let i = 0; i < str.length; i++) {
4-
hex += `${str.charCodeAt(i).toString(16)}`;
6+
const s = str.charCodeAt(i).toString(16);
7+
if (s.length < 2) {
8+
hex += '0' + s;
9+
} else if (s.length > 2) {
10+
throw new InvalidCharacterError(`invalid input at char ${i} == [${hex.substring(i, i + 1)}]`);
11+
} else {
12+
hex += `${s}`;
13+
}
514
}
615
return hex;
716
}
817

918
export function decode(hex: string): string {
19+
if (hex.length & 1) {
20+
throw new InvalidCharacterError('invalid input.');
21+
}
1022
let str = '';
1123
for (let i = 0; i < hex.length; i += 2) {
12-
str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
24+
const b = parseInt(hex.substring(i, i + 2), 16);
25+
if (isNaN(b)) {
26+
throw new InvalidCharacterError(`invalid input at char ${i} == [${hex.substring(i, i + 2)}]`);
27+
}
28+
str += String.fromCharCode(b);
1329
}
1430
return str;
1531
}
1632

33+
export function decodeArrayBuffer(hex: string): ArrayBuffer | never {
34+
const binLength = hex.length >> 1; // 1 byte per 2 characters
35+
if (hex.length & 1) {
36+
throw new InvalidCharacterError('invalid input.');
37+
}
38+
const bytes = new Uint8Array(binLength);
39+
for (let i = 0; i < hex.length; i += 2) {
40+
const b = parseInt(hex.substring(i, i + 2), 16);
41+
if (isNaN(b)) {
42+
throw new InvalidCharacterError(`invalid input at char ${i} == [${hex.substring(i, i + 2)}]`);
43+
}
44+
bytes[i >> 1] = b;
45+
}
46+
return bytes.buffer;
47+
}
48+
1749
export function encodeArrayBuffer(arrayBuffer: ArrayBuffer): string | never {
1850
if (typeof arrayBuffer !== 'object') {
1951
throw new TypeError('Expected input to be an ArrayBuffer Object');

lib/src/index.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,7 @@ export class NanoTDFClient extends Client {
155155
payloadIV[10] = lengthAsUint24[1];
156156
payloadIV[11] = lengthAsUint24[0];
157157

158-
return encrypt(
159-
policyObjectAsStr,
160-
this.kasPubKey,
161-
this.kasUrl,
162-
ephemeralKeyPair,
163-
payloadIV,
164-
data
165-
);
158+
return encrypt(policyObjectAsStr, this.kasPubKey, ephemeralKeyPair, payloadIV, data);
166159
}
167160
}
168161

@@ -274,14 +267,13 @@ export class NanoTDFDatasetClient extends Client {
274267
// Generate a symmetric key.
275268
this.symmetricKey = await keyAgreement(
276269
ephemeralKeyPair.privateKey,
277-
this.kasPubKey,
270+
await this.kasPubKey.key,
278271
await getHkdfSalt(DefaultParams.magicNumberVersion)
279272
);
280273

281274
const nanoTDFBuffer = await encrypt(
282275
policyObjectAsStr,
283276
this.kasPubKey,
284-
this.kasUrl,
285277
ephemeralKeyPair,
286278
ivVector,
287279
data

lib/src/nanotdf/Client.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as base64 from '../encodings/base64.js';
33
import { generateKeyPair, keyAgreement } from '../nanotdf-crypto/index.js';
44
import getHkdfSalt from './helpers/getHkdfSalt.js';
55
import DefaultParams from './models/DefaultParams.js';
6-
import { fetchWrappedKey, OriginAllowList } from '../access.js';
6+
import { fetchWrappedKey, KasPublicKeyInfo, OriginAllowList } from '../access.js';
77
import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js';
88
import { UnsafeUrlError } from '../errors.js';
99
import { cryptoPublicToPem, pemToCryptoPublicKey, validateSecureUrl } from '../utils.js';
@@ -106,7 +106,7 @@ export default class Client {
106106
This is needed as the flow is very specific. Errors should be thrown if the necessary step is not completed.
107107
*/
108108
protected kasUrl: string;
109-
kasPubKey?: CryptoKey;
109+
kasPubKey?: KasPublicKeyInfo;
110110
readonly authProvider: AuthProvider;
111111
readonly dpopEnabled: boolean;
112112
dissems: string[] = [];
@@ -341,6 +341,7 @@ export default class Client {
341341

342342
return unwrappedKey;
343343
} catch (cause) {
344+
console.error('rewrap fail', cause);
344345
throw new Error('Could not rewrap key with entity object.', { cause });
345346
}
346347
}

lib/src/nanotdf/encrypt.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,20 @@ import {
1515
digest,
1616
exportCryptoKey,
1717
} from '../nanotdf-crypto/index.js';
18+
import { KasPublicKeyInfo } from '../access.js';
1819

1920
/**
2021
* Encrypt the plain data into nanotdf buffer
2122
*
2223
* @param policy Policy that will added to the nanotdf
23-
* @param kasPub
24-
* @param kasUrl KAS url as string or ResourceLocator
24+
* @param kasInfo KAS url and public key data
2525
* @param ephemeralKeyPair SDK ephemeral key pair to generate symmetric key
2626
* @param iv
2727
* @param data The data to be encrypted
2828
*/
2929
export default async function encrypt(
3030
policy: string,
31-
kasPub: CryptoKey,
32-
kasUrl: string | ResourceLocator,
31+
kasInfo: KasPublicKeyInfo,
3332
ephemeralKeyPair: CryptoKeyPair,
3433
iv: Uint8Array,
3534
data: string | TypedArray | ArrayBuffer
@@ -40,17 +39,17 @@ export default async function encrypt(
4039
}
4140
const symmetricKey = await keyAgreement(
4241
ephemeralKeyPair.privateKey,
43-
kasPub,
42+
await kasInfo.key,
4443
// Get the hkdf salt params
4544
await getHkdfSalt(DefaultParams.magicNumberVersion)
4645
);
4746

4847
// Construct the kas locator
4948
let kasResourceLocator;
50-
if (kasUrl instanceof ResourceLocator) {
51-
kasResourceLocator = kasUrl;
49+
if (kasInfo.kid) {
50+
kasResourceLocator = ResourceLocator.parse(kasInfo.url, kasInfo.kid);
5251
} else {
53-
kasResourceLocator = ResourceLocator.parse(kasUrl);
52+
kasResourceLocator = ResourceLocator.parse(kasInfo.url);
5453
}
5554

5655
// Auth tag length for policy and payload

lib/src/nanotdf/models/ResourceLocator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default class ResourceLocator {
4747
protocolIdentifierByte[0] = 0x01;
4848
break;
4949
default:
50-
throw new Error('Resource locator protocol is not supported.');
50+
throw new Error('resource locator protocol unsupported');
5151
}
5252

5353
// Set identifier padded length and protocol identifier byte
@@ -149,7 +149,7 @@ export default class ResourceLocator {
149149
}
150150

151151
get url(): string | never {
152-
switch (this.protocol) {
152+
switch (this.protocol & 0xf) {
153153
case ProtocolEnum.Http:
154154
return 'http://' + this.body;
155155
case ProtocolEnum.Https:

lib/src/tdf/AttributeObject.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cryptoPublicToPem } from '../utils.js';
1+
import { type KasPublicKeyInfo } from '../access.js';
22

33
export interface AttributeObject {
44
readonly attribute: string;
@@ -13,14 +13,14 @@ export interface AttributeObject {
1313

1414
export async function createAttribute(
1515
attribute: string,
16-
pubKey: CryptoKey,
16+
pubKey: KasPublicKeyInfo,
1717
kasUrl: string
1818
): Promise<AttributeObject> {
1919
return {
2020
attribute,
2121
isDefault: false,
2222
displayName: '',
23-
pubKey: await cryptoPublicToPem(pubKey),
23+
pubKey: pubKey.publicKey,
2424
kasUrl,
2525
schemaVersion: '1.1.0',
2626
};

lib/src/utils.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { type AxiosResponseHeaders, type RawAxiosResponseHeaders } from 'axios';
2+
import { exportSPKI, importX509 } from 'jose';
3+
24
import { base64 } from './encodings/index.js';
35
import { pemCertToCrypto, pemPublicToCrypto } from './nanotdf-crypto/index.js';
46

@@ -126,5 +128,18 @@ export async function pemToCryptoPublicKey(pem: string): Promise<CryptoKey> {
126128
} else if (/-----BEGIN CERTIFICATE-----/.test(pem)) {
127129
return pemCertToCrypto(pem);
128130
}
129-
throw new Error('unsupported pem type');
131+
throw new Error(`unsupported pem type [${pem}]`);
132+
}
133+
134+
export async function extractPemFromKeyString(keyString: string): Promise<string> {
135+
let pem: string = keyString;
136+
137+
// Skip the public key extraction if we find that the KAS url provides a
138+
// PEM-encoded key instead of certificate
139+
if (keyString.includes('CERTIFICATE')) {
140+
const cert = await importX509(keyString, 'RS256', { extractable: true });
141+
pem = await exportSPKI(cert);
142+
}
143+
144+
return pem;
130145
}

lib/tdf3/src/client/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
buildKeyAccess,
1515
EncryptConfiguration,
1616
fetchKasPublicKey,
17-
KasPublicKeyInfo,
1817
unwrapHtml,
1918
validatePolicyObject,
2019
readStream,
@@ -31,7 +30,7 @@ import {
3130
withHeaders,
3231
} from '../../../src/auth/auth.js';
3332
import EAS from '../../../src/auth/Eas.js';
34-
import { cryptoPublicToPem, validateSecureUrl } from '../../../src/utils.js';
33+
import { cryptoPublicToPem, pemToCryptoPublicKey, validateSecureUrl } from '../../../src/utils.js';
3534

3635
import {
3736
EncryptParams,
@@ -50,7 +49,7 @@ import {
5049
type DecryptSource,
5150
EncryptParamsBuilder,
5251
} from './builders.js';
53-
import { OriginAllowList } from '../../../src/access.js';
52+
import { KasPublicKeyInfo, OriginAllowList } from '../../../src/access.js';
5453
import { TdfError } from '../../../src/errors.js';
5554
import { EntityObject } from '../../../src/tdf/EntityObject.js';
5655
import { Binary } from '../binary.js';
@@ -337,6 +336,7 @@ export class Client {
337336
this.kasKeys[this.kasEndpoint] = Promise.resolve({
338337
url: this.kasEndpoint,
339338
algorithm: 'rsa:2048',
339+
key: pemToCryptoPublicKey(clientConfig.kasPublicKey),
340340
publicKey: clientConfig.kasPublicKey,
341341
});
342342
}

0 commit comments

Comments
 (0)