@aikdna/kdna-core 0.15.12 → 0.16.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 +27 -0
- package/README.md +12 -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/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 +107 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.16.0 (2026-07-13)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- RFC-0019 external account/device key grants, schemas, deterministic golden
|
|
8
|
+
and negative fixtures, and JS/Swift parity vectors.
|
|
9
|
+
- JS Core helpers for external envelope encryption, per-device grant issuance,
|
|
10
|
+
signature/binding verification, in-memory decryption, and explicit zeroization.
|
|
11
|
+
- LoadPlan accepts only a runtime-verified external entitlement for account/org
|
|
12
|
+
assets; structurally similar status objects no longer authorize loading.
|
|
13
|
+
- Reject authoring source directories at the public runtime API boundary;
|
|
14
|
+
`planLoad`, `load`, `loadAsset`, and `loadAuthorized` now require a packaged
|
|
15
|
+
`.kdna` file, including resolver-provided dependencies.
|
|
16
|
+
- Regenerate authorization conformance fixtures from packaged assets and keep
|
|
17
|
+
source directories available only to authoring operations such as inspect,
|
|
18
|
+
validate, pack, and unpack.
|
|
19
|
+
- Remove stale public format-generation wording and local coordination-path
|
|
20
|
+
assumptions from protocol documentation and public audit scripts.
|
|
21
|
+
- Make release readiness fail for dirty inputs or when the selected version
|
|
22
|
+
tag does not point to the current commit.
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
|
|
26
|
+
- Account/device envelopes never fall back to the password profile.
|
|
27
|
+
- Revoked, expired, tampered, digest/version/account/device mismatches fail
|
|
28
|
+
closed before Runtime Capsule projection.
|
|
29
|
+
|
|
3
30
|
## v0.15.12 (2026-07-12)
|
|
4
31
|
|
|
5
32
|
**Single-format refactor + CBOR wire contract + Runtime Capsule.**
|
package/README.md
CHANGED
|
@@ -97,6 +97,18 @@ A `.kdna` asset contains:
|
|
|
97
97
|
encryption envelope in the same entry; authorized plaintext is decrypted in
|
|
98
98
|
memory and projected into the Runtime Capsule.
|
|
99
99
|
|
|
100
|
+
### Account/device external grants
|
|
101
|
+
|
|
102
|
+
RFC-0019 licensed assets can keep the encrypted container publicly available
|
|
103
|
+
while an issuer grants independent account/device access. `authorizeExternalKeyGrant()`
|
|
104
|
+
verifies the issuer signature, lease, revocation state, account, device, asset
|
|
105
|
+
UID/version/digest, and encrypted entry before returning a branded entitlement
|
|
106
|
+
and in-memory decrypt hook. A plain `{status: "active"}` object is not accepted.
|
|
107
|
+
|
|
108
|
+
Device private keys belong in the platform SecretStore. The returned session
|
|
109
|
+
must be disposed after loading; Agent-facing callers should receive only the
|
|
110
|
+
Runtime Capsule. Account grants never fall back to the password profile.
|
|
111
|
+
|
|
100
112
|
## Load Profiles
|
|
101
113
|
|
|
102
114
|
`loadAuthorized()` supports:
|
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://kdna.ai/specs/kdna-envelope-external-grant-v1.schema.json",
|
|
4
|
+
"title": "KDNA external grant envelope v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["profile", "alg", "cek_derivation", "key_ref", "issuer_key_id", "entry_path", "plaintext_digest", "iv", "tag", "ciphertext"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"profile": { "const": "kdna-envelope-external-grant-v1" },
|
|
10
|
+
"alg": { "const": "A256GCM" },
|
|
11
|
+
"cek_derivation": { "const": "HKDF-SHA256" },
|
|
12
|
+
"key_ref": { "type": "string", "minLength": 1, "maxLength": 256 },
|
|
13
|
+
"issuer_key_id": { "type": "string", "minLength": 1, "maxLength": 128 },
|
|
14
|
+
"entry_path": { "type": "string", "pattern": "^[^/].*[^/]$|^[^/]$", "maxLength": 256 },
|
|
15
|
+
"plaintext_digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
|
|
16
|
+
"iv": { "type": "string", "pattern": "^[A-Za-z0-9_-]{16}$" },
|
|
17
|
+
"tag": { "type": "string", "pattern": "^[A-Za-z0-9_-]{22}$" },
|
|
18
|
+
"ciphertext": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$" }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://kdna.ai/specs/kdna-key-grant-v1.schema.json",
|
|
4
|
+
"title": "KDNA account/device external key grant v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["profile", "grant_id", "issuer", "signing_key_id", "entitlement_id", "account_id", "device_id", "device_public_key", "device_signing_public_key", "asset", "issued_at", "refresh_after", "offline_grace_until", "expires_at", "status", "status_version", "wrap", "signature"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"profile": { "const": "kdna-key-grant-v1" },
|
|
10
|
+
"grant_id": { "$ref": "#/$defs/id" },
|
|
11
|
+
"issuer": { "type": "string", "format": "uri", "maxLength": 256 },
|
|
12
|
+
"signing_key_id": { "$ref": "#/$defs/id" },
|
|
13
|
+
"entitlement_id": { "$ref": "#/$defs/id" },
|
|
14
|
+
"account_id": { "$ref": "#/$defs/id" },
|
|
15
|
+
"device_id": { "$ref": "#/$defs/id" },
|
|
16
|
+
"device_public_key": { "type": "string", "pattern": "^x25519:[A-Za-z0-9_-]{43}$" },
|
|
17
|
+
"device_signing_public_key": { "type": "string", "pattern": "^ed25519:[A-Za-z0-9_-]{43}$" },
|
|
18
|
+
"asset": {
|
|
19
|
+
"type": "object", "additionalProperties": false,
|
|
20
|
+
"required": ["asset_id", "asset_uid", "version", "digest", "entry_path", "ciphertext_digest", "key_ref", "issuer_key_id"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"asset_id": { "type": "string", "minLength": 1, "maxLength": 256 },
|
|
23
|
+
"asset_uid": { "type": "string", "minLength": 1, "maxLength": 512 },
|
|
24
|
+
"version": { "type": "string", "minLength": 1, "maxLength": 128 },
|
|
25
|
+
"digest": { "$ref": "#/$defs/digest" },
|
|
26
|
+
"entry_path": { "type": "string", "minLength": 1, "maxLength": 256 },
|
|
27
|
+
"ciphertext_digest": { "$ref": "#/$defs/digest" },
|
|
28
|
+
"key_ref": { "type": "string", "minLength": 1, "maxLength": 256 },
|
|
29
|
+
"issuer_key_id": { "$ref": "#/$defs/id" }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"issued_at": { "type": "string", "format": "date-time" },
|
|
33
|
+
"refresh_after": { "type": "string", "format": "date-time" },
|
|
34
|
+
"offline_grace_until": { "type": "string", "format": "date-time" },
|
|
35
|
+
"expires_at": { "type": "string", "format": "date-time" },
|
|
36
|
+
"status": { "enum": ["active", "revoked", "expired"] },
|
|
37
|
+
"status_version": { "type": "integer", "minimum": 1 },
|
|
38
|
+
"wrap": {
|
|
39
|
+
"type": "object", "additionalProperties": false,
|
|
40
|
+
"required": ["alg", "ephemeral_public_key", "salt", "wrapped_cek"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"alg": { "const": "X25519-HKDF-SHA256+A256KW" },
|
|
43
|
+
"ephemeral_public_key": { "type": "string", "pattern": "^x25519:[A-Za-z0-9_-]{43}$" },
|
|
44
|
+
"salt": { "type": "string", "pattern": "^[A-Za-z0-9_-]{22}$" },
|
|
45
|
+
"wrapped_cek": { "type": "string", "pattern": "^[A-Za-z0-9_-]{54}$" }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"signature": { "type": "string", "pattern": "^ed25519:[A-Za-z0-9_-]{86}$" }
|
|
49
|
+
},
|
|
50
|
+
"$defs": {
|
|
51
|
+
"id": { "type": "string", "pattern": "^[A-Za-z0-9._:@/-]{1,256}$" },
|
|
52
|
+
"digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://github.com/aikdna/kdna/schema/load-plan.schema.json",
|
|
4
4
|
"title": "KDNA LoadPlan v1",
|
|
5
|
-
"description": "Machine-readable pre-load authorization and runtime planning result for KDNA
|
|
5
|
+
"description": "Machine-readable pre-load authorization and runtime planning result for packaged KDNA assets.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": [
|
|
8
8
|
"kdna_version",
|
|
@@ -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;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const v1 = require('./v1');
|
|
6
|
+
|
|
7
|
+
function invalidDirectoryPlan(inputPath) {
|
|
8
|
+
const resolved = path.resolve(inputPath);
|
|
9
|
+
return {
|
|
10
|
+
kdna_version: null,
|
|
11
|
+
asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
|
|
12
|
+
access: null,
|
|
13
|
+
access_alias: null,
|
|
14
|
+
entitlement_profile: null,
|
|
15
|
+
state: 'invalid',
|
|
16
|
+
required_action: 'block',
|
|
17
|
+
can_load_now: false,
|
|
18
|
+
projection_policy: 'none',
|
|
19
|
+
input_fingerprint: null,
|
|
20
|
+
checks: {
|
|
21
|
+
format_valid: false,
|
|
22
|
+
schema_valid: false,
|
|
23
|
+
payload_valid: false,
|
|
24
|
+
checksums_valid: false,
|
|
25
|
+
load_contract_valid: false,
|
|
26
|
+
overall_valid: false,
|
|
27
|
+
},
|
|
28
|
+
issues: [{
|
|
29
|
+
code: 'KDNA_ASSET_FILE_REQUIRED',
|
|
30
|
+
severity: 'blocking',
|
|
31
|
+
message: 'Runtime loading requires a packaged .kdna asset file. Source directories are authoring inputs only.',
|
|
32
|
+
}],
|
|
33
|
+
source: { kind: 'dir', path: resolved },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isDirectory(inputPath) {
|
|
38
|
+
if (typeof inputPath !== 'string') return false;
|
|
39
|
+
try {
|
|
40
|
+
return fs.statSync(path.resolve(inputPath)).isDirectory();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function guardResolver(options = {}) {
|
|
47
|
+
if (typeof options.resolveAsset !== 'function') return options;
|
|
48
|
+
const resolveAsset = options.resolveAsset;
|
|
49
|
+
return {
|
|
50
|
+
...options,
|
|
51
|
+
resolveAsset(name) {
|
|
52
|
+
const resolved = resolveAsset(name);
|
|
53
|
+
if (resolved?.path && isDirectory(resolved.path)) {
|
|
54
|
+
const error = new Error(
|
|
55
|
+
`Resolved asset "${name}" is a source directory. Runtime dependencies must be packaged .kdna files.`,
|
|
56
|
+
);
|
|
57
|
+
error.code = 'KDNA_ASSET_FILE_REQUIRED';
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function planLoad(inputPath, options = {}) {
|
|
66
|
+
if (isDirectory(inputPath)) return invalidDirectoryPlan(inputPath);
|
|
67
|
+
return v1.planLoad(inputPath, guardResolver(options));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function loadAuthorized(inputPath, options = {}) {
|
|
71
|
+
const plan = planLoad(inputPath, options);
|
|
72
|
+
if (plan.can_load_now !== true) {
|
|
73
|
+
const issueCodes = Array.isArray(plan.issues)
|
|
74
|
+
? plan.issues.map((issue) => issue.code).filter(Boolean)
|
|
75
|
+
: [];
|
|
76
|
+
const error = new Error(
|
|
77
|
+
`LoadPlan denied loading: state=${plan.state || 'invalid'} required_action=${plan.required_action || 'block'}`,
|
|
78
|
+
);
|
|
79
|
+
error.code = issueCodes[0] || 'KDNA_LOAD_NOT_AUTHORIZED';
|
|
80
|
+
error.plan = plan;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
return v1.loadAuthorized(inputPath, guardResolver(options));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
planLoad,
|
|
88
|
+
loadAuthorized,
|
|
89
|
+
load: loadAuthorized,
|
|
90
|
+
loadAsset: loadAuthorized,
|
|
91
|
+
};
|
package/src/types.d.ts
CHANGED
|
@@ -414,6 +414,165 @@ export function createLicensedDecryptEntry(options: {
|
|
|
414
414
|
machineFingerprint: string;
|
|
415
415
|
}): NonNullable<KdnaDecryptOptions['decryptEntry']>;
|
|
416
416
|
|
|
417
|
+
export const EXTERNAL_ENVELOPE_PROFILE: 'kdna-envelope-external-grant-v1';
|
|
418
|
+
export const EXTERNAL_GRANT_PROFILE: 'kdna-key-grant-v1';
|
|
419
|
+
|
|
420
|
+
export class KDNAExternalGrantError extends Error {
|
|
421
|
+
code: string;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface KDNAExternalGrantEnvelope {
|
|
425
|
+
profile: 'kdna-envelope-external-grant-v1';
|
|
426
|
+
alg: 'A256GCM';
|
|
427
|
+
cek_derivation: 'HKDF-SHA256';
|
|
428
|
+
key_ref: string;
|
|
429
|
+
issuer_key_id: string;
|
|
430
|
+
entry_path: string;
|
|
431
|
+
plaintext_digest: string;
|
|
432
|
+
iv: string;
|
|
433
|
+
tag: string;
|
|
434
|
+
ciphertext: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export interface KDNAExternalKeyGrant {
|
|
438
|
+
profile: 'kdna-key-grant-v1';
|
|
439
|
+
grant_id: string;
|
|
440
|
+
issuer: string;
|
|
441
|
+
signing_key_id: string;
|
|
442
|
+
entitlement_id: string;
|
|
443
|
+
account_id: string;
|
|
444
|
+
device_id: string;
|
|
445
|
+
device_public_key: string;
|
|
446
|
+
device_signing_public_key: string;
|
|
447
|
+
asset: {
|
|
448
|
+
asset_id: string;
|
|
449
|
+
asset_uid: string;
|
|
450
|
+
version: string;
|
|
451
|
+
digest: string;
|
|
452
|
+
entry_path: string;
|
|
453
|
+
ciphertext_digest: string;
|
|
454
|
+
key_ref: string;
|
|
455
|
+
issuer_key_id: string;
|
|
456
|
+
};
|
|
457
|
+
issued_at: string;
|
|
458
|
+
refresh_after: string;
|
|
459
|
+
offline_grace_until: string;
|
|
460
|
+
expires_at: string;
|
|
461
|
+
status: 'active' | 'revoked' | 'expired';
|
|
462
|
+
status_version: number;
|
|
463
|
+
wrap: {
|
|
464
|
+
alg: 'X25519-HKDF-SHA256+A256KW';
|
|
465
|
+
ephemeral_public_key: string;
|
|
466
|
+
salt: string;
|
|
467
|
+
wrapped_cek: string;
|
|
468
|
+
};
|
|
469
|
+
signature: string;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export interface KDNADeviceKeyPairs {
|
|
473
|
+
agreement: { public_key: string; private_key: string };
|
|
474
|
+
signing: { public_key: string; private_key: string };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Runtime-branded by authorizeExternalKeyGrant; a structurally similar object is not accepted. */
|
|
478
|
+
export interface KDNAVerifiedExternalEntitlement {
|
|
479
|
+
readonly status: 'active' | 'offline_grace';
|
|
480
|
+
readonly profile: 'kdna-key-grant-v1';
|
|
481
|
+
readonly grant_id: string;
|
|
482
|
+
readonly entitlement_id: string;
|
|
483
|
+
readonly account_id: string;
|
|
484
|
+
readonly device_id: string;
|
|
485
|
+
readonly refresh_after: string;
|
|
486
|
+
readonly offline_grace_until: string;
|
|
487
|
+
readonly expires_at: string;
|
|
488
|
+
readonly asset: {
|
|
489
|
+
readonly asset_id: string;
|
|
490
|
+
readonly asset_uid: string;
|
|
491
|
+
readonly version: string;
|
|
492
|
+
readonly digest: string;
|
|
493
|
+
readonly entry_path: string;
|
|
494
|
+
readonly ciphertext_digest: string;
|
|
495
|
+
readonly key_ref: string;
|
|
496
|
+
readonly issuer_key_id: string;
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface KDNAExternalAuthorization {
|
|
501
|
+
entitlement: KDNAVerifiedExternalEntitlement;
|
|
502
|
+
decryptEntry: NonNullable<KdnaDecryptOptions['decryptEntry']>;
|
|
503
|
+
dispose(): void;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function canonicalJson(value: unknown): string;
|
|
507
|
+
export function grantSigningPayload(grant: Omit<KDNAExternalKeyGrant, 'signature'> | KDNAExternalKeyGrant): Uint8Array;
|
|
508
|
+
export function validateExternalEnvelope(value: unknown): KDNAExternalGrantEnvelope;
|
|
509
|
+
export function validateExternalKeyGrant(value: unknown): KDNAExternalKeyGrant;
|
|
510
|
+
export function externalEnvelopeAad(options: {
|
|
511
|
+
manifest: KDNAManifest;
|
|
512
|
+
entryName: string;
|
|
513
|
+
plaintextDigest: string;
|
|
514
|
+
keyRef: string;
|
|
515
|
+
issuerKeyId: string;
|
|
516
|
+
}): Uint8Array;
|
|
517
|
+
export function deriveExternalAssetCek(options: {
|
|
518
|
+
issuerRootKey: string | Uint8Array;
|
|
519
|
+
manifest: KDNAManifest;
|
|
520
|
+
entryName: string;
|
|
521
|
+
plaintextDigest: string;
|
|
522
|
+
keyRef: string;
|
|
523
|
+
issuerKeyId: string;
|
|
524
|
+
}): Uint8Array;
|
|
525
|
+
export function encodeExternalEnvelope(value: KDNAExternalGrantEnvelope): Uint8Array;
|
|
526
|
+
export function decodeExternalEnvelope(value: KDNAExternalGrantEnvelope | Uint8Array): KDNAExternalGrantEnvelope;
|
|
527
|
+
export function encryptExternalGrantEntry(plaintext: string | Uint8Array, options: {
|
|
528
|
+
manifest: KDNAManifest;
|
|
529
|
+
entryName?: string;
|
|
530
|
+
issuerRootKey: string | Uint8Array;
|
|
531
|
+
keyRef: string;
|
|
532
|
+
issuerKeyId: string;
|
|
533
|
+
iv?: Uint8Array;
|
|
534
|
+
}): KDNAExternalGrantEnvelope;
|
|
535
|
+
export function generateDeviceKeyPairs(): KDNADeviceKeyPairs;
|
|
536
|
+
export function createExternalKeyGrant(options: {
|
|
537
|
+
issuerRootKey: string | Uint8Array;
|
|
538
|
+
issuerSigningPrivateKey: unknown;
|
|
539
|
+
signingKeyId: string;
|
|
540
|
+
issuer: string;
|
|
541
|
+
entitlementId: string;
|
|
542
|
+
accountId: string;
|
|
543
|
+
deviceId: string;
|
|
544
|
+
devicePublicKey: string;
|
|
545
|
+
deviceSigningPublicKey: string;
|
|
546
|
+
manifest: KDNAManifest;
|
|
547
|
+
envelope: KDNAExternalGrantEnvelope | Uint8Array;
|
|
548
|
+
assetDigest: string;
|
|
549
|
+
status?: 'active' | 'revoked' | 'expired';
|
|
550
|
+
statusVersion?: number;
|
|
551
|
+
issuedAt?: Date | string;
|
|
552
|
+
refreshAfter?: Date | string;
|
|
553
|
+
offlineGraceUntil?: Date | string;
|
|
554
|
+
expiresAt?: Date | string;
|
|
555
|
+
grantId?: string;
|
|
556
|
+
ephemeralKeyPair?: unknown;
|
|
557
|
+
wrapSalt?: Uint8Array;
|
|
558
|
+
}): KDNAExternalKeyGrant;
|
|
559
|
+
export function authorizeExternalKeyGrant(options: {
|
|
560
|
+
grant: KDNAExternalKeyGrant;
|
|
561
|
+
issuerPublicKeys: Map<string, unknown> | Record<string, unknown>;
|
|
562
|
+
manifest: KDNAManifest;
|
|
563
|
+
checksums: KDNAChecksums;
|
|
564
|
+
envelope: KDNAExternalGrantEnvelope | Uint8Array;
|
|
565
|
+
deviceAgreementKey: KDNADeviceKeyPairs['agreement'] | unknown;
|
|
566
|
+
expectedAccountId: string;
|
|
567
|
+
expectedDeviceId: string;
|
|
568
|
+
expectedDeviceSigningPublicKey: string;
|
|
569
|
+
minimumStatusVersion?: number;
|
|
570
|
+
now?: Date | string;
|
|
571
|
+
networkAvailable?: boolean;
|
|
572
|
+
allowOffline?: boolean;
|
|
573
|
+
}): KDNAExternalAuthorization;
|
|
574
|
+
export function isVerifiedExternalEntitlement(value: unknown): value is KDNAVerifiedExternalEntitlement;
|
|
575
|
+
|
|
417
576
|
// Stable public API — preferred entry points for third-party integrations.
|
|
418
577
|
export type KDNAAssetInput = string | Uint8Array | KdnaAsset;
|
|
419
578
|
|
|
@@ -482,7 +641,7 @@ export function matchDomain(input: string, candidates: Array<KDNAAssetInput | KD
|
|
|
482
641
|
export function matchDomainSync(input: string, candidates: Array<KDNAAssetInput | KDNAInspectResult>, options?: KdnaDecryptOptions): KDNAMatchResult[];
|
|
483
642
|
export function composeKDNA(inputs: KDNAAssetInput[], options?: { input?: string; profile?: 'compact' | 'scenario' | 'full' | string; separator?: string } & KdnaDecryptOptions): Promise<KDNAComposeResult>;
|
|
484
643
|
|
|
485
|
-
// KDNA Core — source
|
|
644
|
+
// KDNA Core — authoring source and packaged runtime API
|
|
486
645
|
export const MIMETYPE: string;
|
|
487
646
|
export const REQUIRED_DIR_ENTRIES: string[];
|
|
488
647
|
|
|
@@ -553,7 +712,7 @@ export interface KDNALoadPlan {
|
|
|
553
712
|
path: string;
|
|
554
713
|
};
|
|
555
714
|
}
|
|
556
|
-
export function planLoad(inputPath: string, options?: { password?: string; hasPassword?: boolean; entitlement?: Record<string, any> }): KDNALoadPlan;
|
|
715
|
+
export function planLoad(inputPath: string, options?: { password?: string; hasPassword?: boolean; entitlement?: KDNAVerifiedExternalEntitlement | Record<string, any> }): KDNALoadPlan;
|
|
557
716
|
export function buildChecksums(sourceDir: string): KDNAChecksums;
|
|
558
717
|
export function pack(sourceDir: string, outputPath: string): void;
|
|
559
718
|
export function unpack(inputPath: string, outputDir: string): void;
|
package/src/v1/index.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* kdna monorepo CLI shim.
|
|
2
|
+
* KDNA Asset Container inspect / validate / pack / unpack implementation.
|
|
4
3
|
*
|
|
5
4
|
* KDNA Core is the official KDNA judgment-asset format and runtime
|
|
6
5
|
* loading contract. .kdna assets are created, inspected, packed,
|
|
7
6
|
* unpacked, and validated through the official KDNA toolchain. This
|
|
8
|
-
* module is the v1 component of that toolchain.
|
|
9
7
|
*
|
|
10
|
-
* The KDNA
|
|
8
|
+
* The KDNA Asset Container is documented in docs/core/file-format.md.
|
|
11
9
|
* This module is the shared implementation that:
|
|
12
10
|
*
|
|
13
|
-
* - packages/kdna/bin/kdna.js uses as
|
|
14
|
-
* - scripts
|
|
11
|
+
* - packages/kdna/bin/kdna.js uses as its container router
|
|
12
|
+
* - compatibility scripts delegate to it so historical tooling
|
|
15
13
|
* scripts and the official CLI cannot drift
|
|
16
14
|
*
|
|
17
15
|
* Hard rules from the format spec:
|
|
@@ -39,6 +37,11 @@ const path = require('node:path');
|
|
|
39
37
|
const zlib = require('node:zlib');
|
|
40
38
|
const crypto = require('node:crypto');
|
|
41
39
|
const cbor = require('cbor-x');
|
|
40
|
+
const {
|
|
41
|
+
EXTERNAL_ENVELOPE_PROFILE,
|
|
42
|
+
validateExternalEnvelope,
|
|
43
|
+
isVerifiedExternalEntitlement,
|
|
44
|
+
} = require('../external-key-grant');
|
|
42
45
|
|
|
43
46
|
const { readAsset } = (() => {
|
|
44
47
|
try {
|
|
@@ -178,7 +181,7 @@ function loadSchemas() {
|
|
|
178
181
|
// ─── Format detection ──────────────────────────────────────────────────
|
|
179
182
|
|
|
180
183
|
/**
|
|
181
|
-
* Detect whether a directory is a
|
|
184
|
+
* Detect whether a directory is a KDNA authoring source layout.
|
|
182
185
|
* Required entries: mimetype, kdna.json, payload.kdnab.
|
|
183
186
|
* mimetype content must equal "application/vnd.kdna.asset".
|
|
184
187
|
*/
|
|
@@ -536,10 +539,10 @@ function readV1Layout(absPath) {
|
|
|
536
539
|
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
537
540
|
const full = path.join(absPath, f);
|
|
538
541
|
if (!fs.existsSync(full)) {
|
|
539
|
-
throw new Error(`not a KDNA
|
|
542
|
+
throw new Error(`not a KDNA authoring source directory: missing ${f}`);
|
|
540
543
|
}
|
|
541
544
|
if (fs.lstatSync(full).isSymbolicLink()) {
|
|
542
|
-
throw new Error(`not a KDNA
|
|
545
|
+
throw new Error(`not a KDNA authoring source directory: ${f} must not be a symlink`);
|
|
543
546
|
}
|
|
544
547
|
}
|
|
545
548
|
for (const f of [...V1_REQUIRED_DIR_ENTRIES, ...V1_OPTIONAL_DIR_ENTRIES]) {
|
|
@@ -557,10 +560,10 @@ function readV1Layout(absPath) {
|
|
|
557
560
|
kind = 'file';
|
|
558
561
|
entries = listZipEntries(absPath);
|
|
559
562
|
if (entries.length === 0 || entries[0].name !== 'mimetype') {
|
|
560
|
-
throw new Error('not a KDNA
|
|
563
|
+
throw new Error('not a KDNA Asset Container: first entry is not mimetype');
|
|
561
564
|
}
|
|
562
565
|
if (entries[0].method !== 0) {
|
|
563
|
-
throw new Error('not a KDNA
|
|
566
|
+
throw new Error('not a KDNA Asset Container: mimetype must be uncompressed');
|
|
564
567
|
}
|
|
565
568
|
for (const e of entries) {
|
|
566
569
|
// We only need the well-known entries; signatures/ attachments/ etc.
|
|
@@ -576,7 +579,7 @@ function readV1Layout(absPath) {
|
|
|
576
579
|
}
|
|
577
580
|
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
578
581
|
if (!map[f]) {
|
|
579
|
-
throw new Error(`not a KDNA
|
|
582
|
+
throw new Error(`not a KDNA Asset Container: missing ${f}`);
|
|
580
583
|
}
|
|
581
584
|
}
|
|
582
585
|
} else {
|
|
@@ -725,7 +728,7 @@ function runValidate(v1) {
|
|
|
725
728
|
if (!validators) {
|
|
726
729
|
result.schema_valid = false;
|
|
727
730
|
problems.push(
|
|
728
|
-
'schema:
|
|
731
|
+
'schema: KDNA Core requires ajv and ajv-formats for JSON-Schema validation. Run: npm install ajv ajv-formats (or: npm install -g ajv ajv-formats for global CLI users)',
|
|
729
732
|
);
|
|
730
733
|
return finalizeValidate(result, problems);
|
|
731
734
|
}
|
|
@@ -758,7 +761,16 @@ function runValidate(v1) {
|
|
|
758
761
|
result.payload_valid = false;
|
|
759
762
|
problems.push(`payload: encrypted envelope profile ${payload.profile || 'unknown'} does not match manifest encryption profile ${encProfile}`);
|
|
760
763
|
}
|
|
761
|
-
|
|
764
|
+
let hasKeyMaterial = !!payload.wrapped_key || (Array.isArray(payload.key_slots) && payload.key_slots.length > 0);
|
|
765
|
+
if (payload.profile === EXTERNAL_ENVELOPE_PROFILE) {
|
|
766
|
+
try {
|
|
767
|
+
validateExternalEnvelope(payload);
|
|
768
|
+
hasKeyMaterial = true;
|
|
769
|
+
} catch (error) {
|
|
770
|
+
result.payload_valid = false;
|
|
771
|
+
problems.push(`payload: ${error.message}`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
762
774
|
if (!payload.profile || !payload.ciphertext || !hasKeyMaterial) {
|
|
763
775
|
result.payload_valid = false;
|
|
764
776
|
problems.push('payload: encrypted envelope missing required fields (profile/ciphertext/key material)');
|
|
@@ -919,7 +931,7 @@ function buildChecksumsV1(sourceDir) {
|
|
|
919
931
|
// ─── pack ──────────────────────────────────────────────────────────────
|
|
920
932
|
|
|
921
933
|
/**
|
|
922
|
-
* Pack
|
|
934
|
+
* Pack an authoring source directory into a .kdna asset. Output is
|
|
923
935
|
* deterministic: the same source directory packed twice produces
|
|
924
936
|
* byte-identical output (fixed DOS timestamps, fixed entry order,
|
|
925
937
|
* mimetype first).
|
|
@@ -1017,7 +1029,7 @@ function unpack(inputPath, outputDir) {
|
|
|
1017
1029
|
throw new Error(`not a file: ${absIn}`);
|
|
1018
1030
|
}
|
|
1019
1031
|
const entries = listZipEntries(absIn);
|
|
1020
|
-
// Sanity:
|
|
1032
|
+
// Sanity: the asset must have mimetype as its first, uncompressed entry.
|
|
1021
1033
|
if (entries.length === 0 || entries[0].name !== 'mimetype') {
|
|
1022
1034
|
throw new Error('not a KDNA container: first entry is not mimetype');
|
|
1023
1035
|
}
|
|
@@ -1101,12 +1113,11 @@ function finalizeLoadPlan(plan) {
|
|
|
1101
1113
|
return plan;
|
|
1102
1114
|
}
|
|
1103
1115
|
|
|
1104
|
-
function computeSourceFingerprint(
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1116
|
+
function computeSourceFingerprint(_inputPath, v1) {
|
|
1117
|
+
// Fingerprint the logical entries, not the transport ZIP bytes. DEFLATE
|
|
1118
|
+
// output can differ between zlib versions and operating systems even when
|
|
1119
|
+
// every uncompressed KDNA entry is identical. LoadPlan conformance must be
|
|
1120
|
+
// stable across JS, Swift, CI runners, and repackaging transports.
|
|
1110
1121
|
const hash = crypto.createHash('sha256');
|
|
1111
1122
|
for (const name of Object.keys(v1.map).sort()) {
|
|
1112
1123
|
const bytes = v1.map[name];
|
|
@@ -1211,14 +1222,15 @@ function canonicalToV1Layout(asset) {
|
|
|
1211
1222
|
}
|
|
1212
1223
|
|
|
1213
1224
|
/**
|
|
1214
|
-
* Plan a KDNA
|
|
1225
|
+
* Plan a KDNA runtime load without decrypting or emitting judgment content.
|
|
1215
1226
|
* Product consumers such as Chat should render authorization UI from this
|
|
1216
1227
|
* result instead of parsing manifest fields directly.
|
|
1217
1228
|
*/
|
|
1218
1229
|
function planLoad(inputPath, opts = {}) {
|
|
1219
1230
|
let v1;
|
|
1220
1231
|
|
|
1221
|
-
// Try the unified container dispatcher first
|
|
1232
|
+
// Try the unified container dispatcher first; the internal compatibility
|
|
1233
|
+
// reader below remains available only for authoring and migration tooling.
|
|
1222
1234
|
if (readAsset !== null) {
|
|
1223
1235
|
try {
|
|
1224
1236
|
const asset = readAsset(path.resolve(inputPath));
|
|
@@ -1278,6 +1290,36 @@ function planLoad(inputPath, opts = {}) {
|
|
|
1278
1290
|
|
|
1279
1291
|
const manifest = v1.manifest;
|
|
1280
1292
|
|
|
1293
|
+
function externalEntitlementMatchesCurrentAsset(entitlement) {
|
|
1294
|
+
if (!isVerifiedExternalEntitlement(entitlement) || !entitlement.asset) return false;
|
|
1295
|
+
let checksums;
|
|
1296
|
+
try {
|
|
1297
|
+
checksums = v1.map['checksums.json']
|
|
1298
|
+
? parseJsonEntry('checksums.json', v1.map['checksums.json'])
|
|
1299
|
+
: null;
|
|
1300
|
+
} catch {
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
return entitlement.asset.asset_id === (manifest.asset_id || manifest.name)
|
|
1304
|
+
&& entitlement.asset.asset_uid === manifest.asset_uid
|
|
1305
|
+
&& entitlement.asset.version === manifest.version
|
|
1306
|
+
&& entitlement.asset.digest === checksums?.asset_digest
|
|
1307
|
+
&& entitlement.asset.entry_path === manifest.payload?.path;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function rejectExternalEntitlementMismatch() {
|
|
1311
|
+
plan.state = 'invalid';
|
|
1312
|
+
plan.required_action = 'block';
|
|
1313
|
+
plan.can_load_now = false;
|
|
1314
|
+
plan.projection_policy = 'none';
|
|
1315
|
+
plan.issues.push(buildLoadPlanIssue(
|
|
1316
|
+
'KDNA_GRANT_ASSET_MISMATCH',
|
|
1317
|
+
'blocking',
|
|
1318
|
+
'The verified account/device grant is bound to a different asset, version, digest, or encrypted entry.',
|
|
1319
|
+
));
|
|
1320
|
+
return finalizeLoadPlan(plan);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1281
1323
|
// Resolve dependencies if declared (Story 6)
|
|
1282
1324
|
const resolvedDeps = [];
|
|
1283
1325
|
if (manifest.dependencies && typeof manifest.dependencies === 'object' && Object.keys(manifest.dependencies).length > 0) {
|
|
@@ -1449,6 +1491,23 @@ function planLoad(inputPath, opts = {}) {
|
|
|
1449
1491
|
}
|
|
1450
1492
|
|
|
1451
1493
|
if (plan.entitlement_profile === 'account') {
|
|
1494
|
+
if (isVerifiedExternalEntitlement(opts.entitlement)) {
|
|
1495
|
+
if (!externalEntitlementMatchesCurrentAsset(opts.entitlement)) {
|
|
1496
|
+
return rejectExternalEntitlementMismatch();
|
|
1497
|
+
}
|
|
1498
|
+
plan.state = opts.entitlement.status === 'offline_grace' ? 'offline_grace' : 'ready';
|
|
1499
|
+
plan.required_action = opts.entitlement.status === 'offline_grace' ? 'sync' : 'load';
|
|
1500
|
+
plan.can_load_now = true;
|
|
1501
|
+
plan.projection_policy = 'minimal';
|
|
1502
|
+
if (opts.entitlement.status === 'offline_grace') {
|
|
1503
|
+
plan.issues.push(buildLoadPlanIssue(
|
|
1504
|
+
'KDNA_AUTH_OFFLINE_GRACE_ACTIVE',
|
|
1505
|
+
'warning',
|
|
1506
|
+
'The verified device grant can load offline but must sync before grace expires.',
|
|
1507
|
+
));
|
|
1508
|
+
}
|
|
1509
|
+
return finalizeLoadPlan(plan);
|
|
1510
|
+
}
|
|
1452
1511
|
plan.state = 'needs_account';
|
|
1453
1512
|
plan.required_action = 'sign_in_or_activate';
|
|
1454
1513
|
plan.can_load_now = false;
|
|
@@ -1462,6 +1521,16 @@ function planLoad(inputPath, opts = {}) {
|
|
|
1462
1521
|
}
|
|
1463
1522
|
|
|
1464
1523
|
if (plan.entitlement_profile === 'org') {
|
|
1524
|
+
if (isVerifiedExternalEntitlement(opts.entitlement)) {
|
|
1525
|
+
if (!externalEntitlementMatchesCurrentAsset(opts.entitlement)) {
|
|
1526
|
+
return rejectExternalEntitlementMismatch();
|
|
1527
|
+
}
|
|
1528
|
+
plan.state = opts.entitlement.status === 'offline_grace' ? 'offline_grace' : 'ready';
|
|
1529
|
+
plan.required_action = opts.entitlement.status === 'offline_grace' ? 'sync' : 'load';
|
|
1530
|
+
plan.can_load_now = true;
|
|
1531
|
+
plan.projection_policy = 'minimal';
|
|
1532
|
+
return finalizeLoadPlan(plan);
|
|
1533
|
+
}
|
|
1465
1534
|
plan.state = 'needs_org_auth';
|
|
1466
1535
|
plan.required_action = 'sign_in_or_activate';
|
|
1467
1536
|
plan.can_load_now = false;
|
|
@@ -1828,7 +1897,7 @@ function loadV1Unsafe(inputPath, opts = {}) {
|
|
|
1828
1897
|
payload = parsePayloadEntry('payload.kdnab', decryptedBuf);
|
|
1829
1898
|
} catch (e) {
|
|
1830
1899
|
const err = new Error(`Decryption failed: ${e.message}`);
|
|
1831
|
-
err.code = 'KDNA_DECRYPT_FAILED';
|
|
1900
|
+
err.code = e.code || 'KDNA_DECRYPT_FAILED';
|
|
1832
1901
|
throw err;
|
|
1833
1902
|
}
|
|
1834
1903
|
} else if (opts.password) {
|
|
@@ -1859,8 +1928,13 @@ function loadV1Unsafe(inputPath, opts = {}) {
|
|
|
1859
1928
|
throw err;
|
|
1860
1929
|
}
|
|
1861
1930
|
} else {
|
|
1862
|
-
const
|
|
1863
|
-
err
|
|
1931
|
+
const isExternal = payload.profile === EXTERNAL_ENVELOPE_PROFILE;
|
|
1932
|
+
const err = new Error(
|
|
1933
|
+
isExternal
|
|
1934
|
+
? 'payload requires a verified account/device external key grant'
|
|
1935
|
+
: 'payload is encrypted and cannot be decrypted without an authorized credential',
|
|
1936
|
+
);
|
|
1937
|
+
err.code = isExternal ? 'KDNA_AUTH_ACCOUNT_REQUIRED' : 'KDNA_AUTH_PASSWORD_REQUIRED';
|
|
1864
1938
|
throw err;
|
|
1865
1939
|
}
|
|
1866
1940
|
}
|
|
@@ -1891,7 +1965,7 @@ function loadV1Unsafe(inputPath, opts = {}) {
|
|
|
1891
1965
|
const hasJudgment = (core.axioms && core.axioms.length > 0)
|
|
1892
1966
|
|| (core.boundaries && core.boundaries.length > 0)
|
|
1893
1967
|
|| (payload.patterns && payload.patterns.length > 0)
|
|
1894
|
-
|| (payload.reasoning && ((payload.reasoning.
|
|
1968
|
+
|| (payload.reasoning && ((payload.reasoning.self_check && payload.reasoning.self_check.length > 0) || (payload.reasoning.failure_modes && payload.reasoning.failure_modes.length > 0)));
|
|
1895
1969
|
profiles.push('index');
|
|
1896
1970
|
if (hasJudgment) profiles.push('compact');
|
|
1897
1971
|
if (payload.scenarios && payload.scenarios.length > 0) profiles.push('scenario');
|
|
@@ -1926,7 +2000,11 @@ function loadV1Unsafe(inputPath, opts = {}) {
|
|
|
1926
2000
|
highest_question: core.highest_question || null,
|
|
1927
2001
|
axioms: (core.axioms || []).map(normalizeCompactAxiom).filter(Boolean),
|
|
1928
2002
|
boundaries: normalizeList(core.boundaries),
|
|
1929
|
-
|
|
2003
|
+
// The canonical payload field is singular (`reasoning.self_check`).
|
|
2004
|
+
// Runtime Capsule uses plural (`context.self_checks`) as its projection
|
|
2005
|
+
// field. Reading the plural name from the payload silently discarded
|
|
2006
|
+
// every Studio-authored self-check.
|
|
2007
|
+
self_checks: normalizeList(payload.reasoning && payload.reasoning.self_check),
|
|
1930
2008
|
failure_modes: normalizeList(payload.reasoning && payload.reasoning.failure_modes),
|
|
1931
2009
|
patterns: normalizeList(payload.patterns).slice(0, 3),
|
|
1932
2010
|
};
|