Skip to content

Commit 28eb417

Browse files
authored
feat(sdk): Pull kas keys from definitions and namespaces. (#808)
* feat(sdk): Pull kas keys from definitions and namespaces. Signed-off-by: Chris Reed <creed@virtru.com> * tests. Signed-off-by: Chris Reed <creed@virtru.com> * refactor. Signed-off-by: Chris Reed <creed@virtru.com> * 🤖 🎨 Autoformat Signed-off-by: Chris Reed <creed@virtru.com> --------- Signed-off-by: Chris Reed <creed@virtru.com>
1 parent be7e4ae commit 28eb417

7 files changed

Lines changed: 511 additions & 17 deletions

File tree

‎lib/src/policy/api.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Value } from './attributes.js';
66
import { GetAttributeValuesByFqnsResponse } from '../platform/policy/attributes/attributes_pb.js';
77
import { GetNamespaceResponse } from '../platform/policy/namespaces/namespaces_pb.js';
88
import { Certificate } from '../platform/policy/objects_pb.js';
9+
import { create } from '@bufbuild/protobuf';
10+
import { ValueSchema } from '../platform/policy/objects_pb.js';
911

1012
// TODO KAS: go over web-sdk and remove policyEndpoint that is only defined to be used here
1113
export async function attributeFQNsAsValues(
@@ -29,12 +31,16 @@ export async function attributeFQNsAsValues(
2931

3032
const values: Value[] = [];
3133
for (const [fqn, av] of Object.entries(response.fqnAttributeValues)) {
32-
const value = av.value;
34+
let value = av.value;
3335
if (!value) {
34-
console.log(`Missing value definition for [${fqn}]; is this a valid attribute?`);
35-
continue;
36-
}
37-
if (value && av.attribute && !value?.attribute) {
36+
if (!av.attribute) {
37+
console.warn(`Missing attribute definition for [${fqn}]; is this a valid attribute?`);
38+
continue;
39+
}
40+
console.warn(`Missing value definition for [${fqn}]; using attribute definition only.`);
41+
42+
value = create(ValueSchema, { attribute: av.attribute, fqn });
43+
} else if (av.attribute && !value?.attribute) {
3844
value.attribute = av.attribute;
3945
}
4046

‎lib/src/policy/granter.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ConfigurationError } from '../errors.js';
22
import { Attribute, AttributeRuleType, KeyAccessServer, Value } from './attributes.js';
33
import { SimpleKasPublicKey } from '../platform/policy/objects_pb.js';
4+
import { effectiveKasKeys } from './kas-keys.js';
45

56
type KeyHolder = KeyAccessServer | (SimpleKasPublicKey & { kasUri: string });
67

@@ -60,6 +61,20 @@ export function plan(dataAttrs: Value[]): KeySplitStep[] {
6061
// Values grouped by normalized attribute prefix
6162
const allClauses: Record<string, AttributeClause> = Object.create(null);
6263

64+
const toKeyHolders = (keys?: Value['kasKeys']): KeyHolder[] => {
65+
if (!keys?.length) {
66+
return [];
67+
}
68+
return keys
69+
.map((kasKey) => {
70+
if (!kasKey.publicKey) {
71+
return null;
72+
}
73+
return Object.assign({ kasUri: kasKey.kasUri }, kasKey.publicKey);
74+
})
75+
.filter((kasKey) => kasKey !== null);
76+
};
77+
6378
const addGrants = (valueFQN: string, gs?: KeyHolder[]): boolean => {
6479
if (!(valueFQN in granters)) {
6580
granters[valueFQN] = new Set();
@@ -74,7 +89,7 @@ export function plan(dataAttrs: Value[]): KeySplitStep[] {
7489
};
7590

7691
for (const v of dataAttrs) {
77-
const { attribute, fqn, kasKeys } = v;
92+
const { attribute, fqn } = v;
7893
if (!attribute) {
7994
throw new ConfigurationError(`attribute not defined for [${fqn}]`);
8095
}
@@ -87,16 +102,10 @@ export function plan(dataAttrs: Value[]): KeySplitStep[] {
87102
};
88103
}
89104
allClauses[attrFqn].values.push(valFqn);
90-
const validKasKeys = kasKeys
91-
.map((kasKey) => {
92-
if (!kasKey.publicKey) {
93-
return null;
94-
}
95-
return Object.assign({ kasUri: kasKey.kasUri }, kasKey.publicKey);
96-
})
97-
.filter((kasKey) => kasKey !== null);
98-
if (validKasKeys.length) {
99-
addGrants(valFqn, validKasKeys);
105+
// Prioritize key mappings over grants
106+
const kasKeyHolders = toKeyHolders(effectiveKasKeys(v));
107+
if (kasKeyHolders.length) {
108+
addGrants(valFqn, kasKeyHolders);
100109
} else if (!addGrants(valFqn, v.grants)) {
101110
if (!addGrants(valFqn, attribute.grants)) {
102111
addGrants(valFqn, attribute.namespace?.grants);

‎lib/src/policy/kas-keys.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Value } from './attributes.js';
2+
3+
export function effectiveKasKeys(value: Value): Value['kasKeys'] {
4+
if (value.kasKeys.length) {
5+
return value.kasKeys;
6+
}
7+
if (value.attribute?.kasKeys?.length) {
8+
return value.attribute.kasKeys;
9+
}
10+
return value.attribute?.namespace?.kasKeys ?? [];
11+
}

‎lib/tdf3/src/client/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { plan } from '../../../src/policy/granter.js';
6060
import { attributeFQNsAsValues } from '../../../src/policy/api.js';
6161
import { type Chunker, fromBuffer, fromSource } from '../../../src/seekable.js';
6262
import { Algorithm, SimpleKasKey } from '../../../src/platform/policy/objects_pb.js';
63+
import { effectiveKasKeys } from '../../../src/policy/kas-keys.js';
6364

6465
const GLOBAL_BYTE_LIMIT = 64 * 1000 * 1000 * 1000; // 64 GB, see WS-9363.
6566

@@ -591,7 +592,7 @@ export class Client {
591592
}
592593

593594
for (const attributeValue of attributeValues) {
594-
for (const kasKey of attributeValue.kasKeys) {
595+
for (const kasKey of effectiveKasKeys(attributeValue)) {
595596
if (kasKey.publicKey !== undefined) {
596597
await putKasKeyIntoCache(this.kasKeyInfoCache, {
597598
// TypeScript is silly and cannot infer that publicKey is not undefined, without re-referencing it like this, even though we checked already.

‎lib/tests/mocha/client.spec.ts‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { assert, expect } from 'chai';
2+
import sinon from 'sinon';
23
import { Client as TDF } from '../../tdf3/src/index.js';
34
import { DecoratedReadableStream } from '../../tdf3/src/client/DecoratedReadableStream.js';
5+
import { findEntryInCache } from '../../tdf3/src/client/index.js';
6+
import { getMocks } from '../mocks/index.js';
7+
import { Algorithm, Value } from '../../src/platform/policy/objects_pb.js';
8+
import { create } from '@bufbuild/protobuf';
9+
import { GetAttributeValuesByFqnsResponseSchema } from '../../src/platform/policy/attributes/attributes_pb.js';
10+
import { base64 } from '../../src/encodings/index.js';
11+
import { Attribute } from 'src/policy/attributes.js';
412

513
describe('client wrapper tests', function () {
614
it('client params safe from updating', function () {
@@ -132,6 +140,178 @@ describe('client wrapper tests', function () {
132140
assert.ok(expected);
133141
}
134142
});
143+
144+
it('encrypt autoconfigure hydrates fqns via getAttributeValuesByFqns', async function () {
145+
const Mocks = getMocks();
146+
const authProvider = {
147+
updateClientPublicKey: async () => {},
148+
withCreds: async (httpReq: TDF.HttpRequest) => ({
149+
...httpReq,
150+
headers: { ...httpReq.headers, Authorization: 'Bearer dummy-auth-token' },
151+
}),
152+
};
153+
154+
const kasValueUri = 'https://kas.value.example/kas';
155+
const kasAttributeUri = 'https://kas.attribute.example/kas';
156+
157+
const attributeValueFqn = 'http://example.com/attr/value-keys/value/one';
158+
const attributeOnlyFqn = 'http://example.com/attr/attr-keys/value/two';
159+
160+
const valueWithAttribute: Value = {
161+
$typeName: 'policy.Value',
162+
fqn: attributeValueFqn,
163+
kasKeys: [
164+
{
165+
$typeName: 'policy.SimpleKasKey',
166+
kasId: 'kas-value-1',
167+
kasUri: kasValueUri,
168+
publicKey: {
169+
$typeName: 'policy.SimpleKasPublicKey',
170+
pem: Mocks.kasPublicKey,
171+
kid: 'value-kid-1',
172+
algorithm: Algorithm.RSA_2048,
173+
},
174+
},
175+
],
176+
id: 'value-id-1',
177+
attribute: {
178+
$typeName: 'policy.Attribute',
179+
id: 'attr-id-value',
180+
name: 'value-keys',
181+
fqn: 'http://example.com/attr/value-keys',
182+
rule: 0,
183+
values: [],
184+
grants: [],
185+
kasKeys: [],
186+
namespace: {
187+
$typeName: 'policy.Namespace',
188+
id: 'ns-id-value',
189+
name: 'example.com',
190+
fqn: 'http://example.com',
191+
grants: [],
192+
kasKeys: [],
193+
rootCerts: [],
194+
},
195+
},
196+
value: 'one',
197+
grants: [],
198+
active: true,
199+
subjectMappings: [],
200+
resourceMappings: [],
201+
obligations: [],
202+
};
203+
204+
const attributeOnly: Attribute = {
205+
$typeName: 'policy.Attribute',
206+
id: 'attr-id-attr',
207+
name: 'attr-keys',
208+
fqn: 'http://example.com/attr/attr-keys',
209+
rule: 0,
210+
values: [],
211+
grants: [],
212+
kasKeys: [
213+
{
214+
$typeName: 'policy.SimpleKasKey',
215+
kasId: 'kas-attr-1',
216+
kasUri: kasAttributeUri,
217+
publicKey: {
218+
$typeName: 'policy.SimpleKasPublicKey',
219+
pem: Mocks.kasPublicKey,
220+
kid: 'attr-kid-1',
221+
algorithm: Algorithm.RSA_2048,
222+
},
223+
},
224+
],
225+
namespace: {
226+
$typeName: 'policy.Namespace',
227+
id: 'ns-id-attr',
228+
name: 'example.com',
229+
fqn: 'http://example.com',
230+
grants: [],
231+
kasKeys: [],
232+
rootCerts: [],
233+
},
234+
};
235+
236+
const getAttributeValuesByFqnsResponse = create(GetAttributeValuesByFqnsResponseSchema, {
237+
fqnAttributeValues: {
238+
[attributeValueFqn]: {
239+
value: valueWithAttribute,
240+
attribute: valueWithAttribute.attribute,
241+
},
242+
[attributeOnlyFqn]: {
243+
attribute: attributeOnly,
244+
},
245+
},
246+
});
247+
248+
const fetchStub = sinon.stub(globalThis, 'fetch').callsFake(async (input) => {
249+
const url = typeof input === 'string' ? input : input.toString();
250+
if (url.includes('GetAttributeValuesByFqns')) {
251+
return new Response(JSON.stringify(getAttributeValuesByFqnsResponse), {
252+
status: 200,
253+
headers: { 'Content-Type': 'application/json' },
254+
});
255+
}
256+
throw new Error(`unexpected fetch: ${url}`);
257+
});
258+
259+
const client = new TDF.Client({
260+
kasEndpoint: 'https://kas.default.example/kas',
261+
clientId: 'id',
262+
dpopKeys: Mocks.entityKeyPair(),
263+
authProvider,
264+
platformUrl: 'http://example.com',
265+
});
266+
267+
const encryptParams = {
268+
...new TDF.EncryptParamsBuilder().withStringSource('hello world').withAutoconfigure().build(),
269+
scope: {
270+
attributes: [attributeValueFqn, attributeOnlyFqn],
271+
},
272+
};
273+
274+
try {
275+
const stream = await client.encrypt(encryptParams);
276+
assert.ok(stream);
277+
assert.equal(fetchStub.callCount, 1, 'fetch should only be called for FQN hydration');
278+
279+
const cachedValueKey = findEntryInCache(
280+
client.kasKeyInfoCache,
281+
kasValueUri,
282+
'rsa:2048',
283+
'value-kid-1'
284+
);
285+
const cachedAttributeKey = findEntryInCache(
286+
client.kasKeyInfoCache,
287+
kasAttributeUri,
288+
'rsa:2048',
289+
'attr-kid-1'
290+
);
291+
assert(cachedValueKey !== null, 'value-level key should be cached');
292+
assert(cachedAttributeKey !== null, 'attribute-level key should be cached');
293+
const policy = JSON.parse(base64.decode(stream.manifest.encryptionInformation.policy));
294+
const dataAttributes = policy?.body?.dataAttributes ?? [];
295+
assert.deepEqual(
296+
dataAttributes.map((attr: { attribute: string }) => attr.attribute).sort(),
297+
[attributeValueFqn, attributeOnlyFqn].sort(),
298+
'policy should include both attributes'
299+
);
300+
301+
const keyAccess = stream.manifest.encryptionInformation.keyAccess;
302+
assert.equal(keyAccess.length, 2, 'manifest should include both key access objects');
303+
assert.deepInclude(
304+
keyAccess.map((kao) => ({ url: kao.url, kid: kao.kid })),
305+
{ url: kasValueUri, kid: 'value-kid-1' }
306+
);
307+
assert.deepInclude(
308+
keyAccess.map((kao) => ({ url: kao.url, kid: kao.kid })),
309+
{ url: kasAttributeUri, kid: 'attr-kid-1' }
310+
);
311+
} finally {
312+
fetchStub.restore();
313+
}
314+
});
135315
});
136316

137317
describe('tdf stream tests', function () {

0 commit comments

Comments
 (0)