Skip to content

Commit 598c39f

Browse files
authored
feat(sdk): get KASes list from platform when allowedKases list is not passed (#557)
* feat: pass platfromUrl to fetch allowed KASes when allowedKases list is empty * fix: encrypt-decrypt.spec tests * fix: roundtrip test, ignoreAllowList to create OriginAllowList Signed-off-by: Eugene Yakhnenko <[email protected]> * feat: fetchKeyAccessServers to fetch the KAS list * feat: warn users that platformUrl is required for security reasons * test: fix roundtrip test * tests: add more roundtrip tests for coverage --------- Signed-off-by: Eugene Yakhnenko <[email protected]>
1 parent dc4e48e commit 598c39f

File tree

11 files changed

+243
-34
lines changed

11 files changed

+243
-34
lines changed

cli/src/cli.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ export const handleArgs = (args: string[]) => {
379379
})
380380
.option('allowList', {
381381
group: 'Security:',
382-
desc: 'allowed KAS origins, comma separated; defaults to [kasEndpoint]',
382+
desc: 'allowed KAS origins, comma separated; defaults to the list from "/key-access-servers" endpoint',
383383
type: 'string',
384384
validate: (uris: string) => uris.split(','),
385385
})
@@ -614,10 +614,7 @@ export const handleArgs = (args: string[]) => {
614614
},
615615
async (argv) => {
616616
log('DEBUG', 'Running decrypt command');
617-
let allowedKases = argv.allowList?.split(',');
618-
if (!allowedKases) {
619-
allowedKases = argv.kasEndpoint ? [argv.kasEndpoint] : [];
620-
}
617+
const allowedKases = argv.allowList?.split(',');
621618
log('DEBUG', `Allowed KASes: ${allowedKases}`);
622619
const ignoreAllowList = !!argv.ignoreAllowList;
623620
if (!argv.oidcEndpoint) {

lib/src/access.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,54 @@ async function noteInvalidPublicKey(url: URL, r: Promise<CryptoKey>): Promise<Cr
156156
}
157157
}
158158

159+
export async function fetchKeyAccessServers(
160+
platformUrl: string,
161+
authProvider: AuthProvider
162+
): Promise<OriginAllowList> {
163+
let nextOffset = 0;
164+
const allServers = [];
165+
do {
166+
const req = await authProvider.withCreds({
167+
url: `${platformUrl}/policy.kasregistry.KeyAccessServerRegistryService/ListKeyAccessServers`,
168+
method: 'POST',
169+
headers: {
170+
'Content-Type': 'application/json',
171+
},
172+
body: JSON.stringify({
173+
pagination: {
174+
offset: nextOffset,
175+
},
176+
}),
177+
});
178+
let response: Response;
179+
try {
180+
response = await fetch(req.url, {
181+
method: req.method,
182+
headers: req.headers,
183+
body: req.body as BodyInit,
184+
mode: 'cors',
185+
cache: 'no-cache',
186+
credentials: 'same-origin',
187+
redirect: 'follow',
188+
referrerPolicy: 'no-referrer',
189+
});
190+
} catch (e) {
191+
throw new NetworkError(`unable to fetch kas list from [${req.url}]`, e);
192+
}
193+
if (response.ok) {
194+
const { keyAccessServers = [], pagination = {} } = await response.json();
195+
allServers.push(...keyAccessServers);
196+
nextOffset = pagination.nextOffset || 0;
197+
}
198+
} while (nextOffset > 0);
199+
200+
if (!allServers.length) {
201+
throw new ConfigurationError('There are no available KAS');
202+
}
203+
const serverUrls = allServers.map((server) => server.uri);
204+
return new OriginAllowList(serverUrls, false);
205+
}
206+
159207
/**
160208
* If we have KAS url but not public key we can fetch it from KAS, fetching
161209
* the value from `${kas}/kas_public_key`.

lib/src/nanotdf/Client.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import * as base64 from '../encodings/base64.js';
22
import { generateKeyPair, keyAgreement } from '../nanotdf-crypto/index.js';
33
import getHkdfSalt from './helpers/getHkdfSalt.js';
44
import DefaultParams from './models/DefaultParams.js';
5-
import { fetchWrappedKey, KasPublicKeyInfo, OriginAllowList } from '../access.js';
5+
import {
6+
fetchKeyAccessServers,
7+
fetchWrappedKey,
8+
KasPublicKeyInfo,
9+
OriginAllowList,
10+
} from '../access.js';
611
import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js';
712
import { ConfigurationError, DecryptError, TdfError, UnsafeUrlError } from '../errors.js';
813
import { cryptoPublicToPem, pemToCryptoPublicKey, validateSecureUrl } from '../utils.js';
@@ -15,6 +20,7 @@ export interface ClientConfig {
1520
dpopKeys?: Promise<CryptoKeyPair>;
1621
ephemeralKeyPair?: Promise<CryptoKeyPair>;
1722
kasEndpoint: string;
23+
platformUrl: string;
1824
}
1925

2026
function toJWSAlg(c: CryptoKey): string {
@@ -99,12 +105,13 @@ export default class Client {
99105
static readonly INITIAL_RELEASE_IV_SIZE = 3;
100106
static readonly IV_SIZE = 12;
101107

102-
allowedKases: OriginAllowList;
108+
allowedKases?: OriginAllowList;
103109
/*
104110
These variables are expected to be either assigned during initialization or within the methods.
105111
This is needed as the flow is very specific. Errors should be thrown if the necessary step is not completed.
106112
*/
107113
protected kasUrl: string;
114+
readonly platformUrl: string;
108115
kasPubKey?: KasPublicKeyInfo;
109116
readonly authProvider: AuthProvider;
110117
readonly dpopEnabled: boolean;
@@ -150,7 +157,6 @@ export default class Client {
150157
// TODO Disallow http KAS. For now just log as error
151158
validateSecureUrl(kasUrl);
152159
this.kasUrl = kasUrl;
153-
this.allowedKases = new OriginAllowList([kasUrl]);
154160
this.dpopEnabled = dpopEnabled;
155161

156162
if (ephemeralKeyPair) {
@@ -168,12 +174,16 @@ export default class Client {
168174
dpopKeys,
169175
ephemeralKeyPair,
170176
kasEndpoint,
177+
platformUrl,
171178
} = optsOrOldAuthProvider;
172179
this.authProvider = enwrapAuthProvider(authProvider);
173180
// TODO Disallow http KAS. For now just log as error
174181
validateSecureUrl(kasEndpoint);
175182
this.kasUrl = kasEndpoint;
176-
this.allowedKases = new OriginAllowList(allowedKases || [kasEndpoint], !!ignoreAllowList);
183+
this.platformUrl = platformUrl;
184+
if (allowedKases?.length || ignoreAllowList) {
185+
this.allowedKases = new OriginAllowList(allowedKases || [], ignoreAllowList);
186+
}
177187
this.dpopEnabled = !!dpopEnabled;
178188
if (dpopKeys) {
179189
this.requestSignerKeyPair = dpopKeys;
@@ -214,8 +224,14 @@ export default class Client {
214224
magicNumberVersion: ArrayBufferLike,
215225
clientVersion: string
216226
): Promise<CryptoKey> {
217-
if (!this.allowedKases.allows(kasRewrapUrl)) {
218-
throw new UnsafeUrlError(`request URL ∉ ${this.allowedKases.origins};`, kasRewrapUrl);
227+
let allowedKases = this.allowedKases;
228+
229+
if (!allowedKases) {
230+
allowedKases = await fetchKeyAccessServers(this.platformUrl, this.authProvider);
231+
}
232+
233+
if (!allowedKases.allows(kasRewrapUrl)) {
234+
throw new UnsafeUrlError(`request URL ∉ ${allowedKases.origins};`, kasRewrapUrl);
219235
}
220236

221237
const ephemeralKeyPair = await this.ephemeralKeyPair;

lib/src/opentdf.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import {
1313
AssertionConfig,
1414
AssertionVerificationKeys,
1515
} from '../tdf3/src/assertions.js';
16-
import { type KasPublicKeyAlgorithm, OriginAllowList, isPublicKeyAlgorithm } from './access.js';
16+
import {
17+
type KasPublicKeyAlgorithm,
18+
OriginAllowList,
19+
fetchKeyAccessServers,
20+
isPublicKeyAlgorithm,
21+
} from './access.js';
1722
import { type Manifest } from '../tdf3/src/models/manifest.js';
1823
import { type Payload } from '../tdf3/src/models/payload.js';
1924
import {
@@ -87,6 +92,7 @@ export type CreateNanoTDFOptions = CreateOptions & {
8792
};
8893

8994
export type CreateNanoTDFCollectionOptions = CreateNanoTDFOptions & {
95+
platformUrl: string;
9096
// The maximum number of key iterations to use for a single DEK.
9197
maxKeyIterations?: number;
9298
};
@@ -136,6 +142,8 @@ export type CreateZTDFOptions = CreateOptions & {
136142
export type ReadOptions = {
137143
// ciphertext
138144
source: Source;
145+
// Platform URL
146+
platformUrl?: string;
139147
// list of KASes that may be contacted for a rewrap
140148
allowedKASEndpoints?: string[];
141149
// Optionally disable checking the allowlist
@@ -157,6 +165,9 @@ export type OpenTDFOptions = {
157165
// Policy service endpoint
158166
policyEndpoint?: string;
159167

168+
// Platform URL
169+
platformUrl?: string;
170+
160171
// Auth provider for connections to the policy service and KASes.
161172
authProvider: AuthProvider;
162173

@@ -286,6 +297,7 @@ export type TDFReader = {
286297
// SDK for dealing with OpenTDF data and policy services.
287298
export class OpenTDF {
288299
// Configuration service and more is at this URL/connectRPC endpoint
300+
readonly platformUrl: string;
289301
readonly policyEndpoint: string;
290302
readonly authProvider: AuthProvider;
291303
readonly dpopEnabled: boolean;
@@ -305,11 +317,19 @@ export class OpenTDF {
305317
disableDPoP,
306318
policyEndpoint,
307319
rewrapCacheOptions,
320+
platformUrl,
308321
}: OpenTDFOptions) {
309322
this.authProvider = authProvider;
310323
this.defaultCreateOptions = defaultCreateOptions || {};
311324
this.defaultReadOptions = defaultReadOptions || {};
312325
this.dpopEnabled = !!disableDPoP;
326+
if (platformUrl) {
327+
this.platformUrl = platformUrl;
328+
} else {
329+
console.warn(
330+
"Warning: 'platformUrl' is required for security to ensure the SDK uses the platform-configured Key Access Server list"
331+
);
332+
}
313333
this.policyEndpoint = policyEndpoint || '';
314334
this.rewrapCache = new RewrapCache(rewrapCacheOptions);
315335
this.tdf3Client = new TDF3Client({
@@ -333,8 +353,14 @@ export class OpenTDF {
333353
}
334354

335355
async createNanoTDF(opts: CreateNanoTDFOptions): Promise<DecoratedStream> {
336-
opts = { ...this.defaultCreateOptions, ...opts };
337-
const collection = await this.createNanoTDFCollection(opts);
356+
opts = {
357+
...this.defaultCreateOptions,
358+
...opts,
359+
};
360+
const collection = await this.createNanoTDFCollection({
361+
...opts,
362+
platformUrl: this.platformUrl,
363+
});
338364
try {
339365
return await collection.encrypt(opts.source);
340366
} finally {
@@ -415,6 +441,9 @@ class UnknownTypeReader {
415441
this.state = 'resolving';
416442
const chunker = await fromSource(this.opts.source);
417443
const prefix = await chunker(0, 3);
444+
if (!this.opts.platformUrl && this.outer.platformUrl) {
445+
this.opts.platformUrl = this.outer.platformUrl;
446+
}
418447
if (prefix[0] === 0x50 && prefix[1] === 0x4b) {
419448
this.state = 'loaded';
420449
return new ZTDFReader(this.outer.tdf3Client, this.opts, chunker);
@@ -466,6 +495,13 @@ class NanoTDFReader {
466495
readonly chunker: Chunker,
467496
private readonly rewrapCache: RewrapCache
468497
) {
498+
if (
499+
!this.opts.ignoreAllowlist &&
500+
!this.outer.platformUrl &&
501+
!this.opts.allowedKASEndpoints?.length
502+
) {
503+
throw new ConfigurationError('platformUrl is required when allowedKasEndpoints is empty');
504+
}
469505
// lazily load the container
470506
this.container = new Promise(async (resolve, reject) => {
471507
try {
@@ -493,6 +529,7 @@ class NanoTDFReader {
493529
dpopEnabled: this.outer.dpopEnabled,
494530
dpopKeys: this.outer.dpopKeys,
495531
kasEndpoint: this.opts.allowedKASEndpoints?.[0] || 'https://disallow.all.invalid',
532+
platformUrl: this.outer.platformUrl,
496533
});
497534
// TODO: The version number should be fetched from the API
498535
const version = '0.0.1';
@@ -550,17 +587,29 @@ class ZTDFReader {
550587
noVerify: noVerifyAssertions,
551588
wrappingKeyAlgorithm,
552589
} = this.opts;
553-
const allowList = new OriginAllowList(
554-
this.opts.allowedKASEndpoints ?? [],
555-
this.opts.ignoreAllowlist
556-
);
590+
591+
if (!this.opts.ignoreAllowlist && !this.opts.allowedKASEndpoints && !this.opts.platformUrl) {
592+
throw new ConfigurationError('platformUrl is required when allowedKasEndpoints is empty');
593+
}
594+
557595
const dpopKeys = await this.client.dpopKeys;
558596

559597
const { authProvider, cryptoService } = this.client;
560598
if (!authProvider) {
561599
throw new ConfigurationError('authProvider is required');
562600
}
563601

602+
let allowList: OriginAllowList | undefined;
603+
604+
if (this.opts.allowedKASEndpoints?.length || this.opts.ignoreAllowlist) {
605+
allowList = new OriginAllowList(
606+
this.opts.allowedKASEndpoints || [],
607+
this.opts.ignoreAllowlist
608+
);
609+
} else if (this.opts.platformUrl) {
610+
allowList = await fetchKeyAccessServers(this.opts.platformUrl, authProvider);
611+
}
612+
564613
const overview = await this.overview;
565614
const oldStream = await decryptStreamFrom(
566615
{
@@ -646,6 +695,7 @@ class Collection {
646695
authProvider,
647696
kasEndpoint: opts.defaultKASEndpoint ?? 'https://disallow.all.invalid',
648697
maxKeyIterations: opts.maxKeyIterations,
698+
platformUrl: opts.platformUrl,
649699
});
650700
}
651701

0 commit comments

Comments
 (0)