wyvrnpm 2.12.3 → 2.13.12
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/README.md +162 -1
- package/bin/wyvrnpm.js +167 -11
- package/package.json +1 -1
- package/src/build/recipe.js +25 -3
- package/src/commands/configure.js +61 -1
- package/src/commands/install.js +76 -1
- package/src/commands/key.js +112 -0
- package/src/commands/publish.js +402 -49
- package/src/commands/show.js +23 -0
- package/src/commands/trust.js +136 -0
- package/src/conf/index.js +131 -11
- package/src/config.js +35 -4
- package/src/download.js +408 -11
- package/src/providers/base.js +110 -15
- package/src/providers/file.js +90 -29
- package/src/providers/http.js +109 -32
- package/src/providers/s3.js +103 -31
- package/src/publish/slice.js +414 -0
- package/src/resolve.js +16 -2
- package/src/signing/attestation.js +301 -0
- package/src/signing/canonical.js +80 -0
- package/src/signing/ed25519.js +123 -0
- package/src/signing/index.js +201 -0
- package/src/signing/resolve-context.js +73 -0
- package/src/signing/trust.js +215 -0
- package/src/upload-built.js +88 -14
- package/src/v2-meta.js +98 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { canonicalize } = require('./canonical');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builder + validator for the wyvrnpm artefact attestation. Two shapes
|
|
7
|
+
* coexist on the registry:
|
|
8
|
+
*
|
|
9
|
+
* v1 — fat-zip publish (today's default).
|
|
10
|
+
* Binds contentSha256 (the single `wyvrn.zip`) + manifestSha256.
|
|
11
|
+
*
|
|
12
|
+
* v2 — per-config slice publish (recipe `build.publishPerConfig: true`).
|
|
13
|
+
* Binds artefactSha256 (a `{ config → sha256 }` map covering every
|
|
14
|
+
* `wyvrn-<Config>.zip` slice) + manifestSha256. The signed canonical
|
|
15
|
+
* body covers the whole map, so a tampered or omitted slice changes
|
|
16
|
+
* the signed bytes and the signature fails — even if the slice the
|
|
17
|
+
* consumer is asking for is unchanged.
|
|
18
|
+
*
|
|
19
|
+
* Both shapes share name/version/profileHash/manifestSha256/publishedAt/
|
|
20
|
+
* publisherKeyId/uploadedBy. A v1 attestation is byte-identical to what
|
|
21
|
+
* pre-2.13.x publishers produced, so existing trust anchors and signed
|
|
22
|
+
* artefacts on the registry stay verifiable without rotation.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const ATTESTATION_V1 = 1;
|
|
26
|
+
const ATTESTATION_V2 = 2;
|
|
27
|
+
const SUPPORTED_ATTESTATION_VERSIONS = new Set([ATTESTATION_V1, ATTESTATION_V2]);
|
|
28
|
+
|
|
29
|
+
// Legacy alias — pre-v2 callers imported `ATTESTATION_VERSION` literally.
|
|
30
|
+
// Keep it pointing at v1 so they keep emitting v1 unless they opt in.
|
|
31
|
+
const ATTESTATION_VERSION = ATTESTATION_V1;
|
|
32
|
+
|
|
33
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
34
|
+
const PROFILE_HASH_RE = /^[0-9a-f]{16}$/;
|
|
35
|
+
// keyId lives in keys.json + signature envelope; keep it ASCII + slug-shaped
|
|
36
|
+
// so it round-trips cleanly through filesystems and config files.
|
|
37
|
+
const KEY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
38
|
+
const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
39
|
+
// CMake config names are PascalCase, ASCII, no spaces. The slice classifier
|
|
40
|
+
// only emits canonical configs (Debug / Release / RelWithDebInfo /
|
|
41
|
+
// MinSizeRel) but the regex stays generous so a future custom-config
|
|
42
|
+
// extension doesn't require a schema bump.
|
|
43
|
+
const CONFIG_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
44
|
+
|
|
45
|
+
const REQUIRED_V1 = [
|
|
46
|
+
'wyvrnAttestationVersion', 'name', 'version', 'profileHash',
|
|
47
|
+
'contentSha256', 'manifestSha256', 'publishedAt', 'publisherKeyId',
|
|
48
|
+
];
|
|
49
|
+
const REQUIRED_V2 = [
|
|
50
|
+
'wyvrnAttestationVersion', 'name', 'version', 'profileHash',
|
|
51
|
+
'artefactSha256', 'manifestSha256', 'publishedAt', 'publisherKeyId',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a fully-validated attestation. Dispatch:
|
|
56
|
+
* - explicit `wyvrnAttestationVersion`: honoured.
|
|
57
|
+
* - else: v2 if `artefactSha256` is supplied; v1 otherwise.
|
|
58
|
+
*
|
|
59
|
+
* Both v1 and v2 inputs are mutually exclusive — supplying both
|
|
60
|
+
* `contentSha256` and `artefactSha256` is incoherent and throws.
|
|
61
|
+
*/
|
|
62
|
+
function buildAttestation(opts) {
|
|
63
|
+
if (opts == null || typeof opts !== 'object') {
|
|
64
|
+
throw new Error('attestation: opts must be an object');
|
|
65
|
+
}
|
|
66
|
+
const v = opts.wyvrnAttestationVersion;
|
|
67
|
+
if (v !== undefined && v !== null && !SUPPORTED_ATTESTATION_VERSIONS.has(v)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`attestation: unsupported wyvrnAttestationVersion ${v}` +
|
|
70
|
+
` (this client supports ${[...SUPPORTED_ATTESTATION_VERSIONS].join(', ')})`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (opts.contentSha256 != null && opts.artefactSha256 != null) {
|
|
74
|
+
throw new Error('attestation: pass either contentSha256 (v1) or artefactSha256 (v2), not both');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const route = v ?? (opts.artefactSha256 != null ? ATTESTATION_V2 : ATTESTATION_V1);
|
|
78
|
+
return route === ATTESTATION_V2 ? buildV2Attestation(opts) : buildV1Attestation(opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildV1Attestation({
|
|
82
|
+
name, version, profileHash, contentSha256, manifestSha256,
|
|
83
|
+
publishedAt, publisherKeyId, uploadedBy = null,
|
|
84
|
+
}) {
|
|
85
|
+
validateCommon({ name, version, profileHash, manifestSha256, publishedAt, publisherKeyId, uploadedBy });
|
|
86
|
+
if (typeof contentSha256 !== 'string' || !SHA256_HEX_RE.test(contentSha256)) {
|
|
87
|
+
throw new Error('attestation: contentSha256 must be 64 lowercase hex chars');
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
wyvrnAttestationVersion: ATTESTATION_V1,
|
|
91
|
+
name,
|
|
92
|
+
version,
|
|
93
|
+
profileHash,
|
|
94
|
+
contentSha256,
|
|
95
|
+
manifestSha256,
|
|
96
|
+
publishedAt,
|
|
97
|
+
publisherKeyId,
|
|
98
|
+
uploadedBy,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildV2Attestation({
|
|
103
|
+
name, version, profileHash, artefactSha256, manifestSha256,
|
|
104
|
+
publishedAt, publisherKeyId, uploadedBy = null,
|
|
105
|
+
}) {
|
|
106
|
+
validateCommon({ name, version, profileHash, manifestSha256, publishedAt, publisherKeyId, uploadedBy });
|
|
107
|
+
if (artefactSha256 === null || typeof artefactSha256 !== 'object' || Array.isArray(artefactSha256)) {
|
|
108
|
+
throw new Error('attestation: artefactSha256 must be an object keyed by config name');
|
|
109
|
+
}
|
|
110
|
+
const entries = Object.entries(artefactSha256);
|
|
111
|
+
if (entries.length === 0) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'attestation: artefactSha256 must not be empty (use v1 contentSha256 for fat-zip publish)',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
for (const [config, sha] of entries) {
|
|
117
|
+
if (typeof config !== 'string' || !CONFIG_NAME_RE.test(config)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`attestation: artefactSha256 key ${JSON.stringify(config)} is not a valid config name`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (typeof sha !== 'string' || !SHA256_HEX_RE.test(sha)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`attestation: artefactSha256[${JSON.stringify(config)}] must be 64 lowercase hex chars`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Sort the in-memory shape so deepEqual et al. work intuitively in tests.
|
|
129
|
+
// canonicalize() sorts at signing time anyway, so this doesn't affect signed bytes.
|
|
130
|
+
const sortedShape = {};
|
|
131
|
+
for (const k of Object.keys(artefactSha256).sort()) sortedShape[k] = artefactSha256[k];
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
wyvrnAttestationVersion: ATTESTATION_V2,
|
|
135
|
+
name,
|
|
136
|
+
version,
|
|
137
|
+
profileHash,
|
|
138
|
+
artefactSha256: sortedShape,
|
|
139
|
+
manifestSha256,
|
|
140
|
+
publishedAt,
|
|
141
|
+
publisherKeyId,
|
|
142
|
+
uploadedBy,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateCommon({
|
|
147
|
+
name, version, profileHash, manifestSha256, publishedAt, publisherKeyId, uploadedBy,
|
|
148
|
+
}) {
|
|
149
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
150
|
+
throw new Error('attestation: name must be a non-empty string');
|
|
151
|
+
}
|
|
152
|
+
if (typeof version !== 'string' || version.length === 0) {
|
|
153
|
+
throw new Error('attestation: version must be a non-empty string');
|
|
154
|
+
}
|
|
155
|
+
if (typeof profileHash !== 'string' || !PROFILE_HASH_RE.test(profileHash)) {
|
|
156
|
+
throw new Error(`attestation: profileHash must be 16 lowercase hex chars (got "${profileHash}")`);
|
|
157
|
+
}
|
|
158
|
+
if (typeof manifestSha256 !== 'string' || !SHA256_HEX_RE.test(manifestSha256)) {
|
|
159
|
+
throw new Error('attestation: manifestSha256 must be 64 lowercase hex chars');
|
|
160
|
+
}
|
|
161
|
+
if (typeof publishedAt !== 'string' || !ISO8601_RE.test(publishedAt)) {
|
|
162
|
+
throw new Error(`attestation: publishedAt must be ISO 8601 UTC (got "${publishedAt}")`);
|
|
163
|
+
}
|
|
164
|
+
if (typeof publisherKeyId !== 'string' || !KEY_ID_RE.test(publisherKeyId)) {
|
|
165
|
+
throw new Error(`attestation: publisherKeyId must match ${KEY_ID_RE} (got "${publisherKeyId}")`);
|
|
166
|
+
}
|
|
167
|
+
if (uploadedBy !== null && (typeof uploadedBy !== 'object' || Array.isArray(uploadedBy))) {
|
|
168
|
+
throw new Error('attestation: uploadedBy must be null or a plain object');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Parse and validate an attestation read from disk / network. Dispatches on
|
|
174
|
+
* `wyvrnAttestationVersion`; rejects unknown versions fail-closed (a future
|
|
175
|
+
* schema change must come with a verifier upgrade).
|
|
176
|
+
*/
|
|
177
|
+
function parseAttestation(raw) {
|
|
178
|
+
let obj;
|
|
179
|
+
try {
|
|
180
|
+
obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
throw new Error(`attestation: malformed JSON — ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
185
|
+
throw new Error('attestation: must be a JSON object');
|
|
186
|
+
}
|
|
187
|
+
const v = obj.wyvrnAttestationVersion;
|
|
188
|
+
if (!SUPPORTED_ATTESTATION_VERSIONS.has(v)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`attestation: unsupported wyvrnAttestationVersion ${v}` +
|
|
191
|
+
` (this client supports ${[...SUPPORTED_ATTESTATION_VERSIONS].join(', ')})`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const required = (v === ATTESTATION_V2) ? REQUIRED_V2 : REQUIRED_V1;
|
|
195
|
+
for (const field of required) {
|
|
196
|
+
if (!(field in obj)) {
|
|
197
|
+
throw new Error(`attestation: missing required field "${field}"`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return buildAttestation(obj);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Canonical bytes of an attestation — exactly what gets fed to ed25519.sign
|
|
205
|
+
* at publish and to ed25519.verify at install. Wrapping the canonicaliser
|
|
206
|
+
* here documents that "the signed bytes are the canonical attestation, full
|
|
207
|
+
* stop" without exposing canonical.js as a public API across the signing
|
|
208
|
+
* subsystem.
|
|
209
|
+
*/
|
|
210
|
+
function canonicalAttestationBytes(att) {
|
|
211
|
+
return Buffer.from(canonicalize(att), 'utf8');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Confirm the attestation's bound `(name, version, profileHash)` match what
|
|
216
|
+
* the consumer expected to download. Defends against an attacker swapping a
|
|
217
|
+
* legitimately-signed attestation for `pkg-A@1.0.0` into the URL path of
|
|
218
|
+
* `pkg-B@2.0.0`. Throws on mismatch.
|
|
219
|
+
*
|
|
220
|
+
* Identical contract for v1 and v2 — both bind these fields the same way.
|
|
221
|
+
*/
|
|
222
|
+
function assertBoundIdentity(att, { name, version, profileHash }) {
|
|
223
|
+
if (att.name !== name) {
|
|
224
|
+
throw new Error(`attestation bound to name="${att.name}", expected "${name}"`);
|
|
225
|
+
}
|
|
226
|
+
if (att.version !== version) {
|
|
227
|
+
throw new Error(`attestation bound to version="${att.version}", expected "${version}"`);
|
|
228
|
+
}
|
|
229
|
+
if (att.profileHash !== profileHash) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`attestation bound to profileHash="${att.profileHash}", expected "${profileHash}"`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Confirm bound hashes between the attestation and the consumer's local
|
|
238
|
+
* computation. Dispatches on `att.wyvrnAttestationVersion`:
|
|
239
|
+
*
|
|
240
|
+
* v1: expected = { contentSha256, manifestSha256 }
|
|
241
|
+
* v2: expected = { sliceConfig, sliceSha256, manifestSha256 }
|
|
242
|
+
* — sliceConfig is the canonical config name of the slice the consumer
|
|
243
|
+
* chose (e.g. "Release"); sliceSha256 is its locally-computed hash.
|
|
244
|
+
* The function looks up att.artefactSha256[sliceConfig] and asserts.
|
|
245
|
+
* A consumer who downloaded multiple slices calls this once per slice.
|
|
246
|
+
*
|
|
247
|
+
* manifestSha256 is checked against att.manifestSha256 in both cases so the
|
|
248
|
+
* single canonical-byte signature still binds the manifest the consumer read.
|
|
249
|
+
*/
|
|
250
|
+
function assertBoundHashes(att, expected) {
|
|
251
|
+
if (att.manifestSha256 !== expected.manifestSha256) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`attestation manifestSha256="${att.manifestSha256}" does not match local manifest "${expected.manifestSha256}"`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (att.wyvrnAttestationVersion === ATTESTATION_V1) {
|
|
257
|
+
if (att.contentSha256 !== expected.contentSha256) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`attestation contentSha256="${att.contentSha256}" does not match local zip "${expected.contentSha256}"`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (att.wyvrnAttestationVersion === ATTESTATION_V2) {
|
|
265
|
+
const { sliceConfig, sliceSha256 } = expected;
|
|
266
|
+
if (typeof sliceConfig !== 'string' || sliceConfig.length === 0) {
|
|
267
|
+
throw new Error('assertBoundHashes(v2): expected.sliceConfig required');
|
|
268
|
+
}
|
|
269
|
+
if (typeof sliceSha256 !== 'string' || !SHA256_HEX_RE.test(sliceSha256)) {
|
|
270
|
+
throw new Error('assertBoundHashes(v2): expected.sliceSha256 must be 64 lowercase hex chars');
|
|
271
|
+
}
|
|
272
|
+
const recorded = att.artefactSha256[sliceConfig];
|
|
273
|
+
if (typeof recorded !== 'string') {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`attestation has no artefactSha256 entry for config "${sliceConfig}" ` +
|
|
276
|
+
`(published configs: ${Object.keys(att.artefactSha256).join(', ')})`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (recorded !== sliceSha256) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`attestation artefactSha256[${JSON.stringify(sliceConfig)}]="${recorded}" does not match local slice "${sliceSha256}"`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`assertBoundHashes: unsupported attestation version ${att.wyvrnAttestationVersion}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
ATTESTATION_VERSION, // legacy alias for ATTESTATION_V1
|
|
291
|
+
ATTESTATION_V1,
|
|
292
|
+
ATTESTATION_V2,
|
|
293
|
+
SUPPORTED_ATTESTATION_VERSIONS,
|
|
294
|
+
buildAttestation,
|
|
295
|
+
buildV1Attestation,
|
|
296
|
+
buildV2Attestation,
|
|
297
|
+
parseAttestation,
|
|
298
|
+
canonicalAttestationBytes,
|
|
299
|
+
assertBoundIdentity,
|
|
300
|
+
assertBoundHashes,
|
|
301
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic JSON serialisation: keys sorted at every nesting level, no
|
|
7
|
+
* whitespace, identical output on every platform and Node version.
|
|
8
|
+
*
|
|
9
|
+
* Used as the signed payload of the artefact attestation
|
|
10
|
+
* (claude/PLAN-SIGNING.md §3.1) and as the input to `manifestSha256` so the
|
|
11
|
+
* published manifest cannot be swapped while the zip stays the same.
|
|
12
|
+
*
|
|
13
|
+
* Behavioural notes:
|
|
14
|
+
* - Object keys sorted via `Array.prototype.sort()` (UTF-16 code-unit order).
|
|
15
|
+
* Our attestation + manifest keys are all ASCII, so this matches the
|
|
16
|
+
* RFC 8785 (JCS) ordering that future cross-tool verifiers expect.
|
|
17
|
+
* - Primitive serialisation is delegated to `JSON.stringify`, which uses
|
|
18
|
+
* ES262 `ToString(Number)` for numbers — the same algorithm JCS mandates.
|
|
19
|
+
* - Non-finite numbers (NaN, Infinity, -Infinity) are rejected.
|
|
20
|
+
* - `undefined`, functions, and symbols are rejected. JSON has no
|
|
21
|
+
* representation for them; silently dropping (the default
|
|
22
|
+
* `JSON.stringify` behaviour) would change the signed bytes when a
|
|
23
|
+
* caller forgot to populate a field.
|
|
24
|
+
* - Arrays preserve their input order — only object keys get sorted.
|
|
25
|
+
*/
|
|
26
|
+
function canonicalize(value) {
|
|
27
|
+
return _serialize(value, []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _serialize(value, pathStack) {
|
|
31
|
+
if (value === null) return 'null';
|
|
32
|
+
|
|
33
|
+
const t = typeof value;
|
|
34
|
+
if (t === 'boolean') return value ? 'true' : 'false';
|
|
35
|
+
|
|
36
|
+
if (t === 'number') {
|
|
37
|
+
if (!Number.isFinite(value)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`canonicalize: non-finite number at ${_pathStr(pathStack)} (${value})`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (t === 'string') return JSON.stringify(value);
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
const parts = value.map((v, i) => _serialize(v, [...pathStack, i]));
|
|
49
|
+
return '[' + parts.join(',') + ']';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (t === 'object') {
|
|
53
|
+
const keys = Object.keys(value).sort();
|
|
54
|
+
const parts = keys.map((k) =>
|
|
55
|
+
JSON.stringify(k) + ':' + _serialize(value[k], [...pathStack, k]),
|
|
56
|
+
);
|
|
57
|
+
return '{' + parts.join(',') + '}';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error(
|
|
61
|
+
`canonicalize: unsupported value at ${_pathStr(pathStack)} (typeof=${t})`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _pathStr(stack) {
|
|
66
|
+
return stack.length === 0 ? '<root>' : stack.map((s) => `[${JSON.stringify(s)}]`).join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convenience: SHA256 of the canonical form, full 64-char lowercase hex.
|
|
71
|
+
* The shape consumers cite as `manifestSha256` in the attestation block.
|
|
72
|
+
*/
|
|
73
|
+
function sha256OfCanonical(value) {
|
|
74
|
+
return crypto.createHash('sha256').update(canonicalize(value), 'utf8').digest('hex');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
canonicalize,
|
|
79
|
+
sha256OfCanonical,
|
|
80
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thin wrappers over Node's built-in `crypto` for Ed25519 signing and key
|
|
7
|
+
* handling. The whole reason wyvrnpm picked Ed25519 (vs GPG, Sigstore,
|
|
8
|
+
* Authenticode) is that nothing here shells out — every byte goes through
|
|
9
|
+
* `node:crypto`, identical on Windows / Linux / macOS.
|
|
10
|
+
*
|
|
11
|
+
* Format conventions (mirror what `keys.json` and the publisher's local
|
|
12
|
+
* `*.pub.pem` / `*.priv.pem` use):
|
|
13
|
+
*
|
|
14
|
+
* - Private keys round-trip as PKCS#8 PEM (Node's native format for
|
|
15
|
+
* `createPrivateKey`).
|
|
16
|
+
* - Public keys round-trip as SPKI PEM for file storage and as SPKI DER
|
|
17
|
+
* base64 for embedding in `keys.json`. Both forms are accepted by the
|
|
18
|
+
* verify path; pick whichever is more ergonomic at the call site.
|
|
19
|
+
* - Signatures are the raw 64-byte Ed25519 output (RFC 8032). No DER
|
|
20
|
+
* wrapping. `wyvrn.sig` carries the base64 form.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ALG = 'ed25519';
|
|
24
|
+
const RAW_SIG_LEN = 64;
|
|
25
|
+
|
|
26
|
+
function generateKeyPair() {
|
|
27
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync(ALG);
|
|
28
|
+
return {
|
|
29
|
+
privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }),
|
|
30
|
+
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
|
31
|
+
publicKeyB64: publicKey.export({ format: 'der', type: 'spki' }).toString('base64'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load a private key from a PEM string. Validates that the key is Ed25519;
|
|
37
|
+
* other algorithms throw fast so a misconfigured publisher can't accidentally
|
|
38
|
+
* publish with a wrong-shape signature.
|
|
39
|
+
*/
|
|
40
|
+
function loadPrivateKey(pem) {
|
|
41
|
+
const k = crypto.createPrivateKey({ key: pem, format: 'pem' });
|
|
42
|
+
if (k.asymmetricKeyType !== ALG) {
|
|
43
|
+
throw new Error(`expected ed25519 private key, got ${k.asymmetricKeyType}`);
|
|
44
|
+
}
|
|
45
|
+
return k;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load a public key from either an SPKI PEM string (file-based) or a base64
|
|
50
|
+
* SPKI DER string (registry `keys.json`). Auto-detects by leading marker.
|
|
51
|
+
*/
|
|
52
|
+
function loadPublicKey(input) {
|
|
53
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
54
|
+
throw new Error('loadPublicKey: input must be a non-empty string');
|
|
55
|
+
}
|
|
56
|
+
let k;
|
|
57
|
+
if (input.includes('-----BEGIN')) {
|
|
58
|
+
k = crypto.createPublicKey({ key: input, format: 'pem' });
|
|
59
|
+
} else {
|
|
60
|
+
const der = Buffer.from(input, 'base64');
|
|
61
|
+
k = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
62
|
+
}
|
|
63
|
+
if (k.asymmetricKeyType !== ALG) {
|
|
64
|
+
throw new Error(`expected ed25519 public key, got ${k.asymmetricKeyType}`);
|
|
65
|
+
}
|
|
66
|
+
return k;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Derive the public key (SPKI PEM + base64 DER) from a private key PEM.
|
|
71
|
+
* Used by `wyvrnpm key show-pub` and as a sanity check during publisher
|
|
72
|
+
* bootstrap.
|
|
73
|
+
*/
|
|
74
|
+
function derivePublicKey(privateKeyPem) {
|
|
75
|
+
const priv = loadPrivateKey(privateKeyPem);
|
|
76
|
+
const pub = crypto.createPublicKey(priv);
|
|
77
|
+
return {
|
|
78
|
+
publicKeyPem: pub.export({ format: 'pem', type: 'spki' }),
|
|
79
|
+
publicKeyB64: pub.export({ format: 'der', type: 'spki' }).toString('base64'),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sign `bytes` with `privateKey`. `privateKey` may be a PEM string or an
|
|
85
|
+
* already-loaded KeyObject. Returns a 64-byte raw Ed25519 signature.
|
|
86
|
+
*/
|
|
87
|
+
function sign(privateKey, bytes) {
|
|
88
|
+
const key = typeof privateKey === 'string' ? loadPrivateKey(privateKey) : privateKey;
|
|
89
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes, 'utf8');
|
|
90
|
+
// Ed25519 in Node uses null algorithm (key type carries the algorithm).
|
|
91
|
+
const sig = crypto.sign(null, buf, key);
|
|
92
|
+
if (sig.length !== RAW_SIG_LEN) {
|
|
93
|
+
throw new Error(`unexpected ed25519 signature length: ${sig.length}`);
|
|
94
|
+
}
|
|
95
|
+
return sig;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Verify `sig` against `bytes` using `publicKey`. `publicKey` may be a PEM
|
|
100
|
+
* string, a base64 SPKI DER string, or a KeyObject. `sig` may be a Buffer or
|
|
101
|
+
* a base64 string. Returns boolean — never throws on a bad signature, only
|
|
102
|
+
* on a malformed key/sig shape.
|
|
103
|
+
*/
|
|
104
|
+
function verify(publicKey, bytes, sig) {
|
|
105
|
+
const key = typeof publicKey === 'string' ? loadPublicKey(publicKey) : publicKey;
|
|
106
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes, 'utf8');
|
|
107
|
+
const sigBuf = Buffer.isBuffer(sig) ? sig : Buffer.from(sig, 'base64');
|
|
108
|
+
if (sigBuf.length !== RAW_SIG_LEN) {
|
|
109
|
+
throw new Error(`expected ${RAW_SIG_LEN}-byte ed25519 signature, got ${sigBuf.length}`);
|
|
110
|
+
}
|
|
111
|
+
return crypto.verify(null, buf, key, sigBuf);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
ALG,
|
|
116
|
+
RAW_SIG_LEN,
|
|
117
|
+
generateKeyPair,
|
|
118
|
+
loadPrivateKey,
|
|
119
|
+
loadPublicKey,
|
|
120
|
+
derivePublicKey,
|
|
121
|
+
sign,
|
|
122
|
+
verify,
|
|
123
|
+
};
|