wyvrnpm 2.12.2 → 2.13.11
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 +163 -2
- package/bin/wyvrnpm.js +167 -11
- package/cmake/cpp.cmake +6 -8
- 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/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/toolchain/template.cmake +7 -5
- package/src/upload-built.js +88 -14
- package/src/v2-meta.js +98 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getProvider } = require('../providers');
|
|
4
|
+
const { readConfig } = require('../config');
|
|
5
|
+
const { resolveSourceAuth } = require('../auth');
|
|
6
|
+
const trust = require('../signing/trust');
|
|
7
|
+
const log = require('../logger');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a `--source` argument (URL or configured-source name) against
|
|
11
|
+
* `installSources` first then `publishSources`. Trust anchors live with
|
|
12
|
+
* the registry, not the consumer's view of it; both side's source lists
|
|
13
|
+
* point at the same buckets, so accepting either is the right UX.
|
|
14
|
+
*/
|
|
15
|
+
function resolveTrustSource(argv) {
|
|
16
|
+
const config = readConfig();
|
|
17
|
+
const all = [...(config.installSources ?? []), ...(config.publishSources ?? [])];
|
|
18
|
+
let arg = argv.source;
|
|
19
|
+
if (!arg && all.length === 1) arg = all[0].name;
|
|
20
|
+
if (!arg) {
|
|
21
|
+
log.error(
|
|
22
|
+
'specify a source: `wyvrnpm trust <cmd> --source <name|url>` ' +
|
|
23
|
+
'(or configure exactly one installSources entry)',
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const named = all.find((s) => s.name === arg);
|
|
28
|
+
if (named) return { source: named.url, sourceEntry: named, awsProfile: argv.awsProfile ?? named.profile };
|
|
29
|
+
return { source: arg, sourceEntry: null, awsProfile: argv.awsProfile };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `wyvrnpm trust list [--source <name|url>] [--format json]`
|
|
34
|
+
*
|
|
35
|
+
* Prints the locally-cached trust anchor for a registry. Read-only —
|
|
36
|
+
* does NOT fetch. Pair with `trust refresh` when stale.
|
|
37
|
+
*/
|
|
38
|
+
async function list(argv) {
|
|
39
|
+
const { source } = resolveTrustSource(argv);
|
|
40
|
+
const keys = trust.readCachedKeys(source);
|
|
41
|
+
const mode = trust.readCachedMode(source);
|
|
42
|
+
const ageMs = trust.getCacheAgeMs(source);
|
|
43
|
+
|
|
44
|
+
if (argv.format === 'json') {
|
|
45
|
+
process.stdout.write(JSON.stringify({
|
|
46
|
+
command: 'trust list',
|
|
47
|
+
wyvrnpmVersion: require('../../package.json').version,
|
|
48
|
+
source,
|
|
49
|
+
keys, mode,
|
|
50
|
+
cacheAgeMs: Number.isFinite(ageMs) ? ageMs : null,
|
|
51
|
+
fresh: trust.isCacheFresh(source),
|
|
52
|
+
}, null, 2) + '\n');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!keys && !mode) {
|
|
57
|
+
log.warn(`no cached trust anchor for ${source}. Run \`wyvrnpm trust refresh --source ${argv.source ?? source}\` to fetch.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(`Trust anchor for ${source}`);
|
|
62
|
+
console.log(` cached : ${Number.isFinite(ageMs) ? `${Math.round(ageMs / 1000)}s ago` : 'unknown'}`);
|
|
63
|
+
console.log(` mode : ${mode?.mode ?? '(none — defaults to off)'}`);
|
|
64
|
+
console.log(` keys : ${keys?.keys?.length ?? 0}`);
|
|
65
|
+
for (const k of keys?.keys ?? []) {
|
|
66
|
+
const status = k.retiredAt ? `retired ${k.retiredAt}` : 'active';
|
|
67
|
+
console.log(` - ${k.id} [${k.alg}] ${status} ${k.identityHint ? `(${k.identityHint})` : ''}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `wyvrnpm trust refresh [--source <name|url>] [--format json]`
|
|
73
|
+
*
|
|
74
|
+
* Fetch `.trust/keys.json` and `.trust/mode.json` from the registry.
|
|
75
|
+
* TOFU diff classification:
|
|
76
|
+
* - `first-fetch` / `unchanged` / `additive` → cache + accept silently
|
|
77
|
+
* (additive logs the new keyIds).
|
|
78
|
+
* - `breaking` → DO NOT cache; print diff
|
|
79
|
+
* and exit 1. The user must re-run with `--accept-breaking` to pin
|
|
80
|
+
* the new state. (Phase 2 will require a root-key signature instead.)
|
|
81
|
+
*/
|
|
82
|
+
async function refresh(argv) {
|
|
83
|
+
const { source, sourceEntry, awsProfile } = resolveTrustSource(argv);
|
|
84
|
+
const { token } = resolveSourceAuth({}, sourceEntry);
|
|
85
|
+
|
|
86
|
+
let provider;
|
|
87
|
+
try { provider = getProvider(source); }
|
|
88
|
+
catch (err) { log.error(`no provider for ${source}: ${err.message}`); process.exit(1); }
|
|
89
|
+
|
|
90
|
+
const fetched = await provider.v2GetTrustAnchor({ source, awsProfile, token });
|
|
91
|
+
if (!fetched) {
|
|
92
|
+
log.warn(`no .trust/ directory found at ${source} — registry has not adopted S1 (mode=off)`);
|
|
93
|
+
if (argv.format === 'json') {
|
|
94
|
+
process.stdout.write(JSON.stringify({ command: 'trust refresh', source, found: false }, null, 2) + '\n');
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cached = trust.readCachedKeys(source);
|
|
100
|
+
const diff = fetched.keys ? trust.diffKeys(cached, fetched.keys) : { kind: 'unchanged' };
|
|
101
|
+
|
|
102
|
+
if (diff.kind === 'breaking' && !argv.acceptBreaking) {
|
|
103
|
+
log.error(
|
|
104
|
+
`trust anchor at ${source} changed in a non-additive way:\n` +
|
|
105
|
+
` removed: ${diff.removedIds.join(', ') || '(none)'}\n` +
|
|
106
|
+
` modified: ${diff.modifiedIds.join(', ') || '(none)'}\n` +
|
|
107
|
+
` added: ${diff.addedIds.join(', ') || '(none)'}\n` +
|
|
108
|
+
'Re-run with --accept-breaking to overwrite the cache. Phase 2 will require a root-key signature instead.',
|
|
109
|
+
);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
trust.writeCache(source, { keys: fetched.keys ?? undefined, mode: fetched.mode ?? undefined });
|
|
114
|
+
|
|
115
|
+
if (argv.format === 'json') {
|
|
116
|
+
process.stdout.write(JSON.stringify({
|
|
117
|
+
command: 'trust refresh',
|
|
118
|
+
wyvrnpmVersion: require('../../package.json').version,
|
|
119
|
+
source,
|
|
120
|
+
diff,
|
|
121
|
+
keys: fetched.keys,
|
|
122
|
+
mode: fetched.mode,
|
|
123
|
+
}, null, 2) + '\n');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log.success(`refreshed trust anchor for ${source} (${diff.kind})`);
|
|
128
|
+
if (diff.kind === 'additive') {
|
|
129
|
+
log.info(` added keys : ${diff.addedIds.join(', ')}`);
|
|
130
|
+
} else if (diff.kind === 'breaking') {
|
|
131
|
+
log.warn(` breaking diff accepted: -${diff.removedIds.length} ~${diff.modifiedIds.length} +${diff.addedIds.length}`);
|
|
132
|
+
}
|
|
133
|
+
log.info(` mode : ${fetched.mode?.mode ?? '(none — defaults to off)'}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { list, refresh, resolveTrustSource };
|
package/src/conf/index.js
CHANGED
|
@@ -21,14 +21,29 @@ const { validateFlatConfKeys } = require('./namespaces');
|
|
|
21
21
|
|
|
22
22
|
const LOCAL_OVERLAY_FILENAME = 'wyvrn.local.json';
|
|
23
23
|
|
|
24
|
-
// Per plan §4.5 "Strict top-level allow-list".
|
|
25
|
-
//
|
|
26
|
-
// - `
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
24
|
+
// Per plan §4.5 "Strict top-level allow-list". Top-level keys today:
|
|
25
|
+
// - `conf` — non-ABI build-time knobs (PLAN-CONF.md).
|
|
26
|
+
// - `binaryDirRoot` — dev-local override of the preset's build
|
|
27
|
+
// subdir (sibling channel to --build-dir on
|
|
28
|
+
// install/build). See src/binary-dir.js.
|
|
29
|
+
// - `requestConfigs` — global per-config artefact selection;
|
|
30
|
+
// dev-local / runner-local default. Array of
|
|
31
|
+
// CMake config names; intersected with the
|
|
32
|
+
// author's `artefactConfigs` at install time.
|
|
33
|
+
// - `depRequestConfigs` — per-dep override of `requestConfigs`. Map
|
|
34
|
+
// of `{ <depName>: [<config>, ...] }`. When a
|
|
35
|
+
// dep is named here, the listed configs apply
|
|
36
|
+
// to it ONLY; everything else inherits the
|
|
37
|
+
// global `requestConfigs`. Use case: "fetch
|
|
38
|
+
// all four configs of fmt + zlib, but only
|
|
39
|
+
// MinSizeRel of the multi-GB grpc."
|
|
30
40
|
// Adding dependencies/options/etc. is plan-2 territory.
|
|
31
|
-
const LOCAL_OVERLAY_ALLOWED_TOP_KEYS = new Set([
|
|
41
|
+
const LOCAL_OVERLAY_ALLOWED_TOP_KEYS = new Set([
|
|
42
|
+
'conf',
|
|
43
|
+
'binaryDirRoot',
|
|
44
|
+
'requestConfigs',
|
|
45
|
+
'depRequestConfigs',
|
|
46
|
+
]);
|
|
32
47
|
|
|
33
48
|
// ---------------------------------------------------------------------------
|
|
34
49
|
// Value normalisation
|
|
@@ -203,11 +218,24 @@ function parseCliConf(rawArgs) {
|
|
|
203
218
|
* phase 1 is `conf`; anything else is a hard error (not a warning).
|
|
204
219
|
*
|
|
205
220
|
* @param {string} rootDir
|
|
206
|
-
* @returns {{
|
|
221
|
+
* @returns {{
|
|
222
|
+
* flat: Record<string,string>,
|
|
223
|
+
* path: string|null,
|
|
224
|
+
* binaryDirRoot: string|null,
|
|
225
|
+
* requestConfigs: string[]|null,
|
|
226
|
+
* depRequestConfigs: Record<string, string[]>|null,
|
|
227
|
+
* }}
|
|
207
228
|
*/
|
|
208
229
|
function readLocalOverlay(rootDir) {
|
|
209
230
|
const p = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
|
|
210
|
-
if (!fs.existsSync(p))
|
|
231
|
+
if (!fs.existsSync(p)) {
|
|
232
|
+
return {
|
|
233
|
+
flat: {}, path: null,
|
|
234
|
+
binaryDirRoot: null,
|
|
235
|
+
requestConfigs: null,
|
|
236
|
+
depRequestConfigs: null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
211
239
|
|
|
212
240
|
let raw;
|
|
213
241
|
try {
|
|
@@ -243,7 +271,84 @@ function readLocalOverlay(rootDir) {
|
|
|
243
271
|
? raw.binaryDirRoot
|
|
244
272
|
: null;
|
|
245
273
|
|
|
246
|
-
|
|
274
|
+
// `requestConfigs` shape-validates here; intersection with the
|
|
275
|
+
// author's `artefactConfigs` happens in download.js. Custom CMake
|
|
276
|
+
// configs (e.g. "Profile") are intentionally permitted — the
|
|
277
|
+
// intersection will reject unknown names with a clear diagnostic
|
|
278
|
+
// listing what the author actually published.
|
|
279
|
+
let requestConfigs = null;
|
|
280
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'requestConfigs')) {
|
|
281
|
+
const v = raw.requestConfigs;
|
|
282
|
+
if (!Array.isArray(v)) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`${LOCAL_OVERLAY_FILENAME}: requestConfigs must be an array of CMake config names — ` +
|
|
285
|
+
`got ${JSON.stringify(v)}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (v.length === 0) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`${LOCAL_OVERLAY_FILENAME}: requestConfigs must not be empty — ` +
|
|
291
|
+
`omit the key to inherit the recipe's build.configs`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
for (const entry of v) {
|
|
295
|
+
if (typeof entry !== 'string' || entry.trim() !== entry || entry.length === 0) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`${LOCAL_OVERLAY_FILENAME}: requestConfigs entries must be non-empty trimmed strings — ` +
|
|
298
|
+
`got ${JSON.stringify(entry)}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
requestConfigs = v.slice();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// `depRequestConfigs` shape-validates here. Each entry is the same
|
|
306
|
+
// array-of-config-names contract as `requestConfigs`; the dep-name
|
|
307
|
+
// key is intentionally NOT validated against any registered package
|
|
308
|
+
// list — names are resolved at install time and a typo just means
|
|
309
|
+
// "no override applies" (with the global `requestConfigs` taking over).
|
|
310
|
+
let depRequestConfigs = null;
|
|
311
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'depRequestConfigs')) {
|
|
312
|
+
const map = raw.depRequestConfigs;
|
|
313
|
+
if (map === null || typeof map !== 'object' || Array.isArray(map)) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`${LOCAL_OVERLAY_FILENAME}: depRequestConfigs must be an object keyed by dep name — ` +
|
|
316
|
+
`got ${JSON.stringify(map)}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const out = {};
|
|
320
|
+
for (const [depName, val] of Object.entries(map)) {
|
|
321
|
+
if (typeof depName !== 'string' || depName.trim() !== depName || depName.length === 0) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`${LOCAL_OVERLAY_FILENAME}: depRequestConfigs key ${JSON.stringify(depName)} must be a non-empty trimmed string`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (!Array.isArray(val)) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`${LOCAL_OVERLAY_FILENAME}: depRequestConfigs[${JSON.stringify(depName)}] must be an array of CMake config names — ` +
|
|
329
|
+
`got ${JSON.stringify(val)}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (val.length === 0) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`${LOCAL_OVERLAY_FILENAME}: depRequestConfigs[${JSON.stringify(depName)}] must not be empty — ` +
|
|
335
|
+
`omit the entry to inherit the global requestConfigs`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
for (const entry of val) {
|
|
339
|
+
if (typeof entry !== 'string' || entry.trim() !== entry || entry.length === 0) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`${LOCAL_OVERLAY_FILENAME}: depRequestConfigs[${JSON.stringify(depName)}] entries must be non-empty trimmed strings — ` +
|
|
342
|
+
`got ${JSON.stringify(entry)}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
out[depName] = val.slice();
|
|
347
|
+
}
|
|
348
|
+
depRequestConfigs = out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { flat, path: p, binaryDirRoot, requestConfigs, depRequestConfigs };
|
|
247
352
|
}
|
|
248
353
|
|
|
249
354
|
// ---------------------------------------------------------------------------
|
|
@@ -356,7 +461,13 @@ function resolveEffectiveConf({ manifest, rootDir, cliConf, profile }) {
|
|
|
356
461
|
const recipeFlat = readRecipeConf(manifest);
|
|
357
462
|
const profileFlat = readProfileConf(profile);
|
|
358
463
|
const overlay = readLocalOverlay(rootDir);
|
|
359
|
-
const {
|
|
464
|
+
const {
|
|
465
|
+
flat: localFlat,
|
|
466
|
+
path: localOverlayPath,
|
|
467
|
+
binaryDirRoot: localBinaryDirRoot,
|
|
468
|
+
requestConfigs: localRequestConfigs,
|
|
469
|
+
depRequestConfigs: localDepRequestConfigs,
|
|
470
|
+
} = overlay;
|
|
360
471
|
const cli = parseCliConf(cliConf);
|
|
361
472
|
const flat = mergeConfLayers(recipeFlat, localFlat, cli, profileFlat);
|
|
362
473
|
return {
|
|
@@ -370,6 +481,15 @@ function resolveEffectiveConf({ manifest, rootDir, cliConf, profile }) {
|
|
|
370
481
|
// re-reading the overlay file. Validated downstream — this just
|
|
371
482
|
// forwards the raw value the user wrote.
|
|
372
483
|
localBinaryDirRoot,
|
|
484
|
+
// Forwarded for the per-config slice resolver in install.js. Null
|
|
485
|
+
// when the overlay omits it; the resolver falls through to recipe
|
|
486
|
+
// build.configs in that case.
|
|
487
|
+
localRequestConfigs,
|
|
488
|
+
// Per-dep override of the slice resolver. Map of `{ depName: [...] }`.
|
|
489
|
+
// When a dep is named here, those configs apply to it specifically;
|
|
490
|
+
// otherwise the global `localRequestConfigs` (or its fallback chain)
|
|
491
|
+
// takes over.
|
|
492
|
+
localDepRequestConfigs,
|
|
373
493
|
};
|
|
374
494
|
}
|
|
375
495
|
|
package/src/config.js
CHANGED
|
@@ -26,12 +26,21 @@ function getConfigPath() {
|
|
|
26
26
|
/**
|
|
27
27
|
* Read and parse config.json.
|
|
28
28
|
* Returns a default empty config if the file doesn't exist yet.
|
|
29
|
-
* @returns {{
|
|
29
|
+
* @returns {{
|
|
30
|
+
* installSources: SourceEntry[],
|
|
31
|
+
* publishSources: SourceEntry[],
|
|
32
|
+
* linkedPackages: Object<string, string>,
|
|
33
|
+
* defaultProfile: string,
|
|
34
|
+
* defaultSigning: SigningConfig|null,
|
|
35
|
+
* }}
|
|
30
36
|
*/
|
|
31
37
|
function readConfig() {
|
|
32
38
|
const configPath = getConfigPath();
|
|
33
39
|
if (!fs.existsSync(configPath)) {
|
|
34
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
installSources: [], publishSources: [], linkedPackages: {},
|
|
42
|
+
defaultProfile: 'default', defaultSigning: null,
|
|
43
|
+
};
|
|
35
44
|
}
|
|
36
45
|
try {
|
|
37
46
|
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
@@ -41,10 +50,19 @@ function readConfig() {
|
|
|
41
50
|
linkedPackages: raw.linkedPackages ?? {},
|
|
42
51
|
// Migrate: if old config had an inline `profile` object, drop it; use file-based profiles.
|
|
43
52
|
defaultProfile: raw.defaultProfile ?? 'default',
|
|
53
|
+
// S1: optional signing config, never auto-populated. Each
|
|
54
|
+
// installSources / publishSources entry MAY also carry `signing`
|
|
55
|
+
// (publish-side) and `trustRootPub` (install-side, phase 2)
|
|
56
|
+
// overrides; consumers consult the per-source value first and
|
|
57
|
+
// fall back to defaultSigning.
|
|
58
|
+
defaultSigning: raw.defaultSigning ?? null,
|
|
44
59
|
};
|
|
45
60
|
} catch {
|
|
46
61
|
log.warn('could not parse config.json — using defaults');
|
|
47
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
installSources: [], publishSources: [], linkedPackages: {},
|
|
64
|
+
defaultProfile: 'default', defaultSigning: null,
|
|
65
|
+
};
|
|
48
66
|
}
|
|
49
67
|
}
|
|
50
68
|
|
|
@@ -61,5 +79,18 @@ function writeConfig(config) {
|
|
|
61
79
|
module.exports = { getConfigDir, getConfigPath, readConfig, writeConfig };
|
|
62
80
|
|
|
63
81
|
/**
|
|
64
|
-
* @typedef {{ name: string, url: string, profile?: string, token?: string }} SourceEntry
|
|
82
|
+
* @typedef {{ name: string, url: string, profile?: string, token?: string, signing?: SigningConfig, trustRootPub?: string }} SourceEntry
|
|
83
|
+
*
|
|
84
|
+
* @typedef {{
|
|
85
|
+
* alg: 'ed25519',
|
|
86
|
+
* keyId: string,
|
|
87
|
+
* privateKey: { env: string } | { file: string },
|
|
88
|
+
* }} SigningConfig
|
|
89
|
+
*
|
|
90
|
+
* `signing` on a publishSources entry overrides `defaultSigning` for that
|
|
91
|
+
* specific destination; useful when one team-shared registry uses a
|
|
92
|
+
* different keyId from another. `trustRootPub` on an installSources entry
|
|
93
|
+
* (phase 2) carries an SPKI-PEM root key pinned by the consumer; the
|
|
94
|
+
* registry's keys.json will then need to be signed by that root for the
|
|
95
|
+
* install to accept rotations.
|
|
65
96
|
*/
|