@aikdna/kdna-core 0.20.0 → 0.22.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 +71 -0
- package/README.md +12 -2
- package/package.json +2 -2
- package/schema/load-contract.schema.json +3 -2
- package/schema/load-plan.schema.json +7 -0
- package/schema/manifest.schema.json +24 -14
- package/schema/payload-profile.schema.json +156 -6
- package/schema/runtime-capsule.schema.json +67 -10
- package/src/asset-reader.js +30 -105
- package/src/container/index.js +550 -44
- package/src/crypto-profile.js +18 -3
- package/src/external-key-grant.js +4 -8
- package/src/index.js +2 -0
- package/src/index.mjs +22 -2
- package/src/public-api.js +55 -13
- package/src/runtime-capsule.js +84 -1
- package/src/signature.js +320 -0
- package/src/types.d.ts +109 -19
package/src/crypto-profile.js
CHANGED
|
@@ -278,6 +278,18 @@ function ensureArgon2id() {
|
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
const MAX_ARGON2_MEMORY_KIB = 262144; // 256 MiB
|
|
282
|
+
const MAX_ARGON2_ITERATIONS = 16;
|
|
283
|
+
const MAX_ARGON2_PARALLELISM = 8;
|
|
284
|
+
|
|
285
|
+
function boundedArgon2Param(value, name, max) {
|
|
286
|
+
if (value === undefined) return undefined;
|
|
287
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
288
|
+
throw new Error(`unsupported Argon2id ${name}: must be an integer between 1 and ${max}`);
|
|
289
|
+
}
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
|
|
281
293
|
function derivePasswordKey(password, params = {}) {
|
|
282
294
|
ensureArgon2id();
|
|
283
295
|
const {
|
|
@@ -287,12 +299,15 @@ function derivePasswordKey(password, params = {}) {
|
|
|
287
299
|
parallelism = 4,
|
|
288
300
|
} = params;
|
|
289
301
|
if (!salt) throw new Error('salt is required for Argon2id');
|
|
302
|
+
const boundedMemory = boundedArgon2Param(memory_kib, 'memory_kib', MAX_ARGON2_MEMORY_KIB);
|
|
303
|
+
const boundedIterations = boundedArgon2Param(iterations, 'iterations', MAX_ARGON2_ITERATIONS);
|
|
304
|
+
const boundedParallelism = boundedArgon2Param(parallelism, 'parallelism', MAX_ARGON2_PARALLELISM);
|
|
290
305
|
const saltBuf = decodeBase64(salt, 'salt');
|
|
291
306
|
const passwordBuf = toBuffer(password, 'password');
|
|
292
307
|
const key = argon2id(passwordBuf, saltBuf, {
|
|
293
|
-
t:
|
|
294
|
-
m:
|
|
295
|
-
p:
|
|
308
|
+
t: boundedIterations,
|
|
309
|
+
m: boundedMemory,
|
|
310
|
+
p: boundedParallelism,
|
|
296
311
|
dkLen: 32,
|
|
297
312
|
});
|
|
298
313
|
return Buffer.from(key);
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const crypto = require('node:crypto');
|
|
10
|
-
const path = require('node:path');
|
|
11
10
|
const JsonSchema2020 = require('ajv/dist/2020');
|
|
12
11
|
const addFormats = require('ajv-formats');
|
|
13
12
|
const cbor = require('cbor-x');
|
|
14
13
|
const { hkdfSha256, aesWrap, aesUnwrap } = require('./crypto-profile');
|
|
14
|
+
const externalEnvelopeSchema = require('../schema/external-grant-envelope.schema.json');
|
|
15
|
+
const externalKeyGrantSchema = require('../schema/external-key-grant.schema.json');
|
|
15
16
|
|
|
16
17
|
const EXTERNAL_ENVELOPE_PROFILE = 'kdna.envelope.external-grant';
|
|
17
18
|
const EXTERNAL_GRANT_PROFILE = 'kdna.grant.external-key';
|
|
@@ -21,15 +22,10 @@ const EXTERNAL_GRANT_CONTRACT_VERSION = '0.1.0';
|
|
|
21
22
|
const ENVELOPE_ALG = 'A256GCM';
|
|
22
23
|
const GRANT_WRAP_ALG = 'X25519-HKDF-SHA256+A256KW';
|
|
23
24
|
|
|
24
|
-
const schemaRoot = path.join(__dirname, '..', 'schema');
|
|
25
25
|
const ajv = new JsonSchema2020({ allErrors: true, strict: true });
|
|
26
26
|
addFormats(ajv);
|
|
27
|
-
const validateEnvelopeSchema = ajv.compile(
|
|
28
|
-
|
|
29
|
-
);
|
|
30
|
-
const validateGrantSchema = ajv.compile(
|
|
31
|
-
require(path.join(schemaRoot, 'external-key-grant.schema.json')),
|
|
32
|
-
);
|
|
27
|
+
const validateEnvelopeSchema = ajv.compile(externalEnvelopeSchema);
|
|
28
|
+
const validateGrantSchema = ajv.compile(externalKeyGrantSchema);
|
|
33
29
|
|
|
34
30
|
const verifiedEntitlements = new WeakSet();
|
|
35
31
|
|
package/src/index.js
CHANGED
|
@@ -15,9 +15,11 @@ const runtimeApi = require('./runtime-api');
|
|
|
15
15
|
const runtimeCapsule = require('./runtime-capsule');
|
|
16
16
|
const runtimeContract = require('./runtime-contract');
|
|
17
17
|
const loaderCompatibility = require('./loader-compatibility');
|
|
18
|
+
const assetSignature = require('./signature');
|
|
18
19
|
|
|
19
20
|
module.exports = {
|
|
20
21
|
...publicApi,
|
|
22
|
+
...assetSignature,
|
|
21
23
|
...loader,
|
|
22
24
|
...validate,
|
|
23
25
|
...render,
|
package/src/index.mjs
CHANGED
|
@@ -17,10 +17,14 @@ export {
|
|
|
17
17
|
isKdnaSourceDir,
|
|
18
18
|
detectContainerFormat,
|
|
19
19
|
readLayout,
|
|
20
|
+
readLayoutBytes,
|
|
21
|
+
fullEntryBufferMap,
|
|
20
22
|
inspect,
|
|
21
23
|
validate,
|
|
22
24
|
buildChecksums,
|
|
23
25
|
pack,
|
|
26
|
+
packEntryMap,
|
|
27
|
+
signContainerBytes,
|
|
24
28
|
unpack,
|
|
25
29
|
FORBIDDEN_OUTPUT_TERMS,
|
|
26
30
|
parseSemver,
|
|
@@ -78,12 +82,27 @@ export const verifyAsset = publicApi.verifyAsset;
|
|
|
78
82
|
export const verifyAssetSync = publicApi.verifyAssetSync;
|
|
79
83
|
export const verifyDigest = publicApi.verifyDigest;
|
|
80
84
|
export const verifyDigestSync = publicApi.verifyDigestSync;
|
|
81
|
-
export const verifySignature = publicApi.verifySignature;
|
|
82
|
-
export const verifySignatureSync = publicApi.verifySignatureSync;
|
|
83
85
|
export const matchDomain = publicApi.matchDomain;
|
|
84
86
|
export const matchDomainSync = publicApi.matchDomainSync;
|
|
87
|
+
export const signKDNA = publicApi.signKDNA;
|
|
88
|
+
export const signKDNASync = publicApi.signKDNASync;
|
|
89
|
+
export const verifyKDNASignature = publicApi.verifyKDNASignature;
|
|
90
|
+
export const verifyKDNASignatureSync = publicApi.verifyKDNASignatureSync;
|
|
85
91
|
export const composeKDNA = publicApi.composeKDNA;
|
|
86
92
|
|
|
93
|
+
import assetSignature from './signature.js';
|
|
94
|
+
export const KDSIG_PROFILE = assetSignature.KDSIG_PROFILE;
|
|
95
|
+
export const KDSIG_PROFILE_VERSION = assetSignature.KDSIG_PROFILE_VERSION;
|
|
96
|
+
export const KDSIG_ALGORITHM = assetSignature.KDSIG_ALGORITHM;
|
|
97
|
+
export const SIGNATURE_ENTRY_NAME = assetSignature.SIGNATURE_ENTRY_NAME;
|
|
98
|
+
export const buildSigningPayload = assetSignature.buildSigningPayload;
|
|
99
|
+
export const generateSigningKeyPair = assetSignature.generateSigningKeyPair;
|
|
100
|
+
export const keyFingerprint = assetSignature.keyFingerprint;
|
|
101
|
+
export const parseSignatureBundle = assetSignature.parseSignatureBundle;
|
|
102
|
+
export const serializeSignatureBundle = assetSignature.serializeSignatureBundle;
|
|
103
|
+
export const signContentDigest = assetSignature.signContentDigest;
|
|
104
|
+
export const verifySignatureBundle = assetSignature.verifySignatureBundle;
|
|
105
|
+
|
|
87
106
|
export const DIGEST_PROFILE = runtimeCapsule.DIGEST_PROFILE;
|
|
88
107
|
export const DIGEST_PROFILE_VERSION = runtimeCapsule.DIGEST_PROFILE_VERSION;
|
|
89
108
|
export const CAPSULE_DIGEST_PROFILE = runtimeCapsule.CAPSULE_DIGEST_PROFILE;
|
|
@@ -123,6 +142,7 @@ export const validateJudgmentTrace = runtimeContract.validateJudgmentTrace;
|
|
|
123
142
|
|
|
124
143
|
export const STANDARD_ENTRIES = assetReader.STANDARD_ENTRIES;
|
|
125
144
|
export const createKdnaAssetReader = assetReader.createKdnaAssetReader;
|
|
145
|
+
export const contentDigestFromEntryBuffers = assetReader.contentDigestFromEntryBuffers;
|
|
126
146
|
|
|
127
147
|
export const {
|
|
128
148
|
LICENSED_ENTRY_PROFILE,
|
package/src/public-api.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const fs = require('fs');
|
|
11
|
-
const { createKdnaAssetReader } = require('./asset-reader');
|
|
11
|
+
const { createKdnaAssetReader, contentDigestFromEntryBuffers } = require('./asset-reader');
|
|
12
12
|
const container = require('./container');
|
|
13
13
|
const runtimeApi = require('./runtime-api');
|
|
14
|
+
const signature = require('./signature');
|
|
14
15
|
|
|
15
16
|
function readerFrom(options = {}) {
|
|
16
17
|
return options.reader || createKdnaAssetReader();
|
|
@@ -63,7 +64,6 @@ async function inspectKDNA(input, options = {}) {
|
|
|
63
64
|
entries,
|
|
64
65
|
asset_digest: asset.asset_digest,
|
|
65
66
|
content_digest: contentDigest,
|
|
66
|
-
signature_valid: verification ? verification.signature_valid : null,
|
|
67
67
|
ok: verification ? verification.ok : null,
|
|
68
68
|
errors: verification ? verification.errors : [],
|
|
69
69
|
warnings: verification ? verification.warnings : [],
|
|
@@ -84,7 +84,6 @@ function inspectKDNASync(input, options = {}) {
|
|
|
84
84
|
entries,
|
|
85
85
|
asset_digest: asset.asset_digest,
|
|
86
86
|
content_digest: contentDigest,
|
|
87
|
-
signature_valid: verification ? verification.signature_valid : null,
|
|
88
87
|
ok: verification ? verification.ok : null,
|
|
89
88
|
errors: verification ? verification.errors : [],
|
|
90
89
|
warnings: verification ? verification.warnings : [],
|
|
@@ -138,14 +137,6 @@ function verifyDigestSync(input, expectedDigest, options = {}) {
|
|
|
138
137
|
return verifyAssetSync(input, { ...options, asset_digest: expectedDigest });
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
async function verifySignature(input, options = {}) {
|
|
142
|
-
return verifyAsset(input, { ...options, requireSignature: true });
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function verifySignatureSync(input, options = {}) {
|
|
146
|
-
return verifyAssetSync(input, { ...options, requireSignature: true });
|
|
147
|
-
}
|
|
148
|
-
|
|
149
140
|
function scoreMatch(input, info) {
|
|
150
141
|
const haystack = String(input || '').toLowerCase();
|
|
151
142
|
const terms = [
|
|
@@ -186,6 +177,55 @@ function matchDomainSync(input, candidates, options = {}) {
|
|
|
186
177
|
return results.sort((a, b) => b.score - a.score || String(a.asset_id).localeCompare(String(b.asset_id)));
|
|
187
178
|
}
|
|
188
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Sign a packaged .kdna asset per RFC-0021 M1 (`kdsig.ed25519`). Returns the
|
|
182
|
+
* signed container bytes, the signature bundle, and the signed content
|
|
183
|
+
* digest. `privateKey` is the raw 32-byte Ed25519 seed as 64 lowercase hex
|
|
184
|
+
* characters. When `options.outputPath` is set the signed container is also
|
|
185
|
+
* written to that path.
|
|
186
|
+
*/
|
|
187
|
+
function signKDNASync(input, privateKey, options = {}) {
|
|
188
|
+
const result = container.signContainerBytes(canonicalBytes(input), privateKey, options);
|
|
189
|
+
if (typeof options.outputPath === 'string' && options.outputPath.length > 0) {
|
|
190
|
+
fs.writeFileSync(options.outputPath, result.containerBytes);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function signKDNA(input, privateKey, options = {}) {
|
|
196
|
+
return signKDNASync(input, privateKey, options);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Verify the RFC-0021 M1 (`kdsig.ed25519`) signature of a packaged .kdna
|
|
201
|
+
* asset offline. Fail-closed: any malformed, unsupported, or unverifiable
|
|
202
|
+
* bundle throws. Returns `{ state: 'absent' }` for unsigned assets unless
|
|
203
|
+
* `options.required` is true (then absence also throws), and the verified
|
|
204
|
+
* evidence otherwise. `options.expectedPublicKey` pins the signer key.
|
|
205
|
+
*/
|
|
206
|
+
function verifyKDNASignatureSync(input, options = {}) {
|
|
207
|
+
const layout = container.readLayoutBytes(canonicalBytes(input));
|
|
208
|
+
const bundleBytes = layout.map[signature.SIGNATURE_ENTRY_NAME];
|
|
209
|
+
if (bundleBytes === undefined) {
|
|
210
|
+
if (options.required === true) {
|
|
211
|
+
const error = new Error(
|
|
212
|
+
`required asset signature is absent: missing entry ${signature.SIGNATURE_ENTRY_NAME}`,
|
|
213
|
+
);
|
|
214
|
+
error.code = 'KDNA_SIGNATURE_ABSENT';
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
return { state: 'absent' };
|
|
218
|
+
}
|
|
219
|
+
const contentDigest = contentDigestFromEntryBuffers(container.fullEntryBufferMap(layout));
|
|
220
|
+
return signature.verifySignatureBundle(bundleBytes, contentDigest, {
|
|
221
|
+
expectedPublicKey: options.expectedPublicKey,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function verifyKDNASignature(input, options = {}) {
|
|
226
|
+
return verifyKDNASignatureSync(input, options);
|
|
227
|
+
}
|
|
228
|
+
|
|
189
229
|
async function composeKDNA(inputs, options = {}) {
|
|
190
230
|
void inputs;
|
|
191
231
|
void options;
|
|
@@ -211,9 +251,11 @@ module.exports = {
|
|
|
211
251
|
verifyAssetSync,
|
|
212
252
|
verifyDigest,
|
|
213
253
|
verifyDigestSync,
|
|
214
|
-
verifySignature,
|
|
215
|
-
verifySignatureSync,
|
|
216
254
|
matchDomain,
|
|
217
255
|
matchDomainSync,
|
|
256
|
+
signKDNA,
|
|
257
|
+
signKDNASync,
|
|
258
|
+
verifyKDNASignature,
|
|
259
|
+
verifyKDNASignatureSync,
|
|
218
260
|
composeKDNA,
|
|
219
261
|
};
|
package/src/runtime-capsule.js
CHANGED
|
@@ -8,6 +8,10 @@ const addFormats = require('ajv-formats');
|
|
|
8
8
|
|
|
9
9
|
const { createKdnaAssetReader } = require('./asset-reader');
|
|
10
10
|
const container = require('./container');
|
|
11
|
+
const {
|
|
12
|
+
KDSIG_PROFILE,
|
|
13
|
+
KDSIG_PROFILE_VERSION,
|
|
14
|
+
} = require('./signature');
|
|
11
15
|
const digestEvidenceSchema = require('../schema/digest-evidence.schema.json');
|
|
12
16
|
const runtimeCapsuleSchema = require('../schema/runtime-capsule.schema.json');
|
|
13
17
|
|
|
@@ -433,6 +437,82 @@ function assertSuccessfulDigestEvidence(digests) {
|
|
|
433
437
|
}
|
|
434
438
|
}
|
|
435
439
|
|
|
440
|
+
const SIGNATURE_EVIDENCE_FIELDS = Object.freeze({
|
|
441
|
+
absent: ['state'],
|
|
442
|
+
verified: ['state', 'profile', 'profile_version', 'key_fingerprint', 'content_digest'],
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Normalize Runtime Capsule signature evidence (RFC-0021 M1). Unsigned assets
|
|
447
|
+
* report `{ state: 'absent' }`; signed assets report the verified kdsig.ed25519
|
|
448
|
+
* evidence. Any other shape fails closed — signatures never downgrade to
|
|
449
|
+
* "absent" and unknown profiles are never collapsed into known ones.
|
|
450
|
+
*/
|
|
451
|
+
function normalizeSignatureEvidence(signature) {
|
|
452
|
+
if (signature === undefined) return { state: 'absent' };
|
|
453
|
+
if (!signature || typeof signature !== 'object' || Array.isArray(signature)) {
|
|
454
|
+
fail(
|
|
455
|
+
'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
|
|
456
|
+
'Runtime Capsule signature evidence must be an object.',
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (signature.state === 'absent') {
|
|
460
|
+
const extra = Object.keys(signature).filter((key) => key !== 'state');
|
|
461
|
+
if (extra.length) {
|
|
462
|
+
fail(
|
|
463
|
+
'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
|
|
464
|
+
'Absent signature evidence must not carry extra fields.',
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
return { state: 'absent' };
|
|
468
|
+
}
|
|
469
|
+
if (signature.state === 'verified') {
|
|
470
|
+
const keys = Object.keys(signature).sort();
|
|
471
|
+
const expected = [...SIGNATURE_EVIDENCE_FIELDS.verified].sort();
|
|
472
|
+
if (keys.join(',') !== expected.join(',')) {
|
|
473
|
+
fail(
|
|
474
|
+
'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
|
|
475
|
+
`Verified signature evidence must carry exactly: ${SIGNATURE_EVIDENCE_FIELDS.verified.join(', ')}.`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (signature.profile !== KDSIG_PROFILE) {
|
|
479
|
+
fail(
|
|
480
|
+
'KDNA_ASSET_SIGNATURE_UNSUPPORTED',
|
|
481
|
+
`Signature profile ${JSON.stringify(signature.profile)} is not supported (supported: ${KDSIG_PROFILE}).`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (signature.profile_version !== KDSIG_PROFILE_VERSION) {
|
|
485
|
+
fail(
|
|
486
|
+
'KDNA_ASSET_SIGNATURE_UNSUPPORTED',
|
|
487
|
+
`Signature profile_version ${JSON.stringify(signature.profile_version)} is not supported (supported: ${KDSIG_PROFILE_VERSION}).`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (!SHA256_RE.test(signature.key_fingerprint || '')) {
|
|
491
|
+
fail(
|
|
492
|
+
'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
|
|
493
|
+
'Verified signature evidence requires a sha256 key_fingerprint.',
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (!SHA256_RE.test(signature.content_digest || '')) {
|
|
497
|
+
fail(
|
|
498
|
+
'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
|
|
499
|
+
'Verified signature evidence requires a sha256 content_digest.',
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
state: 'verified',
|
|
504
|
+
profile: signature.profile,
|
|
505
|
+
profile_version: signature.profile_version,
|
|
506
|
+
key_fingerprint: signature.key_fingerprint,
|
|
507
|
+
content_digest: signature.content_digest,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
fail(
|
|
511
|
+
'KDNA_ASSET_SIGNATURE_UNSUPPORTED',
|
|
512
|
+
`Signature state ${JSON.stringify(signature.state)} is not supported (supported: absent, verified).`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
436
516
|
function normalizeRuntimeAccess(access) {
|
|
437
517
|
return access === undefined ? 'public' : access;
|
|
438
518
|
}
|
|
@@ -475,7 +555,7 @@ function buildRuntimeCapsule({
|
|
|
475
555
|
if (!['public', 'licensed', 'remote'].includes(runtimeAccess)) {
|
|
476
556
|
fail('KDNA_RUNTIME_CAPSULE_BUILD_INVALID', `Unsupported Runtime access: ${runtimeAccess}.`);
|
|
477
557
|
}
|
|
478
|
-
const signatureEvidence = signature
|
|
558
|
+
const signatureEvidence = normalizeSignatureEvidence(signature);
|
|
479
559
|
|
|
480
560
|
const capsule = {
|
|
481
561
|
type: 'kdna.runtime-capsule',
|
|
@@ -502,6 +582,9 @@ function buildRuntimeCapsule({
|
|
|
502
582
|
profile: projection.profile,
|
|
503
583
|
},
|
|
504
584
|
};
|
|
585
|
+
if (projection.projection_report !== undefined) {
|
|
586
|
+
capsule.trace.projection_report = globalThis.structuredClone(projection.projection_report);
|
|
587
|
+
}
|
|
505
588
|
assertRuntimeCapsuleSuccess(capsule, 'KDNA_RUNTIME_CAPSULE_BUILD_INVALID');
|
|
506
589
|
return capsule;
|
|
507
590
|
}
|
package/src/signature.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KDNA asset signatures — RFC-0021 M1 (`kdsig.ed25519`).
|
|
3
|
+
*
|
|
4
|
+
* A `.kdna` container carries an optional top-level `signature.kdsig` entry:
|
|
5
|
+
* a JSON signature bundle whose Ed25519 signature covers a deterministic
|
|
6
|
+
* signing payload derived from the asset's canonical content digest
|
|
7
|
+
* (docs/CANONICALIZATION.md). Verification is offline and fail-closed: any
|
|
8
|
+
* malformed, unsupported, or unverifiable bundle rejects the asset.
|
|
9
|
+
*
|
|
10
|
+
* A signature proves integrity and provenance only. It never proves
|
|
11
|
+
* expertise, truthfulness, safety, or fitness for purpose.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
const KDSIG_PROFILE = 'kdsig.ed25519';
|
|
19
|
+
const KDSIG_PROFILE_VERSION = '0.1.0';
|
|
20
|
+
const KDSIG_ALGORITHM = 'ed25519';
|
|
21
|
+
const SIGNATURE_ENTRY_NAME = 'signature.kdsig';
|
|
22
|
+
|
|
23
|
+
const RAW_PUBLIC_KEY_HEX_LENGTH = 64;
|
|
24
|
+
const RAW_SIGNATURE_HEX_LENGTH = 128;
|
|
25
|
+
const SHA256_HEX_LENGTH = 64;
|
|
26
|
+
|
|
27
|
+
// RFC 8410 DER prefixes for raw Ed25519 key material.
|
|
28
|
+
const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
29
|
+
const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
30
|
+
|
|
31
|
+
const BUNDLE_FIELDS = Object.freeze([
|
|
32
|
+
'algorithm',
|
|
33
|
+
'content_digest',
|
|
34
|
+
'profile',
|
|
35
|
+
'profile_version',
|
|
36
|
+
'public_key',
|
|
37
|
+
'signature',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function sha256Hex(buf) {
|
|
41
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function signatureError(code, message) {
|
|
45
|
+
const error = new Error(message);
|
|
46
|
+
error.code = code;
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isLowercaseHex(value, length) {
|
|
51
|
+
return typeof value === 'string' && value.length === length && /^[0-9a-f]+$/.test(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertContentDigestShape(value) {
|
|
55
|
+
if (
|
|
56
|
+
typeof value !== 'string'
|
|
57
|
+
|| !value.startsWith('sha256:')
|
|
58
|
+
|| !isLowercaseHex(value.slice('sha256:'.length), SHA256_HEX_LENGTH)
|
|
59
|
+
) {
|
|
60
|
+
throw signatureError(
|
|
61
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
62
|
+
`signature bundle content_digest must be "sha256:<64 lowercase hex>", got ${JSON.stringify(value)}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the exact bytes that Ed25519 signs. The payload is domain-separated by
|
|
69
|
+
* the profile coordinate and binds the asset's canonical content digest, so a
|
|
70
|
+
* signature can never be replayed across profiles, versions, or assets.
|
|
71
|
+
*/
|
|
72
|
+
function buildSigningPayload(contentDigest) {
|
|
73
|
+
assertContentDigestShape(contentDigest);
|
|
74
|
+
return Buffer.from(`${KDSIG_PROFILE}:${KDSIG_PROFILE_VERSION}:${contentDigest}`, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function privateKeyObjectFromSeed(seedHex) {
|
|
78
|
+
if (!isLowercaseHex(seedHex, RAW_PUBLIC_KEY_HEX_LENGTH)) {
|
|
79
|
+
throw signatureError(
|
|
80
|
+
'KDNA_SIGNATURE_KEY_INVALID',
|
|
81
|
+
'Ed25519 private key must be a 32-byte seed encoded as 64 lowercase hex characters',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const der = Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(seedHex, 'hex')]);
|
|
85
|
+
try {
|
|
86
|
+
return crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
throw signatureError('KDNA_SIGNATURE_KEY_INVALID', `invalid Ed25519 private key: ${e.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function publicKeyObjectFromRaw(publicKeyHex) {
|
|
93
|
+
if (!isLowercaseHex(publicKeyHex, RAW_PUBLIC_KEY_HEX_LENGTH)) {
|
|
94
|
+
throw signatureError(
|
|
95
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
96
|
+
'signature bundle public_key must be a 32-byte Ed25519 key encoded as 64 lowercase hex characters',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const der = Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(publicKeyHex, 'hex')]);
|
|
100
|
+
try {
|
|
101
|
+
return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
102
|
+
} catch (e) {
|
|
103
|
+
throw signatureError(
|
|
104
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
105
|
+
`signature bundle public_key is not a valid Ed25519 key: ${e.message}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rawPublicKeyHexFromObject(keyObject) {
|
|
111
|
+
const spki = keyObject.export({ format: 'der', type: 'spki' });
|
|
112
|
+
return spki.subarray(SPKI_ED25519_PREFIX.length).toString('hex');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generate a new Ed25519 signing key pair. The private key is returned as the
|
|
117
|
+
* raw 32-byte seed in lowercase hex; custody is the signer's responsibility.
|
|
118
|
+
*/
|
|
119
|
+
function generateSigningKeyPair() {
|
|
120
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
|
121
|
+
const pkcs8 = privateKey.export({ format: 'der', type: 'pkcs8' });
|
|
122
|
+
const seedHex = pkcs8.subarray(PKCS8_ED25519_PREFIX.length).toString('hex');
|
|
123
|
+
return {
|
|
124
|
+
algorithm: KDSIG_ALGORITHM,
|
|
125
|
+
private_key: seedHex,
|
|
126
|
+
public_key: rawPublicKeyHexFromObject(publicKey),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fingerprint of a raw Ed25519 public key: sha256 over the 32 key bytes.
|
|
132
|
+
*/
|
|
133
|
+
function keyFingerprint(publicKeyHex) {
|
|
134
|
+
if (!isLowercaseHex(publicKeyHex, RAW_PUBLIC_KEY_HEX_LENGTH)) {
|
|
135
|
+
throw signatureError(
|
|
136
|
+
'KDNA_SIGNATURE_KEY_INVALID',
|
|
137
|
+
'keyFingerprint expects a 32-byte Ed25519 public key encoded as 64 lowercase hex characters',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return `sha256:${sha256Hex(Buffer.from(publicKeyHex, 'hex'))}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stableStringify(value) {
|
|
144
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
145
|
+
if (value && typeof value === 'object') {
|
|
146
|
+
return `{${Object.keys(value)
|
|
147
|
+
.sort()
|
|
148
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
149
|
+
.join(',')}}`;
|
|
150
|
+
}
|
|
151
|
+
return JSON.stringify(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Serialize a signature bundle to its canonical wire bytes (sorted keys, no
|
|
156
|
+
* insignificant whitespace). Verification accepts any JSON encoding of the
|
|
157
|
+
* same fields; canonical bytes are what signers and vectors pin.
|
|
158
|
+
*/
|
|
159
|
+
function serializeSignatureBundle(bundle) {
|
|
160
|
+
return Buffer.from(`${stableStringify(bundle)}\n`, 'utf8');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseSignatureBundle(bytes) {
|
|
164
|
+
let text;
|
|
165
|
+
try {
|
|
166
|
+
text = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
throw signatureError('KDNA_INTEGRITY_SIGNATURE_FAILED', `signature bundle is not UTF-8: ${e.message}`);
|
|
169
|
+
}
|
|
170
|
+
let bundle;
|
|
171
|
+
try {
|
|
172
|
+
bundle = JSON.parse(text);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
throw signatureError(
|
|
175
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
176
|
+
`signature.kdsig is not valid JSON: ${e.message}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (!bundle || typeof bundle !== 'object' || Array.isArray(bundle)) {
|
|
180
|
+
throw signatureError('KDNA_INTEGRITY_SIGNATURE_FAILED', 'signature bundle must be a JSON object');
|
|
181
|
+
}
|
|
182
|
+
for (const field of BUNDLE_FIELDS) {
|
|
183
|
+
if (typeof bundle[field] !== 'string') {
|
|
184
|
+
throw signatureError(
|
|
185
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
186
|
+
`signature bundle is missing required string field: ${field}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const extra = Object.keys(bundle).filter((key) => !BUNDLE_FIELDS.includes(key));
|
|
191
|
+
if (extra.length) {
|
|
192
|
+
throw signatureError(
|
|
193
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
194
|
+
`signature bundle carries unsupported fields: ${extra.sort().join(', ')}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (bundle.profile !== KDSIG_PROFILE) {
|
|
198
|
+
throw signatureError(
|
|
199
|
+
'KDNA_SIGNATURE_PROFILE_UNSUPPORTED',
|
|
200
|
+
`signature bundle profile ${JSON.stringify(bundle.profile)} is not supported (supported: ${KDSIG_PROFILE})`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (bundle.profile_version !== KDSIG_PROFILE_VERSION) {
|
|
204
|
+
throw signatureError(
|
|
205
|
+
'KDNA_SIGNATURE_VERSION_UNSUPPORTED',
|
|
206
|
+
`signature bundle profile_version ${JSON.stringify(bundle.profile_version)} is not supported (supported: ${KDSIG_PROFILE_VERSION})`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (bundle.algorithm !== KDSIG_ALGORITHM) {
|
|
210
|
+
throw signatureError(
|
|
211
|
+
'KDNA_SIGNATURE_PROFILE_UNSUPPORTED',
|
|
212
|
+
`signature bundle algorithm ${JSON.stringify(bundle.algorithm)} is not supported (supported: ${KDSIG_ALGORITHM})`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
assertContentDigestShape(bundle.content_digest);
|
|
216
|
+
if (!isLowercaseHex(bundle.public_key, RAW_PUBLIC_KEY_HEX_LENGTH)) {
|
|
217
|
+
throw signatureError(
|
|
218
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
219
|
+
'signature bundle public_key must be 64 lowercase hex characters',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!isLowercaseHex(bundle.signature, RAW_SIGNATURE_HEX_LENGTH)) {
|
|
223
|
+
throw signatureError(
|
|
224
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
225
|
+
'signature bundle signature must be 128 lowercase hex characters',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
profile: bundle.profile,
|
|
230
|
+
profile_version: bundle.profile_version,
|
|
231
|
+
algorithm: bundle.algorithm,
|
|
232
|
+
content_digest: bundle.content_digest,
|
|
233
|
+
public_key: bundle.public_key,
|
|
234
|
+
signature: bundle.signature,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Sign a canonical content digest with an Ed25519 seed (64 lowercase hex).
|
|
240
|
+
* Returns the bundle object; serialize with serializeSignatureBundle().
|
|
241
|
+
*/
|
|
242
|
+
function signContentDigest(contentDigest, privateKeySeedHex) {
|
|
243
|
+
assertContentDigestShape(contentDigest);
|
|
244
|
+
const privateKey = privateKeyObjectFromSeed(privateKeySeedHex);
|
|
245
|
+
const publicKeyHex = rawPublicKeyHexFromObject(crypto.createPublicKey(privateKey));
|
|
246
|
+
const signatureBytes = crypto.sign(null, buildSigningPayload(contentDigest), privateKey);
|
|
247
|
+
return {
|
|
248
|
+
algorithm: KDSIG_ALGORITHM,
|
|
249
|
+
content_digest: contentDigest,
|
|
250
|
+
profile: KDSIG_PROFILE,
|
|
251
|
+
profile_version: KDSIG_PROFILE_VERSION,
|
|
252
|
+
public_key: publicKeyHex,
|
|
253
|
+
signature: signatureBytes.toString('hex'),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Verify a parsed bundle against a canonical content digest. Fail-closed:
|
|
259
|
+
* every mismatch throws with a stable error code. Returns verification
|
|
260
|
+
* evidence on success.
|
|
261
|
+
*/
|
|
262
|
+
function verifySignatureBundle(bundle, contentDigest, options = {}) {
|
|
263
|
+
const parsed = parseSignatureBundle(bundle);
|
|
264
|
+
assertContentDigestShape(contentDigest);
|
|
265
|
+
if (parsed.content_digest !== contentDigest) {
|
|
266
|
+
throw signatureError(
|
|
267
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
268
|
+
`signature bundle content_digest ${parsed.content_digest} does not match the asset content_digest ${contentDigest}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (
|
|
272
|
+
typeof options.expectedPublicKey === 'string'
|
|
273
|
+
&& options.expectedPublicKey.toLowerCase() !== parsed.public_key
|
|
274
|
+
) {
|
|
275
|
+
throw signatureError(
|
|
276
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
277
|
+
'signature bundle public_key does not match the expected pinned key',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const publicKey = publicKeyObjectFromRaw(parsed.public_key);
|
|
281
|
+
const signatureBytes = Buffer.from(parsed.signature, 'hex');
|
|
282
|
+
let valid;
|
|
283
|
+
try {
|
|
284
|
+
valid = crypto.verify(null, buildSigningPayload(contentDigest), publicKey, signatureBytes);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
throw signatureError(
|
|
287
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
288
|
+
`Ed25519 verification error: ${e.message}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (valid !== true) {
|
|
292
|
+
throw signatureError(
|
|
293
|
+
'KDNA_INTEGRITY_SIGNATURE_FAILED',
|
|
294
|
+
'Ed25519 signature verification failed: signature does not match the canonical signing payload',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
state: 'verified',
|
|
299
|
+
profile: parsed.profile,
|
|
300
|
+
profile_version: parsed.profile_version,
|
|
301
|
+
algorithm: parsed.algorithm,
|
|
302
|
+
content_digest: parsed.content_digest,
|
|
303
|
+
public_key: parsed.public_key,
|
|
304
|
+
key_fingerprint: keyFingerprint(parsed.public_key),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
KDSIG_PROFILE,
|
|
310
|
+
KDSIG_PROFILE_VERSION,
|
|
311
|
+
KDSIG_ALGORITHM,
|
|
312
|
+
SIGNATURE_ENTRY_NAME,
|
|
313
|
+
buildSigningPayload,
|
|
314
|
+
generateSigningKeyPair,
|
|
315
|
+
keyFingerprint,
|
|
316
|
+
parseSignatureBundle,
|
|
317
|
+
serializeSignatureBundle,
|
|
318
|
+
signContentDigest,
|
|
319
|
+
verifySignatureBundle,
|
|
320
|
+
};
|