@aikdna/kdna-core 0.15.12 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +18 -0
- package/package.json +1 -1
- package/schema/kdna-envelope-external-grant-v1.schema.json +20 -0
- package/schema/kdna-key-grant-v1.schema.json +54 -0
- package/schema/load-plan.schema.json +1 -1
- package/schema/manifest.schema.json +4 -5
- package/schema/payload-profile-v1.schema.json +25 -0
- package/src/external-key-grant.js +560 -0
- package/src/index.js +4 -0
- package/src/index.mjs +21 -4
- package/src/runtime-api.js +91 -0
- package/src/types.d.ts +161 -2
- package/src/v1/index.js +180 -30
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account/device external key grants (RFC-0019).
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately has no password or license-key fallback. Asset CEKs
|
|
5
|
+
* are derived from an issuer root only in memory, then re-wrapped to one X25519
|
|
6
|
+
* device key by a signed, short-lived grant.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const Ajv2020 = require('ajv/dist/2020');
|
|
12
|
+
const addFormats = require('ajv-formats');
|
|
13
|
+
const cbor = require('cbor-x');
|
|
14
|
+
const { hkdfSha256, aesWrap, aesUnwrap } = require('./crypto-profile');
|
|
15
|
+
|
|
16
|
+
const EXTERNAL_ENVELOPE_PROFILE = 'kdna-envelope-external-grant-v1';
|
|
17
|
+
const EXTERNAL_GRANT_PROFILE = 'kdna-key-grant-v1';
|
|
18
|
+
const EXTERNAL_AAD_PROFILE = 'kdna-external-asset-cek-v1';
|
|
19
|
+
const DEVICE_KEK_PROFILE = 'kdna-device-grant-kek-v1';
|
|
20
|
+
const ENVELOPE_ALG = 'A256GCM';
|
|
21
|
+
const GRANT_WRAP_ALG = 'X25519-HKDF-SHA256+A256KW';
|
|
22
|
+
|
|
23
|
+
const schemaRoot = path.join(__dirname, '..', 'schema');
|
|
24
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
25
|
+
addFormats(ajv);
|
|
26
|
+
const validateEnvelopeSchema = ajv.compile(
|
|
27
|
+
require(path.join(schemaRoot, 'kdna-envelope-external-grant-v1.schema.json')),
|
|
28
|
+
);
|
|
29
|
+
const validateGrantSchema = ajv.compile(
|
|
30
|
+
require(path.join(schemaRoot, 'kdna-key-grant-v1.schema.json')),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const verifiedEntitlements = new WeakSet();
|
|
34
|
+
|
|
35
|
+
class KDNAExternalGrantError extends Error {
|
|
36
|
+
constructor(code, message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'KDNAExternalGrantError';
|
|
39
|
+
this.code = code;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fail(code, message) {
|
|
44
|
+
throw new KDNAExternalGrantError(code, message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sha256(value) {
|
|
48
|
+
return crypto.createHash('sha256').update(value).digest();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function digest(value) {
|
|
52
|
+
return `sha256:${sha256(value).toString('hex')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function b64url(value) {
|
|
56
|
+
return Buffer.from(value).toString('base64url');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decodeB64url(value, label, expectedLength) {
|
|
60
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
61
|
+
fail('KDNA_GRANT_FORMAT_INVALID', `${label} is not canonical base64url`);
|
|
62
|
+
}
|
|
63
|
+
const out = Buffer.from(value, 'base64url');
|
|
64
|
+
if (b64url(out) !== value || (expectedLength != null && out.length !== expectedLength)) {
|
|
65
|
+
fail('KDNA_GRANT_FORMAT_INVALID', `${label} has an invalid length or encoding`);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function prefixedKey(value, prefix, label, expectedLength = 32) {
|
|
71
|
+
if (typeof value !== 'string' || !value.startsWith(`${prefix}:`)) {
|
|
72
|
+
fail('KDNA_GRANT_FORMAT_INVALID', `${label} must use ${prefix}`);
|
|
73
|
+
}
|
|
74
|
+
return decodeB64url(value.slice(prefix.length + 1), label, expectedLength);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function canonicalJson(value) {
|
|
78
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number') {
|
|
79
|
+
return JSON.stringify(value);
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
82
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
83
|
+
if (typeof value === 'object') {
|
|
84
|
+
return `{${Object.keys(value)
|
|
85
|
+
.sort()
|
|
86
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
87
|
+
.join(',')}}`;
|
|
88
|
+
}
|
|
89
|
+
throw new TypeError(`unsupported canonical JSON value: ${typeof value}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function grantSigningPayload(grant) {
|
|
93
|
+
const unsigned = { ...grant };
|
|
94
|
+
delete unsigned.signature;
|
|
95
|
+
return Buffer.from(canonicalJson(unsigned), 'utf8');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function schemaProblems(validator) {
|
|
99
|
+
return (validator.errors || [])
|
|
100
|
+
.slice(0, 4)
|
|
101
|
+
.map((error) => `${error.instancePath || '<root>'} ${error.message}`)
|
|
102
|
+
.join('; ');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateExternalEnvelope(envelope) {
|
|
106
|
+
if (!validateEnvelopeSchema(envelope)) {
|
|
107
|
+
fail('KDNA_ENVELOPE_FORMAT_INVALID', `external envelope is invalid: ${schemaProblems(validateEnvelopeSchema)}`);
|
|
108
|
+
}
|
|
109
|
+
return envelope;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateExternalKeyGrant(grant) {
|
|
113
|
+
if (!validateGrantSchema(grant)) {
|
|
114
|
+
fail('KDNA_GRANT_FORMAT_INVALID', `external key grant is invalid: ${schemaProblems(validateGrantSchema)}`);
|
|
115
|
+
}
|
|
116
|
+
const issued = Date.parse(grant.issued_at);
|
|
117
|
+
const refresh = Date.parse(grant.refresh_after);
|
|
118
|
+
const grace = Date.parse(grant.offline_grace_until);
|
|
119
|
+
const expires = Date.parse(grant.expires_at);
|
|
120
|
+
if (!(issued <= refresh && refresh <= grace && grace <= expires)) {
|
|
121
|
+
fail('KDNA_GRANT_TIME_INVALID', 'grant time window is not monotonic');
|
|
122
|
+
}
|
|
123
|
+
return grant;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function externalEnvelopeAad({ manifest, entryName, plaintextDigest, keyRef, issuerKeyId }) {
|
|
127
|
+
const entitlementProfile = manifest?.entitlement?.profile || '';
|
|
128
|
+
const fields = [
|
|
129
|
+
EXTERNAL_ENVELOPE_PROFILE,
|
|
130
|
+
manifest?.asset_uid || '',
|
|
131
|
+
manifest?.asset_id || manifest?.name || '',
|
|
132
|
+
manifest?.version || '',
|
|
133
|
+
entryName,
|
|
134
|
+
plaintextDigest,
|
|
135
|
+
keyRef,
|
|
136
|
+
issuerKeyId,
|
|
137
|
+
manifest?.access || '',
|
|
138
|
+
entitlementProfile,
|
|
139
|
+
];
|
|
140
|
+
if (fields.some((field) => typeof field !== 'string' || field.length === 0)) {
|
|
141
|
+
fail('KDNA_ENVELOPE_BINDING_INVALID', 'manifest is missing an external envelope binding');
|
|
142
|
+
}
|
|
143
|
+
if (manifest.access !== 'licensed' || !['account', 'org'].includes(entitlementProfile)) {
|
|
144
|
+
fail('KDNA_ENVELOPE_BINDING_INVALID', 'external grants require licensed account or org entitlement');
|
|
145
|
+
}
|
|
146
|
+
return Buffer.from(fields.join('\n'), 'utf8');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeIssuerRoot(value) {
|
|
150
|
+
const root = Buffer.isBuffer(value) ? Buffer.from(value) : decodeB64url(value, 'issuer root', 32);
|
|
151
|
+
if (root.length !== 32) fail('KDNA_ISSUER_ROOT_INVALID', 'issuer root must be 32 bytes');
|
|
152
|
+
return root;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function deriveExternalAssetCek({ issuerRootKey, manifest, entryName, plaintextDigest, keyRef, issuerKeyId }) {
|
|
156
|
+
const root = normalizeIssuerRoot(issuerRootKey);
|
|
157
|
+
const aad = externalEnvelopeAad({ manifest, entryName, plaintextDigest, keyRef, issuerKeyId });
|
|
158
|
+
try {
|
|
159
|
+
return hkdfSha256(
|
|
160
|
+
root,
|
|
161
|
+
sha256(aad),
|
|
162
|
+
Buffer.from(`${EXTERNAL_AAD_PROFILE}\n${keyRef}`, 'utf8'),
|
|
163
|
+
32,
|
|
164
|
+
);
|
|
165
|
+
} finally {
|
|
166
|
+
root.fill(0);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function encodeExternalEnvelope(envelope) {
|
|
171
|
+
validateExternalEnvelope(envelope);
|
|
172
|
+
return Buffer.from(cbor.encode(envelope));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function decodeExternalEnvelope(value) {
|
|
176
|
+
let envelope = value;
|
|
177
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
178
|
+
try {
|
|
179
|
+
envelope = cbor.decode(Buffer.from(value));
|
|
180
|
+
} catch {
|
|
181
|
+
fail('KDNA_ENVELOPE_FORMAT_INVALID', 'external envelope CBOR could not be decoded');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return validateExternalEnvelope(envelope);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function encryptExternalGrantEntry(plaintextValue, options = {}) {
|
|
188
|
+
const plaintext = Buffer.from(plaintextValue);
|
|
189
|
+
const {
|
|
190
|
+
manifest,
|
|
191
|
+
entryName = 'payload.kdnab',
|
|
192
|
+
issuerRootKey,
|
|
193
|
+
keyRef,
|
|
194
|
+
issuerKeyId,
|
|
195
|
+
iv = crypto.randomBytes(12),
|
|
196
|
+
} = options;
|
|
197
|
+
const plaintextDigest = digest(plaintext);
|
|
198
|
+
const aad = externalEnvelopeAad({ manifest, entryName, plaintextDigest, keyRef, issuerKeyId });
|
|
199
|
+
const cek = deriveExternalAssetCek({
|
|
200
|
+
issuerRootKey,
|
|
201
|
+
manifest,
|
|
202
|
+
entryName,
|
|
203
|
+
plaintextDigest,
|
|
204
|
+
keyRef,
|
|
205
|
+
issuerKeyId,
|
|
206
|
+
});
|
|
207
|
+
try {
|
|
208
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', cek, Buffer.from(iv));
|
|
209
|
+
cipher.setAAD(aad);
|
|
210
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
211
|
+
return {
|
|
212
|
+
profile: EXTERNAL_ENVELOPE_PROFILE,
|
|
213
|
+
alg: ENVELOPE_ALG,
|
|
214
|
+
cek_derivation: 'HKDF-SHA256',
|
|
215
|
+
key_ref: keyRef,
|
|
216
|
+
issuer_key_id: issuerKeyId,
|
|
217
|
+
entry_path: entryName,
|
|
218
|
+
plaintext_digest: plaintextDigest,
|
|
219
|
+
iv: b64url(iv),
|
|
220
|
+
tag: b64url(cipher.getAuthTag()),
|
|
221
|
+
ciphertext: b64url(ciphertext),
|
|
222
|
+
};
|
|
223
|
+
} finally {
|
|
224
|
+
cek.fill(0);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function publicKeyFromRaw(crv, value) {
|
|
229
|
+
const x = value.includes(':') ? value.split(':', 2)[1] : value;
|
|
230
|
+
return crypto.createPublicKey({ key: { kty: 'OKP', crv, x }, format: 'jwk' });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function privateKeyFromBundle(crv, bundle) {
|
|
234
|
+
if (bundle && typeof bundle === 'object' && bundle.type === 'private') return bundle;
|
|
235
|
+
if (bundle && bundle.privateKey) return bundle.privateKey;
|
|
236
|
+
const privateValue = bundle?.private_key || bundle?.privateKeyRaw || bundle?.d;
|
|
237
|
+
const publicValue = bundle?.public_key || bundle?.publicKeyRaw || bundle?.x;
|
|
238
|
+
if (!privateValue || !publicValue) {
|
|
239
|
+
fail('KDNA_GRANT_DEVICE_KEY_INVALID', `${crv} private and public key material are required`);
|
|
240
|
+
}
|
|
241
|
+
const d = privateValue.includes(':') ? privateValue.split(':', 2)[1] : privateValue;
|
|
242
|
+
const x = publicValue.includes(':') ? publicValue.split(':', 2)[1] : publicValue;
|
|
243
|
+
decodeB64url(d, `${crv} private key`, 32);
|
|
244
|
+
decodeB64url(x, `${crv} public key`, 32);
|
|
245
|
+
return crypto.createPrivateKey({ key: { kty: 'OKP', crv, d, x }, format: 'jwk' });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function generateDeviceKeyPairs() {
|
|
249
|
+
const agreement = crypto.generateKeyPairSync('x25519');
|
|
250
|
+
const signing = crypto.generateKeyPairSync('ed25519');
|
|
251
|
+
const agreementPublic = agreement.publicKey.export({ format: 'jwk' });
|
|
252
|
+
const agreementPrivate = agreement.privateKey.export({ format: 'jwk' });
|
|
253
|
+
const signingPublic = signing.publicKey.export({ format: 'jwk' });
|
|
254
|
+
const signingPrivate = signing.privateKey.export({ format: 'jwk' });
|
|
255
|
+
return {
|
|
256
|
+
agreement: {
|
|
257
|
+
public_key: `x25519:${agreementPublic.x}`,
|
|
258
|
+
private_key: `x25519:${agreementPrivate.d}`,
|
|
259
|
+
},
|
|
260
|
+
signing: {
|
|
261
|
+
public_key: `ed25519:${signingPublic.x}`,
|
|
262
|
+
private_key: `ed25519:${signingPrivate.d}`,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function resolveIssuerPublicKey(issuerPublicKeys, keyId) {
|
|
268
|
+
const value = issuerPublicKeys instanceof Map ? issuerPublicKeys.get(keyId) : issuerPublicKeys?.[keyId];
|
|
269
|
+
if (!value) fail('KDNA_GRANT_ISSUER_UNKNOWN', 'grant signing key is not pinned');
|
|
270
|
+
if (value && typeof value === 'object' && value.type === 'public') return value;
|
|
271
|
+
if (typeof value === 'string' && value.startsWith('ed25519:')) {
|
|
272
|
+
try {
|
|
273
|
+
const raw = prefixedKey(value, 'ed25519', 'grant issuer public key');
|
|
274
|
+
return publicKeyFromRaw('Ed25519', `ed25519:${b64url(raw)}`);
|
|
275
|
+
} catch {
|
|
276
|
+
fail('KDNA_GRANT_ISSUER_UNKNOWN', 'grant signing key is invalid');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
return crypto.createPublicKey(value);
|
|
281
|
+
} catch {
|
|
282
|
+
fail('KDNA_GRANT_ISSUER_UNKNOWN', 'grant signing key is invalid');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function createExternalKeyGrant(options = {}) {
|
|
287
|
+
const {
|
|
288
|
+
issuerRootKey,
|
|
289
|
+
issuerSigningPrivateKey,
|
|
290
|
+
signingKeyId,
|
|
291
|
+
issuer,
|
|
292
|
+
entitlementId,
|
|
293
|
+
accountId,
|
|
294
|
+
deviceId,
|
|
295
|
+
devicePublicKey,
|
|
296
|
+
deviceSigningPublicKey,
|
|
297
|
+
manifest,
|
|
298
|
+
envelope: envelopeValue,
|
|
299
|
+
assetDigest,
|
|
300
|
+
status = 'active',
|
|
301
|
+
statusVersion = 1,
|
|
302
|
+
issuedAt = new Date(),
|
|
303
|
+
refreshAfter = new Date(issuedAt.getTime() + 24 * 60 * 60 * 1000),
|
|
304
|
+
offlineGraceUntil = new Date(issuedAt.getTime() + 7 * 24 * 60 * 60 * 1000),
|
|
305
|
+
expiresAt = new Date(issuedAt.getTime() + 8 * 24 * 60 * 60 * 1000),
|
|
306
|
+
grantId = `grt_${crypto.randomBytes(16).toString('base64url')}`,
|
|
307
|
+
ephemeralKeyPair = null,
|
|
308
|
+
wrapSalt = null,
|
|
309
|
+
} = options;
|
|
310
|
+
const envelope = decodeExternalEnvelope(envelopeValue);
|
|
311
|
+
const deviceRaw = prefixedKey(devicePublicKey, 'x25519', 'device public key');
|
|
312
|
+
prefixedKey(deviceSigningPublicKey, 'ed25519', 'device signing public key');
|
|
313
|
+
const cek = deriveExternalAssetCek({
|
|
314
|
+
issuerRootKey,
|
|
315
|
+
manifest,
|
|
316
|
+
entryName: envelope.entry_path,
|
|
317
|
+
plaintextDigest: envelope.plaintext_digest,
|
|
318
|
+
keyRef: envelope.key_ref,
|
|
319
|
+
issuerKeyId: envelope.issuer_key_id,
|
|
320
|
+
});
|
|
321
|
+
let shared = null;
|
|
322
|
+
let kek = null;
|
|
323
|
+
try {
|
|
324
|
+
const ephemeral = ephemeralKeyPair || crypto.generateKeyPairSync('x25519');
|
|
325
|
+
const ephemeralJwk = ephemeral.publicKey.export({ format: 'jwk' });
|
|
326
|
+
const deviceKey = publicKeyFromRaw('X25519', `x25519:${b64url(deviceRaw)}`);
|
|
327
|
+
shared = crypto.diffieHellman({ privateKey: ephemeral.privateKey, publicKey: deviceKey });
|
|
328
|
+
const salt = wrapSalt ? Buffer.from(wrapSalt) : crypto.randomBytes(16);
|
|
329
|
+
if (salt.length !== 16) fail('KDNA_GRANT_FORMAT_INVALID', 'grant wrap salt must be 16 bytes');
|
|
330
|
+
kek = hkdfSha256(shared, salt, Buffer.from(`${DEVICE_KEK_PROFILE}\n${grantId}`, 'utf8'), 32);
|
|
331
|
+
const grant = {
|
|
332
|
+
profile: EXTERNAL_GRANT_PROFILE,
|
|
333
|
+
grant_id: grantId,
|
|
334
|
+
issuer,
|
|
335
|
+
signing_key_id: signingKeyId,
|
|
336
|
+
entitlement_id: entitlementId,
|
|
337
|
+
account_id: accountId,
|
|
338
|
+
device_id: deviceId,
|
|
339
|
+
device_public_key: devicePublicKey,
|
|
340
|
+
device_signing_public_key: deviceSigningPublicKey,
|
|
341
|
+
asset: {
|
|
342
|
+
asset_id: manifest.asset_id || manifest.name,
|
|
343
|
+
asset_uid: manifest.asset_uid,
|
|
344
|
+
version: manifest.version,
|
|
345
|
+
digest: assetDigest,
|
|
346
|
+
entry_path: envelope.entry_path,
|
|
347
|
+
ciphertext_digest: digest(Buffer.from(envelope.ciphertext, 'base64url')),
|
|
348
|
+
key_ref: envelope.key_ref,
|
|
349
|
+
issuer_key_id: envelope.issuer_key_id,
|
|
350
|
+
},
|
|
351
|
+
issued_at: new Date(issuedAt).toISOString(),
|
|
352
|
+
refresh_after: new Date(refreshAfter).toISOString(),
|
|
353
|
+
offline_grace_until: new Date(offlineGraceUntil).toISOString(),
|
|
354
|
+
expires_at: new Date(expiresAt).toISOString(),
|
|
355
|
+
status,
|
|
356
|
+
status_version: statusVersion,
|
|
357
|
+
wrap: {
|
|
358
|
+
alg: GRANT_WRAP_ALG,
|
|
359
|
+
ephemeral_public_key: `x25519:${ephemeralJwk.x}`,
|
|
360
|
+
salt: b64url(salt),
|
|
361
|
+
wrapped_cek: b64url(aesWrap(kek, cek)),
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
validateExternalKeyGrant({ ...grant, signature: `ed25519:${'A'.repeat(86)}` });
|
|
365
|
+
const signature = crypto.sign(null, grantSigningPayload(grant), issuerSigningPrivateKey);
|
|
366
|
+
return { ...grant, signature: `ed25519:${b64url(signature)}` };
|
|
367
|
+
} finally {
|
|
368
|
+
cek.fill(0);
|
|
369
|
+
if (shared) shared.fill(0);
|
|
370
|
+
if (kek) kek.fill(0);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function grantState(grant, now, networkAvailable, allowOffline) {
|
|
375
|
+
const time = now instanceof Date ? now.getTime() : new Date(now || Date.now()).getTime();
|
|
376
|
+
const issued = Date.parse(grant.issued_at);
|
|
377
|
+
const refresh = Date.parse(grant.refresh_after);
|
|
378
|
+
const grace = Date.parse(grant.offline_grace_until);
|
|
379
|
+
const expires = Date.parse(grant.expires_at);
|
|
380
|
+
if (
|
|
381
|
+
![time, issued, refresh, grace, expires].every(Number.isFinite) ||
|
|
382
|
+
issued > refresh || refresh > grace || grace > expires
|
|
383
|
+
) fail('KDNA_GRANT_TIME_INVALID', 'external key grant time window is invalid');
|
|
384
|
+
if (time + 5 * 60 * 1000 < issued) fail('KDNA_GRANT_TIME_INVALID', 'external key grant is not valid yet');
|
|
385
|
+
if (grant.status === 'revoked') fail('KDNA_GRANT_REVOKED', 'external key grant is revoked');
|
|
386
|
+
if (grant.status !== 'active') fail('KDNA_GRANT_EXPIRED', 'external key grant is not active');
|
|
387
|
+
if (time > expires) fail('KDNA_GRANT_EXPIRED', 'external key grant has expired');
|
|
388
|
+
if (time <= refresh) return 'active';
|
|
389
|
+
if (networkAvailable) fail('KDNA_GRANT_SYNC_REQUIRED', 'external key grant must be synchronized');
|
|
390
|
+
if (!allowOffline || time > grace) {
|
|
391
|
+
fail('KDNA_GRANT_EXPIRED', 'external key grant offline grace has expired');
|
|
392
|
+
}
|
|
393
|
+
return 'offline_grace';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function expectEqual(actual, expected, code, label) {
|
|
397
|
+
if (expected == null || actual !== expected) fail(code, `${label} mismatch`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function authorizeExternalKeyGrant(options = {}) {
|
|
401
|
+
const {
|
|
402
|
+
grant,
|
|
403
|
+
issuerPublicKeys,
|
|
404
|
+
manifest,
|
|
405
|
+
checksums,
|
|
406
|
+
envelope: envelopeValue,
|
|
407
|
+
deviceAgreementKey,
|
|
408
|
+
expectedAccountId,
|
|
409
|
+
expectedDeviceId,
|
|
410
|
+
expectedDeviceSigningPublicKey,
|
|
411
|
+
minimumStatusVersion = null,
|
|
412
|
+
now = new Date(),
|
|
413
|
+
networkAvailable = false,
|
|
414
|
+
allowOffline = true,
|
|
415
|
+
} = options;
|
|
416
|
+
validateExternalKeyGrant(grant);
|
|
417
|
+
if (minimumStatusVersion != null && (
|
|
418
|
+
!Number.isInteger(minimumStatusVersion) || grant.status_version < minimumStatusVersion
|
|
419
|
+
)) fail('KDNA_GRANT_ROLLBACK_DETECTED', 'external key grant status version rolled back');
|
|
420
|
+
const issuerKey = resolveIssuerPublicKey(issuerPublicKeys, grant.signing_key_id);
|
|
421
|
+
const signature = prefixedKey(grant.signature, 'ed25519', 'grant signature', 64);
|
|
422
|
+
if (signature.length !== 64 || !crypto.verify(null, grantSigningPayload(grant), issuerKey, signature)) {
|
|
423
|
+
fail('KDNA_GRANT_SIGNATURE_INVALID', 'external key grant signature is invalid');
|
|
424
|
+
}
|
|
425
|
+
const state = grantState(grant, now, networkAvailable, allowOffline);
|
|
426
|
+
const envelope = decodeExternalEnvelope(envelopeValue);
|
|
427
|
+
|
|
428
|
+
expectEqual(grant.account_id, expectedAccountId, 'KDNA_GRANT_ACCOUNT_MISMATCH', 'grant account');
|
|
429
|
+
expectEqual(grant.device_id, expectedDeviceId, 'KDNA_GRANT_DEVICE_MISMATCH', 'grant device');
|
|
430
|
+
expectEqual(
|
|
431
|
+
grant.device_signing_public_key,
|
|
432
|
+
expectedDeviceSigningPublicKey,
|
|
433
|
+
'KDNA_GRANT_DEVICE_MISMATCH',
|
|
434
|
+
'device signing key',
|
|
435
|
+
);
|
|
436
|
+
expectEqual(grant.asset.asset_id, manifest.asset_id || manifest.name, 'KDNA_GRANT_ASSET_MISMATCH', 'asset ID');
|
|
437
|
+
expectEqual(grant.asset.asset_uid, manifest.asset_uid, 'KDNA_GRANT_ASSET_MISMATCH', 'asset UID');
|
|
438
|
+
expectEqual(grant.asset.version, manifest.version, 'KDNA_GRANT_ASSET_MISMATCH', 'asset version');
|
|
439
|
+
expectEqual(grant.asset.digest, checksums?.asset_digest, 'KDNA_GRANT_DIGEST_MISMATCH', 'asset digest');
|
|
440
|
+
expectEqual(grant.asset.entry_path, envelope.entry_path, 'KDNA_GRANT_ASSET_MISMATCH', 'entry path');
|
|
441
|
+
expectEqual(grant.asset.key_ref, envelope.key_ref, 'KDNA_GRANT_ASSET_MISMATCH', 'key reference');
|
|
442
|
+
expectEqual(grant.asset.issuer_key_id, envelope.issuer_key_id, 'KDNA_GRANT_ASSET_MISMATCH', 'issuer asset key');
|
|
443
|
+
expectEqual(
|
|
444
|
+
grant.asset.ciphertext_digest,
|
|
445
|
+
digest(Buffer.from(envelope.ciphertext, 'base64url')),
|
|
446
|
+
'KDNA_GRANT_DIGEST_MISMATCH',
|
|
447
|
+
'ciphertext digest',
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const privateKey = privateKeyFromBundle('X25519', deviceAgreementKey);
|
|
451
|
+
const localPublic = deviceAgreementKey.public_key || deviceAgreementKey.publicKeyRaw || deviceAgreementKey.x;
|
|
452
|
+
expectEqual(grant.device_public_key, localPublic, 'KDNA_GRANT_DEVICE_MISMATCH', 'device agreement key');
|
|
453
|
+
const ephemeralRaw = prefixedKey(grant.wrap.ephemeral_public_key, 'x25519', 'ephemeral public key');
|
|
454
|
+
const ephemeralKey = publicKeyFromRaw('X25519', `x25519:${b64url(ephemeralRaw)}`);
|
|
455
|
+
const shared = crypto.diffieHellman({ privateKey, publicKey: ephemeralKey });
|
|
456
|
+
const salt = decodeB64url(grant.wrap.salt, 'grant wrap salt', 16);
|
|
457
|
+
const kek = hkdfSha256(shared, salt, Buffer.from(`${DEVICE_KEK_PROFILE}\n${grant.grant_id}`, 'utf8'), 32);
|
|
458
|
+
let cek;
|
|
459
|
+
try {
|
|
460
|
+
cek = aesUnwrap(kek, decodeB64url(grant.wrap.wrapped_cek, 'wrapped CEK', 40));
|
|
461
|
+
} catch {
|
|
462
|
+
fail('KDNA_GRANT_DEVICE_MISMATCH', 'device could not unwrap the external key grant');
|
|
463
|
+
} finally {
|
|
464
|
+
shared.fill(0);
|
|
465
|
+
kek.fill(0);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const assetBinding = Object.freeze({
|
|
469
|
+
asset_id: grant.asset.asset_id,
|
|
470
|
+
asset_uid: grant.asset.asset_uid,
|
|
471
|
+
version: grant.asset.version,
|
|
472
|
+
digest: grant.asset.digest,
|
|
473
|
+
entry_path: grant.asset.entry_path,
|
|
474
|
+
ciphertext_digest: grant.asset.ciphertext_digest,
|
|
475
|
+
key_ref: grant.asset.key_ref,
|
|
476
|
+
issuer_key_id: grant.asset.issuer_key_id,
|
|
477
|
+
});
|
|
478
|
+
const entitlement = Object.freeze({
|
|
479
|
+
status: state,
|
|
480
|
+
profile: EXTERNAL_GRANT_PROFILE,
|
|
481
|
+
grant_id: grant.grant_id,
|
|
482
|
+
entitlement_id: grant.entitlement_id,
|
|
483
|
+
account_id: grant.account_id,
|
|
484
|
+
device_id: grant.device_id,
|
|
485
|
+
refresh_after: grant.refresh_after,
|
|
486
|
+
offline_grace_until: grant.offline_grace_until,
|
|
487
|
+
expires_at: grant.expires_at,
|
|
488
|
+
asset: assetBinding,
|
|
489
|
+
});
|
|
490
|
+
verifiedEntitlements.add(entitlement);
|
|
491
|
+
|
|
492
|
+
const decryptEntry = ({ entryName, ciphertext, manifest: runtimeManifest }) => {
|
|
493
|
+
const runtimeEnvelope = decodeExternalEnvelope(ciphertext);
|
|
494
|
+
if (entryName !== envelope.entry_path) fail('KDNA_GRANT_ASSET_MISMATCH', 'encrypted entry path mismatch');
|
|
495
|
+
if (digest(Buffer.from(runtimeEnvelope.ciphertext, 'base64url')) !== grant.asset.ciphertext_digest) {
|
|
496
|
+
fail('KDNA_GRANT_DIGEST_MISMATCH', 'encrypted entry digest mismatch');
|
|
497
|
+
}
|
|
498
|
+
const aad = externalEnvelopeAad({
|
|
499
|
+
manifest: runtimeManifest,
|
|
500
|
+
entryName,
|
|
501
|
+
plaintextDigest: runtimeEnvelope.plaintext_digest,
|
|
502
|
+
keyRef: runtimeEnvelope.key_ref,
|
|
503
|
+
issuerKeyId: runtimeEnvelope.issuer_key_id,
|
|
504
|
+
});
|
|
505
|
+
try {
|
|
506
|
+
const decipher = crypto.createDecipheriv(
|
|
507
|
+
'aes-256-gcm',
|
|
508
|
+
cek,
|
|
509
|
+
decodeB64url(runtimeEnvelope.iv, 'envelope IV', 12),
|
|
510
|
+
);
|
|
511
|
+
decipher.setAAD(aad);
|
|
512
|
+
decipher.setAuthTag(decodeB64url(runtimeEnvelope.tag, 'envelope tag', 16));
|
|
513
|
+
const plaintext = Buffer.concat([
|
|
514
|
+
decipher.update(decodeB64url(runtimeEnvelope.ciphertext, 'envelope ciphertext')),
|
|
515
|
+
decipher.final(),
|
|
516
|
+
]);
|
|
517
|
+
if (digest(plaintext) !== runtimeEnvelope.plaintext_digest) {
|
|
518
|
+
fail('KDNA_GRANT_DIGEST_MISMATCH', 'decrypted entry digest mismatch');
|
|
519
|
+
}
|
|
520
|
+
return plaintext;
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (error instanceof KDNAExternalGrantError) throw error;
|
|
523
|
+
fail('KDNA_GRANT_TAMPERED', 'external envelope authentication failed');
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
entitlement,
|
|
529
|
+
decryptEntry,
|
|
530
|
+
dispose() {
|
|
531
|
+
if (cek) cek.fill(0);
|
|
532
|
+
cek = null;
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isVerifiedExternalEntitlement(value) {
|
|
538
|
+
return !!value && verifiedEntitlements.has(value);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
module.exports = {
|
|
542
|
+
EXTERNAL_ENVELOPE_PROFILE,
|
|
543
|
+
EXTERNAL_GRANT_PROFILE,
|
|
544
|
+
EXTERNAL_AAD_PROFILE,
|
|
545
|
+
DEVICE_KEK_PROFILE,
|
|
546
|
+
KDNAExternalGrantError,
|
|
547
|
+
canonicalJson,
|
|
548
|
+
grantSigningPayload,
|
|
549
|
+
validateExternalEnvelope,
|
|
550
|
+
validateExternalKeyGrant,
|
|
551
|
+
externalEnvelopeAad,
|
|
552
|
+
deriveExternalAssetCek,
|
|
553
|
+
encodeExternalEnvelope,
|
|
554
|
+
decodeExternalEnvelope,
|
|
555
|
+
encryptExternalGrantEntry,
|
|
556
|
+
generateDeviceKeyPairs,
|
|
557
|
+
createExternalKeyGrant,
|
|
558
|
+
authorizeExternalKeyGrant,
|
|
559
|
+
isVerifiedExternalEntitlement,
|
|
560
|
+
};
|
package/src/index.js
CHANGED
|
@@ -8,9 +8,11 @@ const render = require('./render');
|
|
|
8
8
|
const compose = require('./compose');
|
|
9
9
|
const assetReader = require('./asset-reader');
|
|
10
10
|
const cryptoProfile = require('./crypto-profile');
|
|
11
|
+
const externalKeyGrant = require('./external-key-grant');
|
|
11
12
|
const publicApi = require('./public-api');
|
|
12
13
|
const workpackPure = require('./workpack-pure');
|
|
13
14
|
const v1 = require('./v1');
|
|
15
|
+
const runtimeApi = require('./runtime-api');
|
|
14
16
|
|
|
15
17
|
module.exports = {
|
|
16
18
|
...publicApi,
|
|
@@ -21,6 +23,8 @@ module.exports = {
|
|
|
21
23
|
...compose,
|
|
22
24
|
...assetReader,
|
|
23
25
|
...cryptoProfile,
|
|
26
|
+
...externalKeyGrant,
|
|
24
27
|
...workpackPure,
|
|
25
28
|
...v1,
|
|
29
|
+
...runtimeApi,
|
|
26
30
|
};
|
package/src/index.mjs
CHANGED
|
@@ -20,13 +20,9 @@ export {
|
|
|
20
20
|
readLayout,
|
|
21
21
|
inspect,
|
|
22
22
|
validate,
|
|
23
|
-
planLoad,
|
|
24
|
-
loadAuthorized,
|
|
25
23
|
buildChecksums,
|
|
26
24
|
pack,
|
|
27
25
|
unpack,
|
|
28
|
-
load,
|
|
29
|
-
loadAsset,
|
|
30
26
|
buildCapsule,
|
|
31
27
|
FORBIDDEN_OUTPUT_TERMS,
|
|
32
28
|
parseSemver,
|
|
@@ -34,6 +30,12 @@ export {
|
|
|
34
30
|
satisfies,
|
|
35
31
|
} from './v1/index.js';
|
|
36
32
|
|
|
33
|
+
import runtimeApi from './runtime-api.js';
|
|
34
|
+
export const planLoad = runtimeApi.planLoad;
|
|
35
|
+
export const loadAuthorized = runtimeApi.loadAuthorized;
|
|
36
|
+
export const load = runtimeApi.load;
|
|
37
|
+
export const loadAsset = runtimeApi.loadAsset;
|
|
38
|
+
|
|
37
39
|
export { validateDomainSchema, validateCrossFile } from './validate-pure.js';
|
|
38
40
|
|
|
39
41
|
export { renderPreviewHTML, escHtml, renderCard } from './render.js';
|
|
@@ -42,6 +44,7 @@ export { composeContext, composeContextWithAttribution, classifySignals, classif
|
|
|
42
44
|
|
|
43
45
|
import assetReader from './asset-reader.js';
|
|
44
46
|
import cryptoProfile from './crypto-profile.js';
|
|
47
|
+
import externalKeyGrant from './external-key-grant.js';
|
|
45
48
|
|
|
46
49
|
export const STANDARD_ENTRIES = assetReader.STANDARD_ENTRIES;
|
|
47
50
|
export const createKdnaAssetReader = assetReader.createKdnaAssetReader;
|
|
@@ -50,3 +53,17 @@ export const deriveLicensedEntryKey = cryptoProfile.deriveLicensedEntryKey;
|
|
|
50
53
|
export const encryptLicensedEntry = cryptoProfile.encryptLicensedEntry;
|
|
51
54
|
export const decryptLicensedEntry = cryptoProfile.decryptLicensedEntry;
|
|
52
55
|
export const createLicensedDecryptEntry = cryptoProfile.createLicensedDecryptEntry;
|
|
56
|
+
export const EXTERNAL_ENVELOPE_PROFILE = externalKeyGrant.EXTERNAL_ENVELOPE_PROFILE;
|
|
57
|
+
export const EXTERNAL_GRANT_PROFILE = externalKeyGrant.EXTERNAL_GRANT_PROFILE;
|
|
58
|
+
export const KDNAExternalGrantError = externalKeyGrant.KDNAExternalGrantError;
|
|
59
|
+
export const canonicalJson = externalKeyGrant.canonicalJson;
|
|
60
|
+
export const grantSigningPayload = externalKeyGrant.grantSigningPayload;
|
|
61
|
+
export const validateExternalEnvelope = externalKeyGrant.validateExternalEnvelope;
|
|
62
|
+
export const validateExternalKeyGrant = externalKeyGrant.validateExternalKeyGrant;
|
|
63
|
+
export const deriveExternalAssetCek = externalKeyGrant.deriveExternalAssetCek;
|
|
64
|
+
export const encodeExternalEnvelope = externalKeyGrant.encodeExternalEnvelope;
|
|
65
|
+
export const decodeExternalEnvelope = externalKeyGrant.decodeExternalEnvelope;
|
|
66
|
+
export const encryptExternalGrantEntry = externalKeyGrant.encryptExternalGrantEntry;
|
|
67
|
+
export const generateDeviceKeyPairs = externalKeyGrant.generateDeviceKeyPairs;
|
|
68
|
+
export const createExternalKeyGrant = externalKeyGrant.createExternalKeyGrant;
|
|
69
|
+
export const authorizeExternalKeyGrant = externalKeyGrant.authorizeExternalKeyGrant;
|