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,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const ed25519 = require('./ed25519');
|
|
6
|
+
const {
|
|
7
|
+
buildAttestation,
|
|
8
|
+
parseAttestation,
|
|
9
|
+
canonicalAttestationBytes,
|
|
10
|
+
assertBoundIdentity,
|
|
11
|
+
assertBoundHashes,
|
|
12
|
+
} = require('./attestation');
|
|
13
|
+
const { findKey, validateKeysJson } = require('./trust');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* High-level signing surface. Publish + upload-built call `signAttestation`;
|
|
17
|
+
* download calls `verifyAttestation`. Everything else (canonical bytes,
|
|
18
|
+
* trust-anchor lookup, identity assertions) is composed from the
|
|
19
|
+
* lower-level modules so each piece stays unit-testable.
|
|
20
|
+
*
|
|
21
|
+
* The `kind` field on a signing or verification result is always
|
|
22
|
+
* `"ed25519"` in v1. It exists so the dispatcher can grow to multiple
|
|
23
|
+
* backends without changing call sites.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const SIG_ENVELOPE_ALG = 'ed25519';
|
|
27
|
+
|
|
28
|
+
// ─── Sign ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a private key from a signing context. Precedence (matches the
|
|
32
|
+
* publish flow):
|
|
33
|
+
* 1. `ctx.privateKeyPem` — already-loaded PEM string (CLI / config).
|
|
34
|
+
* 2. `ctx.privateKeyEnv` — env-var name to read.
|
|
35
|
+
* 3. `ctx.privateKeyFile` — file path to read.
|
|
36
|
+
*
|
|
37
|
+
* Throws fast if none are set so a misconfigured publish never silently
|
|
38
|
+
* produces an unsigned artefact.
|
|
39
|
+
*/
|
|
40
|
+
function resolvePrivateKey(ctx) {
|
|
41
|
+
if (ctx.privateKeyPem) return ctx.privateKeyPem;
|
|
42
|
+
|
|
43
|
+
if (ctx.privateKeyEnv) {
|
|
44
|
+
const v = process.env[ctx.privateKeyEnv];
|
|
45
|
+
if (!v || v.length === 0) {
|
|
46
|
+
throw new Error(`signing: env var "${ctx.privateKeyEnv}" is empty or unset`);
|
|
47
|
+
}
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (ctx.privateKeyFile) {
|
|
52
|
+
if (!fs.existsSync(ctx.privateKeyFile)) {
|
|
53
|
+
throw new Error(`signing: private key file not found: ${ctx.privateKeyFile}`);
|
|
54
|
+
}
|
|
55
|
+
return fs.readFileSync(ctx.privateKeyFile, 'utf8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(
|
|
59
|
+
'signing: no private key configured — pass --signing-key-env / --signing-key-file ' +
|
|
60
|
+
'or configure defaultSigning.privateKey',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sign a freshly-built attestation. Returns the canonical bytes (write to
|
|
66
|
+
* `wyvrn.att.json`) and a signature envelope (write to `wyvrn.sig`).
|
|
67
|
+
*
|
|
68
|
+
* The envelope is JSON to keep the signature inspectable and decouple
|
|
69
|
+
* future algorithm changes from the file format.
|
|
70
|
+
*/
|
|
71
|
+
function signAttestation(attestation, ctx) {
|
|
72
|
+
const att = buildAttestation(attestation); // re-validate before signing
|
|
73
|
+
const attBytes = canonicalAttestationBytes(att);
|
|
74
|
+
const privateKeyPem = resolvePrivateKey(ctx);
|
|
75
|
+
|
|
76
|
+
// Sanity: the supplied keyId must match the key we're actually signing
|
|
77
|
+
// with. The publisher derives `publisherKeyId` from their config; if they
|
|
78
|
+
// swap the private key file without updating the config, fail loudly.
|
|
79
|
+
if (!ctx.keyId || typeof ctx.keyId !== 'string') {
|
|
80
|
+
throw new Error('signing: ctx.keyId required');
|
|
81
|
+
}
|
|
82
|
+
if (att.publisherKeyId !== ctx.keyId) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`signing: attestation.publisherKeyId="${att.publisherKeyId}" does not match ctx.keyId="${ctx.keyId}"`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sig = ed25519.sign(privateKeyPem, attBytes);
|
|
89
|
+
return {
|
|
90
|
+
kind: SIG_ENVELOPE_ALG,
|
|
91
|
+
attBytes,
|
|
92
|
+
sigEnvelope: { alg: SIG_ENVELOPE_ALG, keyId: ctx.keyId, sig: sig.toString('base64') },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serialize the signature envelope for writing to `wyvrn.sig`.
|
|
98
|
+
*/
|
|
99
|
+
function serializeSigEnvelope(env) {
|
|
100
|
+
if (!env || env.alg !== SIG_ENVELOPE_ALG || typeof env.keyId !== 'string' || typeof env.sig !== 'string') {
|
|
101
|
+
throw new Error('signing: malformed signature envelope');
|
|
102
|
+
}
|
|
103
|
+
return JSON.stringify(env, null, 2) + '\n';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Verify ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse a `wyvrn.sig` envelope and validate its shape. Rejects unknown
|
|
110
|
+
* `alg` values fail-closed so a future algorithm change must come with a
|
|
111
|
+
* verifier upgrade — never silently accept an alg the verifier can't check.
|
|
112
|
+
*/
|
|
113
|
+
function parseSigEnvelope(raw) {
|
|
114
|
+
let obj;
|
|
115
|
+
try {
|
|
116
|
+
obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new Error(`signature envelope: malformed JSON — ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
121
|
+
throw new Error('signature envelope: must be a JSON object');
|
|
122
|
+
}
|
|
123
|
+
if (obj.alg !== SIG_ENVELOPE_ALG) {
|
|
124
|
+
throw new Error(`signature envelope: unsupported alg "${obj.alg}" (this client supports "${SIG_ENVELOPE_ALG}")`);
|
|
125
|
+
}
|
|
126
|
+
if (typeof obj.keyId !== 'string' || obj.keyId.length === 0) {
|
|
127
|
+
throw new Error('signature envelope: missing keyId');
|
|
128
|
+
}
|
|
129
|
+
if (typeof obj.sig !== 'string' || obj.sig.length === 0) {
|
|
130
|
+
throw new Error('signature envelope: missing sig');
|
|
131
|
+
}
|
|
132
|
+
return obj;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Verify a downloaded attestation against the trust anchor. Throws on any
|
|
137
|
+
* failure (mismatch, unknown key, retired key, bad signature) — caller
|
|
138
|
+
* (download.js) treats every throw as a verification failure under the
|
|
139
|
+
* registry's `mode`.
|
|
140
|
+
*
|
|
141
|
+
* On success returns `{ kind, keyId, identityHint }` so download.js can
|
|
142
|
+
* stamp `.wyvrn-meta.json` with the signer.
|
|
143
|
+
*
|
|
144
|
+
* `expected` carries the consumer's view of what they downloaded:
|
|
145
|
+
* { name, version, profileHash, contentSha256, manifestSha256 }
|
|
146
|
+
*
|
|
147
|
+
* The function asserts the attestation is bound to those values — defends
|
|
148
|
+
* against URL-swap attacks where a legitimately-signed attestation for one
|
|
149
|
+
* package gets dropped into another package's URL path.
|
|
150
|
+
*/
|
|
151
|
+
function verifyAttestation({ attBytes, sigEnvelope, trustAnchors, expected }) {
|
|
152
|
+
if (!Buffer.isBuffer(attBytes)) {
|
|
153
|
+
throw new Error('verifyAttestation: attBytes must be a Buffer');
|
|
154
|
+
}
|
|
155
|
+
validateKeysJson(trustAnchors);
|
|
156
|
+
|
|
157
|
+
const env = parseSigEnvelope(sigEnvelope);
|
|
158
|
+
const att = parseAttestation(attBytes.toString('utf8'));
|
|
159
|
+
|
|
160
|
+
const lookup = findKey(trustAnchors, env.keyId, att.publishedAt);
|
|
161
|
+
if (!lookup) {
|
|
162
|
+
throw new Error(`unknown publisher keyId "${env.keyId}" — not in trust anchor`);
|
|
163
|
+
}
|
|
164
|
+
if (lookup.expired) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`key "${env.keyId}" was retired before publishedAt (${att.publishedAt}); attestation rejected`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Cryptographic verify against the canonical bytes the publisher signed.
|
|
171
|
+
// We canonicalize the *parsed* attestation rather than verifying the raw
|
|
172
|
+
// bytes directly so a bytewise-different-but-semantically-equivalent
|
|
173
|
+
// serialisation (extra whitespace, key reorder) doesn't accidentally
|
|
174
|
+
// pass — there's only one canonical form, and it must match.
|
|
175
|
+
const canonicalBytes = canonicalAttestationBytes(att);
|
|
176
|
+
const ok = ed25519.verify(lookup.found.publicKey, canonicalBytes, env.sig);
|
|
177
|
+
if (!ok) {
|
|
178
|
+
throw new Error(`signature failed verification (keyId="${env.keyId}")`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Identity + hash bindings — a valid signature over a different package
|
|
182
|
+
// is still a verification failure for *this* package.
|
|
183
|
+
assertBoundIdentity(att, expected);
|
|
184
|
+
assertBoundHashes(att, expected);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
kind: SIG_ENVELOPE_ALG,
|
|
188
|
+
keyId: env.keyId,
|
|
189
|
+
identityHint: lookup.found.identityHint ?? null,
|
|
190
|
+
attestation: att,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
SIG_ENVELOPE_ALG,
|
|
196
|
+
resolvePrivateKey,
|
|
197
|
+
signAttestation,
|
|
198
|
+
serializeSigEnvelope,
|
|
199
|
+
parseSigEnvelope,
|
|
200
|
+
verifyAttestation,
|
|
201
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a publisher signing context per the precedence locked in the S1
|
|
5
|
+
* plan §3.3:
|
|
6
|
+
*
|
|
7
|
+
* 1. `--no-sign` → skip
|
|
8
|
+
* 2. CLI `--signing-key-env` / `--signing-key-file` overrides → use those
|
|
9
|
+
* for the private key, but inherit `keyId` from per-source `signing` /
|
|
10
|
+
* `defaultSigning` so a swap of the key file can't accidentally
|
|
11
|
+
* publish under the wrong publisher identity.
|
|
12
|
+
* 3. Per-source `signing` block on the resolved source entry.
|
|
13
|
+
* 4. `defaultSigning` from config.
|
|
14
|
+
* 5. Nothing configured → null.
|
|
15
|
+
*
|
|
16
|
+
* Used identically by:
|
|
17
|
+
* - `wyvrnpm publish` (publish-side signing)
|
|
18
|
+
* - `wyvrnpm install --upload-built` (consumer-upload signing)
|
|
19
|
+
*
|
|
20
|
+
* Throws on misconfiguration (e.g. CLI override without a configured
|
|
21
|
+
* keyId, or a `signing` block missing `keyId` / `privateKey.{env|file}`).
|
|
22
|
+
*
|
|
23
|
+
* @param {object} argv the yargs argv (looks at noSign / signingKeyEnv / signingKeyFile)
|
|
24
|
+
* @param {object} config the wyvrnpm config (defaultSigning lives here)
|
|
25
|
+
* @param {object|null} sourceEntry the publish-source entry that signing is for
|
|
26
|
+
* @returns {{
|
|
27
|
+
* skip?: boolean,
|
|
28
|
+
* reason?: string,
|
|
29
|
+
* keyId?: string,
|
|
30
|
+
* alg?: 'ed25519',
|
|
31
|
+
* privateKeyEnv?: string,
|
|
32
|
+
* privateKeyFile?: string,
|
|
33
|
+
* } | null}
|
|
34
|
+
*/
|
|
35
|
+
function resolveSigningContext(argv, config, sourceEntry) {
|
|
36
|
+
if (argv?.noSign) return { skip: true, reason: '--no-sign passed on the CLI' };
|
|
37
|
+
|
|
38
|
+
const cliKeyEnv = argv?.signingKeyEnv ?? null;
|
|
39
|
+
const cliKeyFile = argv?.signingKeyFile ?? null;
|
|
40
|
+
|
|
41
|
+
const sourceSigning = sourceEntry?.signing ?? null;
|
|
42
|
+
const defaultSigning = config?.defaultSigning ?? null;
|
|
43
|
+
const signing = sourceSigning ?? defaultSigning;
|
|
44
|
+
|
|
45
|
+
if (!signing && !cliKeyEnv && !cliKeyFile) return null;
|
|
46
|
+
|
|
47
|
+
const keyId = signing?.keyId ?? null;
|
|
48
|
+
if (!keyId) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'signing: keyId is not configured. Set defaultSigning.keyId in config.json ' +
|
|
51
|
+
'or per-source signing.keyId on the source entry.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const out = { keyId, alg: 'ed25519' };
|
|
56
|
+
if (cliKeyEnv) {
|
|
57
|
+
out.privateKeyEnv = cliKeyEnv;
|
|
58
|
+
} else if (cliKeyFile) {
|
|
59
|
+
out.privateKeyFile = cliKeyFile;
|
|
60
|
+
} else if (signing.privateKey?.env) {
|
|
61
|
+
out.privateKeyEnv = signing.privateKey.env;
|
|
62
|
+
} else if (signing.privateKey?.file) {
|
|
63
|
+
out.privateKeyFile = signing.privateKey.file;
|
|
64
|
+
} else {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`signing: keyId "${keyId}" has no private-key source configured. ` +
|
|
67
|
+
'Set signing.privateKey.{env|file} in config or pass --signing-key-env / --signing-key-file.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { resolveSigningContext };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const { getConfigDir } = require('../config');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Local trust-anchor cache + TOFU diff. Mirrors PLAN-SIGNING.md §3.7 phase 1
|
|
11
|
+
* with one tightening: any non-additive change to `keys.json` (removed entry
|
|
12
|
+
* or modified `publicKey` / `alg` on an existing entry) fails closed and
|
|
13
|
+
* forces a deliberate `wyvrnpm trust refresh`. Pure additions are
|
|
14
|
+
* fast-pathed because adding a new publisher is the common rotation event
|
|
15
|
+
* and shouldn't break installs.
|
|
16
|
+
*
|
|
17
|
+
* Network IO is intentionally NOT here — callers pass in fetched bytes.
|
|
18
|
+
* Keeps the module unit-testable and avoids coupling to provider plumbing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const TRUST_DIR_NAME = 'trust';
|
|
22
|
+
const KEYS_FILE = 'keys.json';
|
|
23
|
+
const MODE_FILE = 'mode.json';
|
|
24
|
+
const TIMESTAMP_FILE = 'fetched-at.txt';
|
|
25
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
26
|
+
const VALID_MODES = new Set(['off', 'warn', 'require']);
|
|
27
|
+
const KEYS_SCHEMA_VER = 1;
|
|
28
|
+
|
|
29
|
+
// ─── Path helpers ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-source cache directory under the wyvrnpm config root. Source URL is
|
|
33
|
+
* SHA256-hashed (16-hex prefix) so any URL — `s3://bucket/path`, `https://`,
|
|
34
|
+
* UNC, file path — produces a filesystem-safe directory name.
|
|
35
|
+
*/
|
|
36
|
+
function getTrustCacheDir(source) {
|
|
37
|
+
const hash = crypto.createHash('sha256').update(source, 'utf8').digest('hex').slice(0, 16);
|
|
38
|
+
return path.join(getConfigDir(), TRUST_DIR_NAME, hash);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Validation ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function validateKeysJson(obj) {
|
|
44
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
45
|
+
throw new Error('keys.json: must be a JSON object');
|
|
46
|
+
}
|
|
47
|
+
if (obj.version !== KEYS_SCHEMA_VER) {
|
|
48
|
+
throw new Error(`keys.json: unsupported version ${obj.version} (this client supports ${KEYS_SCHEMA_VER})`);
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(obj.keys)) {
|
|
51
|
+
throw new Error('keys.json: "keys" must be an array');
|
|
52
|
+
}
|
|
53
|
+
const seenIds = new Set();
|
|
54
|
+
for (const [i, k] of obj.keys.entries()) {
|
|
55
|
+
if (k === null || typeof k !== 'object') {
|
|
56
|
+
throw new Error(`keys.json: keys[${i}] must be an object`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof k.id !== 'string' || k.id.length === 0) {
|
|
59
|
+
throw new Error(`keys.json: keys[${i}].id missing`);
|
|
60
|
+
}
|
|
61
|
+
if (seenIds.has(k.id)) {
|
|
62
|
+
throw new Error(`keys.json: duplicate keyId "${k.id}"`);
|
|
63
|
+
}
|
|
64
|
+
seenIds.add(k.id);
|
|
65
|
+
if (k.alg !== 'ed25519') {
|
|
66
|
+
throw new Error(`keys.json: keys[${i}].alg must be "ed25519" (got "${k.alg}")`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof k.publicKey !== 'string' || k.publicKey.length === 0) {
|
|
69
|
+
throw new Error(`keys.json: keys[${i}].publicKey missing`);
|
|
70
|
+
}
|
|
71
|
+
if (k.retiredAt !== null && k.retiredAt !== undefined && typeof k.retiredAt !== 'string') {
|
|
72
|
+
throw new Error(`keys.json: keys[${i}].retiredAt must be a string or null`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return obj;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateModeJson(obj) {
|
|
79
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
80
|
+
throw new Error('mode.json: must be a JSON object');
|
|
81
|
+
}
|
|
82
|
+
if (!VALID_MODES.has(obj.mode)) {
|
|
83
|
+
throw new Error(`mode.json: mode must be one of ${[...VALID_MODES].join(', ')} (got "${obj.mode}")`);
|
|
84
|
+
}
|
|
85
|
+
return obj;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Cache reads ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function readCachedKeys(source) {
|
|
91
|
+
const file = path.join(getTrustCacheDir(source), KEYS_FILE);
|
|
92
|
+
if (!fs.existsSync(file)) return null;
|
|
93
|
+
return validateKeysJson(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readCachedMode(source) {
|
|
97
|
+
const file = path.join(getTrustCacheDir(source), MODE_FILE);
|
|
98
|
+
if (!fs.existsSync(file)) return null;
|
|
99
|
+
return validateModeJson(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getCacheAgeMs(source) {
|
|
103
|
+
const stamp = path.join(getTrustCacheDir(source), TIMESTAMP_FILE);
|
|
104
|
+
if (!fs.existsSync(stamp)) return Infinity;
|
|
105
|
+
const ms = parseInt(fs.readFileSync(stamp, 'utf8').trim(), 10);
|
|
106
|
+
if (!Number.isFinite(ms)) return Infinity;
|
|
107
|
+
return Date.now() - ms;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isCacheFresh(source, ttlMs = DEFAULT_TTL_MS) {
|
|
111
|
+
return getCacheAgeMs(source) < ttlMs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Cache writes ────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function writeCache(source, { keys, mode }) {
|
|
117
|
+
const dir = getTrustCacheDir(source);
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
if (keys !== undefined) {
|
|
120
|
+
validateKeysJson(keys);
|
|
121
|
+
fs.writeFileSync(path.join(dir, KEYS_FILE), JSON.stringify(keys, null, 2) + '\n', 'utf8');
|
|
122
|
+
}
|
|
123
|
+
if (mode !== undefined) {
|
|
124
|
+
validateModeJson(mode);
|
|
125
|
+
fs.writeFileSync(path.join(dir, MODE_FILE), JSON.stringify(mode, null, 2) + '\n', 'utf8');
|
|
126
|
+
}
|
|
127
|
+
fs.writeFileSync(path.join(dir, TIMESTAMP_FILE), String(Date.now()), 'utf8');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function clearCache(source) {
|
|
131
|
+
const dir = getTrustCacheDir(source);
|
|
132
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── TOFU diff ───────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Diff a fetched `keys.json` against the cached one. Classification:
|
|
139
|
+
* - `unchanged`: cache and fetched are byte-identical.
|
|
140
|
+
* - `additive`: every cached entry is present unchanged in the fetched
|
|
141
|
+
* set; one or more new entries appear.
|
|
142
|
+
* - `breaking`: a cached entry is missing from the fetched set, OR a
|
|
143
|
+
* cached entry's `alg` / `publicKey` / `addedAt` changed.
|
|
144
|
+
* `retiredAt` and `identityHint` are allowed to change
|
|
145
|
+
* (rotation lifecycle).
|
|
146
|
+
*
|
|
147
|
+
* Returns `{ kind, addedIds, removedIds, modifiedIds }`. The caller decides
|
|
148
|
+
* how to act on `breaking` (typically: log + abort + prompt for manual
|
|
149
|
+
* `trust refresh`).
|
|
150
|
+
*/
|
|
151
|
+
function diffKeys(cached, fetched) {
|
|
152
|
+
if (cached === null) {
|
|
153
|
+
return { kind: 'first-fetch', addedIds: fetched.keys.map((k) => k.id), removedIds: [], modifiedIds: [] };
|
|
154
|
+
}
|
|
155
|
+
validateKeysJson(cached);
|
|
156
|
+
validateKeysJson(fetched);
|
|
157
|
+
|
|
158
|
+
const cachedById = new Map(cached.keys.map((k) => [k.id, k]));
|
|
159
|
+
const fetchedById = new Map(fetched.keys.map((k) => [k.id, k]));
|
|
160
|
+
|
|
161
|
+
const addedIds = [...fetchedById.keys()].filter((id) => !cachedById.has(id));
|
|
162
|
+
const removedIds = [...cachedById.keys()].filter((id) => !fetchedById.has(id));
|
|
163
|
+
const modifiedIds = [];
|
|
164
|
+
|
|
165
|
+
for (const [id, c] of cachedById) {
|
|
166
|
+
const f = fetchedById.get(id);
|
|
167
|
+
if (!f) continue;
|
|
168
|
+
if (c.alg !== f.alg || c.publicKey !== f.publicKey || (c.addedAt ?? null) !== (f.addedAt ?? null)) {
|
|
169
|
+
modifiedIds.push(id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (removedIds.length === 0 && modifiedIds.length === 0 && addedIds.length === 0) {
|
|
174
|
+
return { kind: 'unchanged', addedIds: [], removedIds: [], modifiedIds: [] };
|
|
175
|
+
}
|
|
176
|
+
if (removedIds.length === 0 && modifiedIds.length === 0) {
|
|
177
|
+
return { kind: 'additive', addedIds, removedIds: [], modifiedIds: [] };
|
|
178
|
+
}
|
|
179
|
+
return { kind: 'breaking', addedIds, removedIds, modifiedIds };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve which key in a `keys.json` should verify a signature for a given
|
|
184
|
+
* `keyId` and `publishedAt` timestamp. Honours retirement: a retired key
|
|
185
|
+
* still verifies signatures whose `publishedAt` predates `retiredAt`, but
|
|
186
|
+
* never accepts new ones.
|
|
187
|
+
*/
|
|
188
|
+
function findKey(keys, keyId, publishedAt) {
|
|
189
|
+
validateKeysJson(keys);
|
|
190
|
+
const k = keys.keys.find((entry) => entry.id === keyId);
|
|
191
|
+
if (!k) return null;
|
|
192
|
+
if (k.retiredAt && new Date(publishedAt) > new Date(k.retiredAt)) {
|
|
193
|
+
return { found: k, expired: true };
|
|
194
|
+
}
|
|
195
|
+
return { found: k, expired: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
TRUST_DIR_NAME,
|
|
200
|
+
KEYS_FILE,
|
|
201
|
+
MODE_FILE,
|
|
202
|
+
DEFAULT_TTL_MS,
|
|
203
|
+
KEYS_SCHEMA_VER,
|
|
204
|
+
getTrustCacheDir,
|
|
205
|
+
validateKeysJson,
|
|
206
|
+
validateModeJson,
|
|
207
|
+
readCachedKeys,
|
|
208
|
+
readCachedMode,
|
|
209
|
+
getCacheAgeMs,
|
|
210
|
+
isCacheFresh,
|
|
211
|
+
writeCache,
|
|
212
|
+
clearCache,
|
|
213
|
+
diffKeys,
|
|
214
|
+
findKey,
|
|
215
|
+
};
|
package/src/upload-built.js
CHANGED
|
@@ -8,6 +8,12 @@ const { getProvider } = require('./providers');
|
|
|
8
8
|
const { defaultCompatBlock } = require('./compat');
|
|
9
9
|
const { getBuildPaths } = require('./build/cache');
|
|
10
10
|
const { resolveSourceAuth } = require('./auth');
|
|
11
|
+
const { buildV2Meta } = require('./v2-meta');
|
|
12
|
+
const { sha256OfCanonical } = require('./signing/canonical');
|
|
13
|
+
const {
|
|
14
|
+
signAttestation,
|
|
15
|
+
serializeSigEnvelope,
|
|
16
|
+
} = require('./signing');
|
|
11
17
|
const log = require('./logger');
|
|
12
18
|
|
|
13
19
|
const WYVRNPM_VERSION = require('../package.json').version;
|
|
@@ -79,6 +85,14 @@ function recordUploadSidecar({ name, version, profileHash, source, contentSha256
|
|
|
79
85
|
* @param {object} [args.uploadAuth] { awsProfile?, token? }
|
|
80
86
|
* @param {Record<string, any>|null} [args.options] Effective options for this build (fed through to buildSettings.options).
|
|
81
87
|
* @param {string} [args.toolchainHash] Optional SHA of the generated wyvrn_toolchain.cmake.
|
|
88
|
+
* @param {object|null} [args.signingContext] Pre-resolved signing context
|
|
89
|
+
* from the consumer's config (`{ keyId, alg, privateKeyEnv|privateKeyFile }`).
|
|
90
|
+
* When non-null, the upload is signed identically to a `wyvrnpm publish`.
|
|
91
|
+
* When null (or omitted), the upload is unsigned — registry's `mode.json`
|
|
92
|
+
* governs whether a downstream consumer warns or aborts. The S1 plan
|
|
93
|
+
* keeps `--upload-built` skip-never-break-install semantics: a sign
|
|
94
|
+
* failure logs and proceeds without uploading, never aborts the parent
|
|
95
|
+
* install.
|
|
82
96
|
* @returns {Promise<{ uploaded: boolean, reason: string }>}
|
|
83
97
|
*/
|
|
84
98
|
async function uploadSourceBuiltArtefact(args) {
|
|
@@ -89,6 +103,7 @@ async function uploadSourceBuiltArtefact(args) {
|
|
|
89
103
|
uploadSource, uploadAuth = {},
|
|
90
104
|
options = null,
|
|
91
105
|
toolchainHash,
|
|
106
|
+
signingContext = null,
|
|
92
107
|
} = args;
|
|
93
108
|
|
|
94
109
|
if (!uploadSource) {
|
|
@@ -124,30 +139,84 @@ async function uploadSourceBuiltArtefact(args) {
|
|
|
124
139
|
|
|
125
140
|
// ── Prepare manifest: cloned wyvrn.json + compat block + uploadedBy ──────
|
|
126
141
|
const rawManifest = JSON.parse(fs.readFileSync(clonedManifestPath, 'utf8'));
|
|
127
|
-
const
|
|
142
|
+
const baseManifest = {
|
|
128
143
|
...rawManifest,
|
|
129
144
|
compatibility: rawManifest.compatibility ?? defaultCompatBlock(),
|
|
130
|
-
uploadedBy: {
|
|
131
|
-
kind: 'source-build',
|
|
132
|
-
profileHash,
|
|
133
|
-
builtFromGit: gitRepo ?? null,
|
|
134
|
-
builtFromSha: gitSha ?? null,
|
|
135
|
-
...(toolchainHash ? { toolchainHash } : {}),
|
|
136
|
-
uploadedAt: new Date().toISOString(),
|
|
137
|
-
wyvrnpmVersion: WYVRNPM_VERSION,
|
|
138
|
-
},
|
|
139
145
|
};
|
|
146
|
+
const uploadedBy = {
|
|
147
|
+
kind: 'source-build',
|
|
148
|
+
profileHash,
|
|
149
|
+
builtFromGit: gitRepo ?? null,
|
|
150
|
+
builtFromSha: gitSha ?? null,
|
|
151
|
+
...(toolchainHash ? { toolchainHash } : {}),
|
|
152
|
+
uploadedAt: new Date().toISOString(),
|
|
153
|
+
wyvrnpmVersion: WYVRNPM_VERSION,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Build v2Meta locally so the publisher's signature covers the exact
|
|
157
|
+
// bytes the provider will upload. Providers no longer mutate.
|
|
158
|
+
const publishedAt = new Date().toISOString();
|
|
159
|
+
const v2Meta = buildV2Meta(baseManifest, {
|
|
160
|
+
profileHash,
|
|
161
|
+
buildSettings: options ? { ...profile, options } : profile,
|
|
162
|
+
contentSha256,
|
|
163
|
+
gitSha: gitSha ?? null,
|
|
164
|
+
gitRepo: gitRepo ?? null,
|
|
165
|
+
publishedAt,
|
|
166
|
+
uploadedBy,
|
|
167
|
+
});
|
|
140
168
|
|
|
141
169
|
const tmpManifestPath = path.join(
|
|
142
170
|
os.tmpdir(),
|
|
143
171
|
`wyvrnpm-upload-${name}-${version}-${profileHash}-${Date.now()}.json`,
|
|
144
172
|
);
|
|
145
|
-
fs.writeFileSync(tmpManifestPath, JSON.stringify(
|
|
173
|
+
fs.writeFileSync(tmpManifestPath, JSON.stringify(v2Meta, null, 2), 'utf8');
|
|
174
|
+
|
|
175
|
+
// ── Sign (S1) ────────────────────────────────────────────────────────────
|
|
176
|
+
// The consumer signs their own attestation over the consumer's
|
|
177
|
+
// contentSha256 + manifestSha256, with `uploadedBy` populated. Author
|
|
178
|
+
// publishes share the same machinery — only the `uploadedBy` block
|
|
179
|
+
// differs.
|
|
180
|
+
let attPath = null;
|
|
181
|
+
let sigPath = null;
|
|
182
|
+
let signerKeyId = null;
|
|
183
|
+
if (signingContext) {
|
|
184
|
+
try {
|
|
185
|
+
const manifestSha256 = sha256OfCanonical(v2Meta);
|
|
186
|
+
const att = {
|
|
187
|
+
name, version, profileHash,
|
|
188
|
+
contentSha256, manifestSha256,
|
|
189
|
+
publishedAt,
|
|
190
|
+
publisherKeyId: signingContext.keyId,
|
|
191
|
+
uploadedBy,
|
|
192
|
+
};
|
|
193
|
+
const result = signAttestation(att, signingContext);
|
|
194
|
+
attPath = path.join(os.tmpdir(), `wyvrnpm-upload-att-${name}-${version}-${profileHash}-${Date.now()}.json`);
|
|
195
|
+
sigPath = path.join(os.tmpdir(), `wyvrnpm-upload-sig-${name}-${version}-${profileHash}-${Date.now()}.json`);
|
|
196
|
+
fs.writeFileSync(attPath, result.attBytes);
|
|
197
|
+
fs.writeFileSync(sigPath, serializeSigEnvelope(result.sigEnvelope));
|
|
198
|
+
signerKeyId = signingContext.keyId;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// Skip-never-break-install: a sign failure on the upload tail
|
|
201
|
+
// doesn't abort the install. The artefact extracted fine; only the
|
|
202
|
+
// upload is forfeited.
|
|
203
|
+
try { fs.unlinkSync(tmpManifestPath); } catch { /* ignore */ }
|
|
204
|
+
return { uploaded: false, reason: `sign failed: ${err.message}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
146
207
|
|
|
147
208
|
try {
|
|
148
|
-
log.info(
|
|
209
|
+
log.info(
|
|
210
|
+
`upload-built: ${name}@${version} [${profileHash}] → ${uploadSource}` +
|
|
211
|
+
(signerKeyId ? ` (signed by ${signerKeyId})` : ''),
|
|
212
|
+
);
|
|
149
213
|
await provider.v2Publish(
|
|
150
|
-
{
|
|
214
|
+
{
|
|
215
|
+
manifest: tmpManifestPath,
|
|
216
|
+
zip: artefactPath,
|
|
217
|
+
...(attPath ? { attPath } : {}),
|
|
218
|
+
...(sigPath ? { sigPath } : {}),
|
|
219
|
+
},
|
|
151
220
|
{
|
|
152
221
|
source: uploadSource,
|
|
153
222
|
name, version, profileHash,
|
|
@@ -158,7 +227,10 @@ async function uploadSourceBuiltArtefact(args) {
|
|
|
158
227
|
// Consumer uploads must not reshape the "latest" pointer — see
|
|
159
228
|
// PLAN-UPLOAD-BUILT §3.3.
|
|
160
229
|
updateLatest: false,
|
|
161
|
-
uploadedBy
|
|
230
|
+
// Note: uploadedBy already baked into the manifest via buildV2Meta.
|
|
231
|
+
// Pass it again on options for back-compat with any legacy
|
|
232
|
+
// provider that still reads it; current providers ignore it.
|
|
233
|
+
uploadedBy,
|
|
162
234
|
awsProfile,
|
|
163
235
|
token,
|
|
164
236
|
},
|
|
@@ -172,6 +244,8 @@ async function uploadSourceBuiltArtefact(args) {
|
|
|
172
244
|
return { uploaded: false, reason: `upload failed: ${err.message}` };
|
|
173
245
|
} finally {
|
|
174
246
|
try { fs.unlinkSync(tmpManifestPath); } catch { /* ignore */ }
|
|
247
|
+
if (attPath) { try { fs.unlinkSync(attPath); } catch { /* ignore */ } }
|
|
248
|
+
if (sigPath) { try { fs.unlinkSync(sigPath); } catch { /* ignore */ } }
|
|
175
249
|
}
|
|
176
250
|
}
|
|
177
251
|
|