mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(sdk): allow new signing key (#2088)
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -354,6 +354,7 @@ const signingDowngrade: NetworkCheck = {
|
||||
// signed, an unsigned update — or one signed with a different key — is REFUSED on every
|
||||
// instance that already has it. Merging that would not just fail; it would strand every
|
||||
// existing user on the version they have.
|
||||
const keyChanged = !!c.entry.authorPublicKey && c.entry.authorPublicKey !== published.authorPublicKey;
|
||||
const problems: string[] = [];
|
||||
if (!c.entry.authorPublicKey) {
|
||||
problems.push(
|
||||
@@ -361,19 +362,24 @@ const signingDowngrade: NetworkCheck = {
|
||||
' TREK refuses an unsigned update to a signed plugin — it would break the update for every existing install.\n' +
|
||||
' Sign it: pass --sign (your key should be at ~/.trek-plugin/signing.key).',
|
||||
);
|
||||
} else if (c.entry.authorPublicKey !== published.authorPublicKey) {
|
||||
} else if (keyChanged && !c.allowKeyChange) {
|
||||
problems.push(
|
||||
'• this entry changes authorPublicKey. TREK refuses a key rotation until an admin re-trusts the plugin.\n' +
|
||||
' Sign with your ORIGINAL key, or ask a maintainer for the allow-key-change label.',
|
||||
' Sign with your ORIGINAL key — or, if the rotation is deliberate, re-run with --allow-key-change\n' +
|
||||
' and ask a maintainer for the allow-key-change label.',
|
||||
);
|
||||
} else {
|
||||
// Every version must stay signed, not just the newest — TREK verifies whichever version it
|
||||
// installs, so an unsigned older block is a landmine for a pinned install.
|
||||
// installs, so an unsigned older block is a landmine for a pinned install. Under a declared
|
||||
// rotation this bites twice over: every version must be RE-signed with the new key.
|
||||
for (const v of c.entry.versions) {
|
||||
if (!v.signature) problems.push(`• ${v.version} has no signature, but this is a signed plugin — TREK will refuse to install it`);
|
||||
}
|
||||
}
|
||||
return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass('signed with the published key');
|
||||
if (problems.length) return fail(`${problems.length} problem(s)`, problems.join('\n'));
|
||||
return keyChanged
|
||||
? pass('key rotation declared — merging needs a maintainer\'s allow-key-change label, and every admin must re-trust the new key')
|
||||
: pass('signed with the published key');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -114,6 +114,12 @@ export interface CheckContext {
|
||||
registry?: string;
|
||||
/** Check every version in the entry, not just the newest. Mirrors `preflight --all`. */
|
||||
allVersions?: boolean;
|
||||
/**
|
||||
* The author declared a deliberate key rotation (`--allow-key-change`). The signing-downgrade
|
||||
* guard then accepts a changed `authorPublicKey` — every version still has to be signed — and
|
||||
* says what the rotation costs instead of refusing it. Never set by default.
|
||||
*/
|
||||
allowKeyChange?: boolean;
|
||||
}
|
||||
|
||||
/** The shape `entry.ts` builds and TREK-Plugins stores at registry/plugins/<id>.json. */
|
||||
|
||||
@@ -86,6 +86,12 @@ export function buildEntry(opts: {
|
||||
commit?: string; asset?: string; mergePath?: string; now: string;
|
||||
/** Optional Ed25519 private-key file — signs the artifact and pins the author key. */
|
||||
signKeyPath?: string;
|
||||
/**
|
||||
* Declare that a signing key DIFFERENT from the published one is a deliberate rotation, not an
|
||||
* accident. Merging still needs a registry maintainer's `allow-key-change` label, and every
|
||||
* admin who has the plugin must re-trust the new key.
|
||||
*/
|
||||
allowKeyChange?: boolean;
|
||||
}): Entry {
|
||||
const manifest = readJsonFile<Record<string, unknown>>(path.join(opts.dir, 'trek-plugin.json'));
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`);
|
||||
@@ -134,13 +140,25 @@ export function buildEntry(opts: {
|
||||
|
||||
if (opts.mergePath) {
|
||||
const existing = readJsonFile<Entry>(opts.mergePath);
|
||||
if (authorPublicKey && existing.authorPublicKey && existing.authorPublicKey !== authorPublicKey) {
|
||||
throw new Error('this signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.');
|
||||
const keyChanged = !!authorPublicKey && !!existing.authorPublicKey && existing.authorPublicKey !== authorPublicKey;
|
||||
if (keyChanged && !opts.allowKeyChange) {
|
||||
throw new Error(
|
||||
'this signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.\n' +
|
||||
'If you MEAN to rotate the key, pass --allow-key-change (the registry PR then needs a maintainer\'s allow-key-change label, and every admin must re-trust the plugin).',
|
||||
);
|
||||
}
|
||||
if (existing.authorPublicKey && !authorPublicKey) {
|
||||
throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.');
|
||||
}
|
||||
const versions = [version, ...existing.versions.filter((v) => v.version !== version.version)];
|
||||
let olderVersions = existing.versions.filter((v) => v.version !== version.version);
|
||||
if (keyChanged) {
|
||||
// A deliberate rotation. The older versions' signatures were made with the OLD key — under
|
||||
// the new authorPublicKey they are exactly the "does not verify" failure preflight rejects.
|
||||
// Strip them: the retro-sign pass then re-signs each pinned artifact with the new key, which
|
||||
// is the only way a rotated entry is internally consistent.
|
||||
olderVersions = olderVersions.map(({ signature: _dropped, ...v }) => v as Version);
|
||||
}
|
||||
const versions = [version, ...olderVersions];
|
||||
// Refresh the icon from the manifest when this release declares one (an author who adds
|
||||
// or changes it should see it in the store), but never wipe an icon the entry already
|
||||
// carries just because the manifest omits it.
|
||||
|
||||
@@ -18,7 +18,7 @@ export const VERSION_LINE = (v: string): string => `trek-plugin-sdk ${v}`;
|
||||
export const PATH_COMMANDS = ['create', 'dev', 'status', 'publish'] as const;
|
||||
|
||||
/** Real, supported, and not what a newcomer needs to see first. */
|
||||
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release', 'unrelease'] as const;
|
||||
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'rotate-key', 'entry', 'preflight', 'submit', 'release', 'unrelease'] as const;
|
||||
|
||||
export interface CommandHelp {
|
||||
/** One line, for the top-level list. */
|
||||
@@ -113,6 +113,12 @@ Scripts are never prompted; pass --sign.
|
||||
Needs git and an authenticated \`gh\`.
|
||||
|
||||
--sign [key] sign the artifact (default key: ~/.trek-plugin/signing.key)
|
||||
--allow-key-change rotate to a NEW signing key as part of this release: the older versions
|
||||
are re-signed with it, and the registry PR is flagged as a rotation (a
|
||||
maintainer must apply the allow-key-change label; every admin must
|
||||
re-trust the plugin). Without this flag, a key that differs from the
|
||||
published one is refused before anything is tagged. See \`rotate-key\`
|
||||
to rotate without shipping a version.
|
||||
--registry o/n a registry other than liketrek/TREK-Plugins
|
||||
--notes release notes
|
||||
--draft open the registry PR as a draft
|
||||
@@ -173,7 +179,8 @@ your plugin changes instead.
|
||||
|
||||
BACK IT UP. Signing is a one-way door: once a plugin has shipped signed, TREK refuses an unsigned
|
||||
update — and one signed with a different key — on every instance that already has it. Losing the
|
||||
key means you cannot ship an update to your own plugin without a maintainer override.`,
|
||||
key means \`rotate-key\` with a new one: a registry maintainer must approve the rotation, and every
|
||||
admin who has your plugin must re-trust it.`,
|
||||
},
|
||||
|
||||
sign: {
|
||||
@@ -182,6 +189,28 @@ key means you cannot ship an update to your own plugin without a maintainer over
|
||||
body: 'Prints the signature and public key for an artifact. Usually you want `publish --sign` instead.',
|
||||
},
|
||||
|
||||
'rotate-key': {
|
||||
summary: 'move a published plugin to a NEW signing key',
|
||||
usage: 'trek-plugin rotate-key [dir] [--id plugin-id] [--key file] [--out entry.json] [--draft]',
|
||||
body: `For a lost or compromised key, or a planned rotation — WITHOUT shipping a new version. Fetches
|
||||
your published registry entry, re-signs EVERY pinned version's artifact with the new key
|
||||
(all-or-nothing, each verified against its pinned sha256 first), swaps authorPublicKey, and opens
|
||||
the registry PR flagged as a rotation. To rotate WHILE shipping a version instead, use
|
||||
\`publish --sign --allow-key-change\`.
|
||||
|
||||
A rotation is never just a merge, and this command only does the half tooling can do:
|
||||
|
||||
1. a registry maintainer must apply the allow-key-change label to the PR — CI refuses the
|
||||
changed key without it
|
||||
2. every admin who already installed the plugin sees SIGNATURE_KEY_CHANGED until they re-trust
|
||||
the new key on their instance
|
||||
|
||||
--id plugin-id when not running from the plugin's directory
|
||||
--key file the NEW key (default: ~/.trek-plugin/signing.key)
|
||||
--out entry.json write the rotated entry instead of opening the PR
|
||||
--draft open the registry PR as a draft`,
|
||||
},
|
||||
|
||||
entry: {
|
||||
summary: 'print the registry entry JSON',
|
||||
usage: 'trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip] [--merge entry.json] [--out file]',
|
||||
@@ -193,6 +222,8 @@ computed by hand.
|
||||
|
||||
--merge entry.json prepend this version onto an existing entry (the update case)
|
||||
--sign [key] sign the artifact and pin the author key
|
||||
--allow-key-change accept that --sign's key differs from the published one (a deliberate
|
||||
rotation — the older versions' signatures are stripped for re-signing)
|
||||
--out file write it instead of printing it`,
|
||||
},
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ADVANCED_MENU: MenuItem[] = [
|
||||
{ value: 'pack', label: 'Pack', hint: 'Build plugin.zip without releasing it' },
|
||||
{ value: 'keygen', label: 'Generate a signing key', hint: 'Create an Ed25519 signing key' },
|
||||
{ value: 'sign', label: 'Sign an artifact', hint: 'Print a signature + public key' },
|
||||
{ value: 'rotate-key', label: 'Rotate signing key', hint: 'Re-sign every published version with a NEW key' },
|
||||
{ value: 'entry', label: 'Registry entry', hint: 'Print the ready-to-PR registry JSON' },
|
||||
{ value: 'preflight', label: 'Preflight', hint: 'The registry checks that need the release to exist' },
|
||||
{ value: 'submit', label: 'Submit', hint: 'Open the registry PR' },
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface PreflightOptions {
|
||||
registry?: string;
|
||||
/** The exact artifact bytes the entry was built from, when the caller still holds them. */
|
||||
zipBytes?: Buffer;
|
||||
/** A declared key rotation — the signing-downgrade guard accepts the changed key. */
|
||||
allowKeyChange?: boolean;
|
||||
}
|
||||
|
||||
/** Flatten a check outcome into the one-line strings this command has always printed. */
|
||||
@@ -42,6 +44,7 @@ export async function preflight(entry: RegistryEntry, opts: PreflightOptions = {
|
||||
const ctx = loadContext(opts.dir ?? '.', { entry, zipBytes: opts.zipBytes });
|
||||
ctx.registry = opts.registry;
|
||||
ctx.allVersions = opts.all;
|
||||
ctx.allowKeyChange = opts.allowKeyChange;
|
||||
|
||||
const report = await runAll(ctx);
|
||||
return {
|
||||
|
||||
@@ -51,6 +51,12 @@ export async function publishPlugin(opts: {
|
||||
skipChecks?: boolean;
|
||||
/** On failure, leave the tag/release this run created in place instead of rolling them back. */
|
||||
keepRelease?: boolean;
|
||||
/**
|
||||
* A deliberate key ROTATION: sign with a key different from the published one, re-signing the
|
||||
* older versions with it. The registry PR is flagged as a rotation — it merges only with a
|
||||
* maintainer's `allow-key-change` label, and every admin who has the plugin must re-trust it.
|
||||
*/
|
||||
allowKeyChange?: boolean;
|
||||
/**
|
||||
* A signing state the CLI already looked up (it does, to know whether to offer signing). Passed
|
||||
* in so a single publish does not hit the registry twice for the same answer.
|
||||
@@ -85,7 +91,11 @@ export async function publishPlugin(opts: {
|
||||
// So it belongs here, before anything exists to be wasted.
|
||||
const id = typeof ctx.manifest?.id === 'string' ? ctx.manifest.id : '';
|
||||
if (id) {
|
||||
assertSigningAllowed(opts.signing ?? (await inspectSigning(id, { registry: opts.registry })), opts.signKeyPath);
|
||||
assertSigningAllowed(
|
||||
opts.signing ?? (await inspectSigning(id, { registry: opts.registry })),
|
||||
opts.signKeyPath,
|
||||
{ allowKeyChange: opts.allowKeyChange },
|
||||
);
|
||||
}
|
||||
|
||||
log(` ✓ ${report.outcomes.filter((o) => o.status === 'pass').length} checks passed`);
|
||||
@@ -167,12 +177,12 @@ export async function publishPlugin(opts: {
|
||||
|
||||
try {
|
||||
// 4. Build the entry, then run the gates that need the tag and the release to exist
|
||||
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
|
||||
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now, allowKeyChange: opts.allowKeyChange });
|
||||
if (opts.skipPreflight) {
|
||||
log('[4/5] Preflight skipped (--no-preflight).');
|
||||
} else {
|
||||
step(4, 'Preflight — the gates that need the release to exist…');
|
||||
const rep = await preflight(entry, { dir, registry: opts.registry });
|
||||
const rep = await preflight(entry, { dir, registry: opts.registry, allowKeyChange: opts.allowKeyChange });
|
||||
for (const f of rep.failures) log(' ✗ ' + f);
|
||||
if (!rep.ok) {
|
||||
throw new Error(
|
||||
@@ -185,7 +195,7 @@ export async function publishPlugin(opts: {
|
||||
|
||||
// 5. Open the registry PR
|
||||
step(5, 'Opening the registry PR…');
|
||||
const { prUrl } = await submitEntry(entry, { registry: opts.registry, draft: opts.draft, signKeyPath: opts.signKeyPath });
|
||||
const { prUrl } = await submitEntry(entry, { registry: opts.registry, draft: opts.draft, signKeyPath: opts.signKeyPath, allowKeyChange: opts.allowKeyChange });
|
||||
log(' ✓ done');
|
||||
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
|
||||
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
|
||||
|
||||
@@ -46,9 +46,67 @@ export async function retroSignVersions(
|
||||
}
|
||||
|
||||
const unsigned = entry.versions.filter((v) => !v.signature);
|
||||
const signatures = await signPinnedArtifacts(unsigned, key, 'retro-sign');
|
||||
|
||||
// All-or-nothing: only mutate the entry once every artifact verified and signed.
|
||||
for (const v of unsigned) {
|
||||
v.signature = signatures.get(v.version)!;
|
||||
log(` ✓ retro-signed v${v.version}`);
|
||||
}
|
||||
return unsigned.map((v) => v.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Key ROTATION: re-sign every published version with a NEW key and swap the entry's
|
||||
* `authorPublicKey` to it.
|
||||
*
|
||||
* This is the artifact half of a rotation, and the only half tooling can do. The other half is
|
||||
* human: a registry maintainer must apply the `allow-key-change` label to the PR, and every admin
|
||||
* who already installed the plugin must re-trust it (SIGNATURE_KEY_CHANGED, one instance at a
|
||||
* time). Callers own saying that out loud — this function just makes the entry internally
|
||||
* consistent, which unsigned-or-old-key versions would not be: TREK verifies whichever version it
|
||||
* installs against `authorPublicKey`, so after a rotation EVERY version must carry a new-key
|
||||
* signature, not just the newest.
|
||||
*/
|
||||
export async function rotateSignatures(
|
||||
entry: RetroSignableEntry,
|
||||
newKeyPath: string,
|
||||
log: (line: string) => void = () => {},
|
||||
): Promise<string[]> {
|
||||
if (!entry.authorPublicKey) {
|
||||
throw new Error(
|
||||
'this plugin is not published signed — there is no key to rotate. To START signing, publish a signed update (pass --sign); the older versions are retro-signed for you.',
|
||||
);
|
||||
}
|
||||
const key = loadPrivateKey(newKeyPath);
|
||||
if (publicKeyBase64(key) === entry.authorPublicKey) {
|
||||
throw new Error(`the key at ${newKeyPath} is already the published authorPublicKey — nothing to rotate.`);
|
||||
}
|
||||
|
||||
const signatures = await signPinnedArtifacts(entry.versions, key, 'rotate');
|
||||
|
||||
// All-or-nothing: only mutate the entry once every artifact verified and signed.
|
||||
for (const v of entry.versions) {
|
||||
v.signature = signatures.get(v.version)!;
|
||||
log(` ✓ re-signed v${v.version} with the new key`);
|
||||
}
|
||||
entry.authorPublicKey = publicKeyBase64(key);
|
||||
return entry.versions.map((v) => v.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download each version's pinned artifact, verify the bytes still hash to the pinned sha256, and
|
||||
* sign those exact bytes. Returns version → signature; throws — signing nothing — if ANY version
|
||||
* fails, so both callers stay all-or-nothing.
|
||||
*/
|
||||
async function signPinnedArtifacts(
|
||||
versions: RetroSignableEntry['versions'],
|
||||
key: import('node:crypto').KeyObject,
|
||||
mode: 'retro-sign' | 'rotate',
|
||||
): Promise<Map<string, string>> {
|
||||
const signatures = new Map<string, string>();
|
||||
const failures: string[] = [];
|
||||
for (const v of unsigned) {
|
||||
for (const v of versions) {
|
||||
try {
|
||||
const r = await fetch(v.downloadUrl, { headers: { 'User-Agent': 'trek-plugin' } });
|
||||
if (!r.ok) { failures.push(`v${v.version}: HTTP ${r.status} fetching ${v.downloadUrl}`); continue; }
|
||||
@@ -65,17 +123,14 @@ export async function retroSignVersions(
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
const why = mode === 'rotate'
|
||||
? 'a rotated entry must have every version re-signed with the new key'
|
||||
: 'the registry requires every version signed once a key is present';
|
||||
throw new Error(
|
||||
`could not retro-sign ${failures.length} older version(s) — the registry requires every version signed once a key is present:\n` +
|
||||
`could not ${mode} ${failures.length} version(s) — ${why}:\n` +
|
||||
failures.map((f) => ` ✗ ${f}`).join('\n') +
|
||||
'\n\nNothing was signed. Fix the artifacts above (or remove those versions from the entry) and re-run.',
|
||||
);
|
||||
}
|
||||
|
||||
// All-or-nothing: only mutate the entry once every artifact verified and signed.
|
||||
for (const v of unsigned) {
|
||||
v.signature = signatures.get(v.version)!;
|
||||
log(` ✓ retro-signed v${v.version}`);
|
||||
}
|
||||
return unsigned.map((v) => v.version);
|
||||
return signatures;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* trek-plugin rotate-key — move a published plugin to a NEW signing key, without shipping a
|
||||
* version.
|
||||
*
|
||||
* Rotation was always possible on the registry side (a maintainer applies the `allow-key-change`
|
||||
* label) and on the TREK side (SIGNATURE_KEY_CHANGED has an admin re-trust override) — but the
|
||||
* SDK had no deliberate path to it: every publishing command refused a changed key outright.
|
||||
* This command is that path for the no-new-version case (a lost key, a compromised machine, a
|
||||
* planned rotation); `publish --allow-key-change` covers rotating as part of a release.
|
||||
*
|
||||
* What it does: fetch the plugin's published registry entry, re-sign EVERY pinned artifact with
|
||||
* the new key (rotate-sign is all-or-nothing — see retro-sign.ts), swap `authorPublicKey`, and
|
||||
* open the registry PR flagged as a rotation — or write the rotated entry to a file for a
|
||||
* hand-made PR. What it cannot do is the human half, so it says it instead: the PR merges only
|
||||
* with a maintainer's `allow-key-change` label, and every admin who has the plugin must re-trust
|
||||
* the new key.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { rotateSignatures } from './retro-sign.js';
|
||||
import { submitEntry } from './submit.js';
|
||||
import { DEFAULT_REGISTRY } from './checks/network.js';
|
||||
import { readJsonFile } from './json.js';
|
||||
import { plainLog, type LogSink } from './ui.js';
|
||||
|
||||
interface PublishedEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
authorPublicKey?: string;
|
||||
versions: Array<{ version: string; downloadUrl: string; sha256: string; signature?: string }>;
|
||||
}
|
||||
|
||||
export interface RotateKeyResult {
|
||||
id: string;
|
||||
/** Every version, re-signed with the new key. */
|
||||
rotatedVersions: string[];
|
||||
/** Set when the rotated entry was written to a file instead of submitted. */
|
||||
outPath?: string;
|
||||
/** Set when the registry PR was opened. */
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
export async function rotateKey(opts: {
|
||||
/** Plugin directory to read the id from. Ignored when `id` is given. */
|
||||
dir?: string;
|
||||
/** The plugin id, when not running from the plugin's directory. */
|
||||
id?: string;
|
||||
/** The NEW Ed25519 private key to rotate to. */
|
||||
keyPath: string;
|
||||
registry?: string;
|
||||
/** Write the rotated entry here (for a hand-made PR) instead of opening the registry PR. */
|
||||
out?: string;
|
||||
draft?: boolean;
|
||||
log?: LogSink;
|
||||
}): Promise<RotateKeyResult> {
|
||||
const log = opts.log ?? plainLog;
|
||||
const registry = opts.registry ?? DEFAULT_REGISTRY;
|
||||
|
||||
const id = opts.id || readIdFrom(opts.dir ?? '.');
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
'no plugin id — run rotate-key from the plugin directory (it reads trek-plugin.json), or pass --id <plugin-id>.',
|
||||
);
|
||||
}
|
||||
|
||||
// The registry's entry is the source of truth for what must be re-signed: the pinned versions,
|
||||
// their artifacts, and the key TREK instances currently trust.
|
||||
const entry = await fetchPublishedEntry(registry, id);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`"${id}" is not published in ${registry} — there is nothing to rotate. ` +
|
||||
'(To sign a plugin for the first time, publish a signed update: `trek-plugin publish --sign`.)',
|
||||
);
|
||||
}
|
||||
|
||||
log(`Rotating the signing key for "${id}" — re-signing ${entry.versions.length} published version(s)…`);
|
||||
const rotatedVersions = await rotateSignatures(entry, opts.keyPath, log);
|
||||
|
||||
if (opts.out) {
|
||||
fs.writeFileSync(opts.out, JSON.stringify(entry, null, 2) + '\n');
|
||||
log(`Rotated entry written to ${opts.out} — PR it as registry/plugins/${id}.json.`);
|
||||
log('Remind the maintainers it needs the allow-key-change label; every admin must then re-trust the plugin.');
|
||||
return { id, rotatedVersions, outPath: opts.out };
|
||||
}
|
||||
|
||||
const { prUrl } = await submitEntry(entry, { registry, draft: opts.draft, rotateOnly: true });
|
||||
return { id, rotatedVersions, prUrl };
|
||||
}
|
||||
|
||||
function readIdFrom(dir: string): string {
|
||||
try {
|
||||
const m = readJsonFile<{ id?: unknown }>(path.join(path.resolve(dir), 'trek-plugin.json'));
|
||||
return typeof m.id === 'string' ? m.id : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublishedEntry(registry: string, id: string): Promise<PublishedEntry | null> {
|
||||
const url = `https://raw.githubusercontent.com/${registry}/main/registry/plugins/${id}.json`;
|
||||
let text: string;
|
||||
try {
|
||||
const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin' } });
|
||||
if (r.status === 404) return null;
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
text = await r.text();
|
||||
} catch (e) {
|
||||
throw new Error(`could not read the published entry from ${registry} (${e instanceof Error ? e.message : String(e)}) — rotation needs the registry to know what to re-sign.`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as PublishedEntry;
|
||||
} catch {
|
||||
throw new Error(`the published entry at ${url} is not valid JSON — refusing to rotate over it.`);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
* GitHub release was cut. Same trap as the one `publish`'s reorder closed. It belongs in step 1.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { defaultKeyPath, generateKeypair } from './sign.js';
|
||||
import { defaultKeyPath, generateKeypair, loadPrivateKey, publicKeyBase64 } from './sign.js';
|
||||
import { DEFAULT_REGISTRY } from './checks/network.js';
|
||||
import { isInteractive, promptConfirm, note, logWarn, logSuccess } from './ui.js';
|
||||
|
||||
@@ -74,7 +74,31 @@ export async function inspectSigning(
|
||||
* most check failures are not: the release is immutable, so an author who learns at step 4 that
|
||||
* their update had to be signed has burned the tag.
|
||||
*/
|
||||
export function assertSigningAllowed(state: SigningState, signKeyPath: string | undefined): void {
|
||||
export function assertSigningAllowed(
|
||||
state: SigningState,
|
||||
signKeyPath: string | undefined,
|
||||
opts: { allowKeyChange?: boolean } = {},
|
||||
): void {
|
||||
if (state.publishedSigned && signKeyPath && state.publishedKey && !opts.allowKeyChange) {
|
||||
// Signing with a DIFFERENT key than the one TREK pinned is refused just as hard as not
|
||||
// signing — instances reject it as SIGNATURE_KEY_CHANGED. Catching it downstream (preflight,
|
||||
// submit) is catching it after the immutable release is cut, so compare identities here.
|
||||
// Best-effort only: an unreadable/missing key file is NOT a mismatch — `entry`/`sign` report
|
||||
// that with the actionable keygen hint, and inventing a "wrong key" error over it would lie.
|
||||
let localKey: string | undefined;
|
||||
try { localKey = publicKeyBase64(loadPrivateKey(signKeyPath)); } catch { /* see above */ }
|
||||
if (localKey && localKey !== state.publishedKey) {
|
||||
throw new Error(
|
||||
'this plugin was published under a DIFFERENT key than the one you are signing with.\n\n' +
|
||||
'TREK pins the author key on first install, so an update signed with another key is refused on every\n' +
|
||||
'instance that already has the plugin (SIGNATURE_KEY_CHANGED).\n\n' +
|
||||
`If this is the wrong key by accident, sign with the original one (expected authorPublicKey ${state.publishedKey}).\n` +
|
||||
'If you MEAN to rotate the key: `trek-plugin rotate-key` rotates without publishing a version, or re-run\n' +
|
||||
'this publish with --allow-key-change to rotate as part of it. Either way a registry maintainer must\n' +
|
||||
'apply the allow-key-change label to the PR, and every admin who has the plugin must re-trust it.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!state.publishedSigned || signKeyPath) return;
|
||||
|
||||
throw new Error(
|
||||
|
||||
@@ -28,26 +28,53 @@ function gh(...a: string[]): string {
|
||||
}
|
||||
|
||||
/** Merge a freshly-built single-version entry onto an existing registry file (update case). */
|
||||
function mergeOnto(existing: EntryLike, fresh: EntryLike): EntryLike {
|
||||
function mergeOnto(existing: EntryLike, fresh: EntryLike, allowKeyChange = false): { merged: EntryLike; rotated: boolean } {
|
||||
const v = fresh.versions[0];
|
||||
if (existing.authorPublicKey && fresh.authorPublicKey && existing.authorPublicKey !== fresh.authorPublicKey) {
|
||||
throw new Error('the signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.');
|
||||
const keyChanged = !!existing.authorPublicKey && !!fresh.authorPublicKey && existing.authorPublicKey !== fresh.authorPublicKey;
|
||||
if (keyChanged && !allowKeyChange) {
|
||||
throw new Error(
|
||||
'the signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.\n' +
|
||||
'If you MEAN to rotate the key, re-run with --allow-key-change (the PR then needs a maintainer\'s allow-key-change label, and every admin must re-trust the plugin).',
|
||||
);
|
||||
}
|
||||
if (existing.authorPublicKey && !fresh.authorPublicKey) {
|
||||
throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.');
|
||||
}
|
||||
const versions = [v, ...existing.versions.filter((x) => x.version !== v.version)];
|
||||
let older = existing.versions.filter((x) => x.version !== v.version);
|
||||
if (keyChanged) {
|
||||
// Deliberate rotation: the older versions' signatures were made with the OLD key and no
|
||||
// longer verify against the entry's new authorPublicKey. Strip them so the retro-sign pass
|
||||
// below re-signs each pinned artifact with the new key — a rotated entry must have EVERY
|
||||
// version signed with the key it declares.
|
||||
older = older.map(({ signature: _dropped, ...x }) => x);
|
||||
}
|
||||
const versions = [v, ...older];
|
||||
const merged: EntryLike = { ...existing, ...fresh, versions };
|
||||
merged.authorPublicKey = fresh.authorPublicKey ?? existing.authorPublicKey;
|
||||
if (merged.authorPublicKey === undefined) delete merged.authorPublicKey;
|
||||
return merged;
|
||||
return { merged, rotated: keyChanged };
|
||||
}
|
||||
|
||||
export async function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean; signKeyPath?: string } = {}): Promise<{ prUrl: string }> {
|
||||
/** The PR paragraph a key rotation always carries — maintainers gate on it, admins live with it. */
|
||||
const ROTATION_BODY =
|
||||
'**This update rotates the author signing key** (`authorPublicKey` changes, and every version is re-signed with the new key).\n' +
|
||||
'Merging needs a maintainer to apply the **allow-key-change** label — CI refuses the key change without it.\n' +
|
||||
'After merge, every instance that already has this plugin will show `SIGNATURE_KEY_CHANGED` until its admin re-trusts the new key.';
|
||||
|
||||
export async function submitEntry(entry: EntryLike, opts: {
|
||||
registry?: string; branch?: string; draft?: boolean; keep?: boolean; signKeyPath?: string;
|
||||
/** Accept a deliberately CHANGED signing key on the update path (see mergeOnto). */
|
||||
allowKeyChange?: boolean;
|
||||
/**
|
||||
* `rotate-key`: the entry IS the registry's current entry with its key rotated — no new
|
||||
* version. Write it wholesale instead of merging, and open a rotation PR.
|
||||
*/
|
||||
rotateOnly?: boolean;
|
||||
} = {}): Promise<{ prUrl: string }> {
|
||||
const registry = opts.registry || DEFAULT_REGISTRY;
|
||||
const name = registry.split('/')[1];
|
||||
const login = gh('api', 'user', '--jq', '.login');
|
||||
const branch = opts.branch || `plugin-${entry.id}-${entry.versions[0].version}`;
|
||||
const branch = opts.branch || (opts.rotateOnly ? `plugin-${entry.id}-rotate-key` : `plugin-${entry.id}-${entry.versions[0].version}`);
|
||||
|
||||
// Ensure the fork exists (idempotent — prints "already exists" if it does).
|
||||
try { gh('repo', 'fork', registry, '--clone=false'); } catch { /* already forked */ }
|
||||
@@ -84,9 +111,14 @@ export async function submitEntry(entry: EntryLike, opts: { registry?: string; b
|
||||
const abs = path.join(tmp, rel);
|
||||
let toWrite = entry;
|
||||
let action = 'add';
|
||||
if (fs.existsSync(abs)) {
|
||||
const existing = readJsonFile<EntryLike>(abs);
|
||||
toWrite = mergeOnto(existing, entry);
|
||||
let rotated = !!opts.rotateOnly;
|
||||
if (opts.rotateOnly) {
|
||||
if (!fs.existsSync(abs)) throw new Error(`no registry entry to rotate — ${rel} does not exist in ${registry}.`);
|
||||
action = 'update';
|
||||
} else if (fs.existsSync(abs)) {
|
||||
const m = mergeOnto(readJsonFile<EntryLike>(abs), entry, opts.allowKeyChange);
|
||||
toWrite = m.merged;
|
||||
rotated = m.rotated;
|
||||
action = 'update';
|
||||
}
|
||||
// First signed update onto an unsigned history: the registry refuses a mixed entry (key
|
||||
@@ -105,15 +137,20 @@ export async function submitEntry(entry: EntryLike, opts: { registry?: string; b
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, JSON.stringify(toWrite, null, 2) + '\n');
|
||||
|
||||
const title = action === 'add'
|
||||
? `Add ${entry.name || entry.id} (${entry.versions[0].version})`
|
||||
: `Update ${entry.name || entry.id} to ${entry.versions[0].version}`;
|
||||
const title = opts.rotateOnly
|
||||
? `Rotate signing key for ${entry.name || entry.id}`
|
||||
: action === 'add'
|
||||
? `Add ${entry.name || entry.id} (${entry.versions[0].version})`
|
||||
: `Update ${entry.name || entry.id} to ${entry.versions[0].version}${rotated ? ' (key rotation)' : ''}`;
|
||||
git(tmp, 'add', rel);
|
||||
git(tmp, 'commit', '-m', title);
|
||||
git(tmp, 'push', '--force-with-lease', 'origin', branch);
|
||||
|
||||
const body = [
|
||||
`${action === 'add' ? 'New plugin' : 'Plugin update'}: **${entry.name || entry.id}** \`${entry.id}\` ${entry.versions[0].version}.`,
|
||||
opts.rotateOnly
|
||||
? `Key rotation for **${entry.name || entry.id}** \`${entry.id}\` — no new version.`
|
||||
: `${action === 'add' ? 'New plugin' : 'Plugin update'}: **${entry.name || entry.id}** \`${entry.id}\` ${entry.versions[0].version}.`,
|
||||
...(rotated ? ['', ROTATION_BODY] : []),
|
||||
'',
|
||||
'Generated with `trek-plugin submit`. CI validates the tag, artifact hash, manifest parity and README.',
|
||||
].join('\n');
|
||||
|
||||
@@ -25,6 +25,7 @@ import { preflight } from './preflight.js';
|
||||
import { submitEntry } from './submit.js';
|
||||
import { publishPlugin } from './publish.js';
|
||||
import { unrelease } from './unrelease.js';
|
||||
import { rotateKey } from './rotate-key.js';
|
||||
import { needsRetroSign, retroSignVersions } from './retro-sign.js';
|
||||
import { loadContext } from './checks/context.js';
|
||||
import { runOffline } from './checks/index.js';
|
||||
@@ -79,11 +80,12 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
||||
pack: ['out', 'json'],
|
||||
keygen: ['key'],
|
||||
sign: ['key'],
|
||||
entry: ['repo', 'tag', 'dir', 'zip', 'commit', 'asset', 'merge', 'sign', 'key', 'out'],
|
||||
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all', 'registry'],
|
||||
submit: ['repo', 'tag', 'zip', 'commit', 'sign', 'key', 'registry', 'branch', 'draft', 'keep'],
|
||||
release: ['repo', 'tag', 'out', 'notes', 'commit', 'merge', 'sign', 'key'],
|
||||
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force', 'keep-release'],
|
||||
'rotate-key': ['key', 'id', 'registry', 'out', 'draft', 'yes'],
|
||||
entry: ['repo', 'tag', 'dir', 'zip', 'commit', 'asset', 'merge', 'sign', 'key', 'out', 'allow-key-change'],
|
||||
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all', 'registry', 'allow-key-change'],
|
||||
submit: ['repo', 'tag', 'zip', 'commit', 'sign', 'key', 'registry', 'branch', 'draft', 'keep', 'allow-key-change'],
|
||||
release: ['repo', 'tag', 'out', 'notes', 'commit', 'merge', 'sign', 'key', 'allow-key-change'],
|
||||
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force', 'keep-release', 'allow-key-change'],
|
||||
unrelease: ['repo', 'tag', 'registry', 'yes'],
|
||||
};
|
||||
|
||||
@@ -222,7 +224,7 @@ function readPluginId(dir: string): string {
|
||||
}
|
||||
|
||||
/** Commands that operate on a plugin directory, and so need one when the menu launched them. */
|
||||
const DIR_COMMANDS = new Set(['dev', 'status', 'validate', 'pack', 'shot', 'publish', 'preflight', 'submit', 'release']);
|
||||
const DIR_COMMANDS = new Set(['dev', 'status', 'validate', 'pack', 'shot', 'publish', 'preflight', 'submit', 'release', 'rotate-key']);
|
||||
|
||||
async function promptPluginDir(command: string): Promise<string | undefined> {
|
||||
if (!DIR_COMMANDS.has(command)) return undefined;
|
||||
@@ -337,6 +339,34 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
console.log(`signature: ${signArtifact(buf, key)}`);
|
||||
console.log(`authorPublicKey: ${publicKeyBase64(key)}`);
|
||||
}
|
||||
} else if (command === 'rotate-key') {
|
||||
const dir = positional[0] || '.';
|
||||
const keyPath = f.key || defaultKeyPath();
|
||||
const id = f.id || readPluginId(dir);
|
||||
if (tui && !f.yes) {
|
||||
note(
|
||||
`Rotating re-signs EVERY published version of "${id || '(unknown id)'}" with the key at\n${keyPath} and changes the entry's authorPublicKey.\n\n` +
|
||||
'The registry PR merges only once a maintainer applies the allow-key-change label, and every\n' +
|
||||
'admin who already installed the plugin will see SIGNATURE_KEY_CHANGED until they re-trust it.',
|
||||
'Rotate signing key',
|
||||
);
|
||||
const ok = await promptConfirm({
|
||||
message: f.out ? `Rotate and write the entry to ${f.out}?` : 'Rotate and open the registry PR?',
|
||||
initialValue: false,
|
||||
});
|
||||
if (!ok) { outro('Cancelled — nothing was rotated.'); return; }
|
||||
}
|
||||
const r = await rotateKey({
|
||||
dir, id: f.id, keyPath, registry: f.registry, out: f.out, draft: !!f.draft,
|
||||
log: tui ? clackLogSink : undefined,
|
||||
});
|
||||
if (r.prUrl) {
|
||||
const msg = 'Rotation PR opened — it merges only with a maintainer\'s allow-key-change label, and every admin must re-trust the plugin:';
|
||||
if (tui) logSuccess(msg); else console.error(msg);
|
||||
console.log(r.prUrl); // machine output on stdout
|
||||
} else if (tui && r.outPath) {
|
||||
logSuccess(`Rotated entry written to ${r.outPath}`);
|
||||
}
|
||||
} else if (command === 'entry') {
|
||||
const { repo, tag } = await ensureRepoTag(f, 'entry needs --repo <owner/name> and --tag <vX.Y.Z>');
|
||||
const entry = buildEntry({
|
||||
@@ -344,6 +374,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
zipPath: f.zip || 'plugin.zip',
|
||||
commit: f.commit, asset: f.asset, mergePath: f.merge,
|
||||
signKeyPath: signKey(f), now: new Date().toISOString(),
|
||||
allowKeyChange: !!f['allow-key-change'],
|
||||
});
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
const json = JSON.stringify(entry, null, 2) + '\n';
|
||||
@@ -360,11 +391,11 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
entry = readJsonFile<ReturnType<typeof buildEntry>>(f.entry);
|
||||
} else {
|
||||
const { repo, tag } = await ensureRepoTag(f, 'preflight needs --repo <owner/name> --tag <vX>, or --entry <file.json>');
|
||||
entry = buildEntry({ dir: positional[0] || '.', repo, tag, zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString() });
|
||||
entry = buildEntry({ dir: positional[0] || '.', repo, tag, zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString(), allowKeyChange: !!f['allow-key-change'] });
|
||||
}
|
||||
if (tui) {
|
||||
const s = spinner(); s.start('Running the registry CI checks over the network');
|
||||
const rep = await preflight(entry, { all: !!f.all });
|
||||
const rep = await preflight(entry, { all: !!f.all, registry: f.registry, allowKeyChange: !!f['allow-key-change'] });
|
||||
s.stop('Checks complete');
|
||||
for (const p of rep.passed) logSuccess(p);
|
||||
for (const fa of rep.failures) logError(fa);
|
||||
@@ -372,7 +403,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
outro('✓ all checks passed — this entry should sail through CI.');
|
||||
} else {
|
||||
console.error('Running registry CI checks over the network…\n');
|
||||
const rep = await preflight(entry, { all: !!f.all });
|
||||
const rep = await preflight(entry, { all: !!f.all, registry: f.registry, allowKeyChange: !!f['allow-key-change'] });
|
||||
for (const p of rep.passed) console.error(' ✓ ' + p);
|
||||
for (const fa of rep.failures) console.error(' ✗ ' + fa);
|
||||
if (!rep.ok) { console.error(`\n${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); }
|
||||
@@ -408,7 +439,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
dir, repo, tag,
|
||||
signKeyPath, signing, registry: f.registry, draft: !!f.draft,
|
||||
notes: f.notes, skipPreflight: !!f['no-preflight'], skipChecks: !!f['no-checks'], force: !!f.force,
|
||||
keepRelease: !!f['keep-release'],
|
||||
keepRelease: !!f['keep-release'], allowKeyChange: !!f['allow-key-change'],
|
||||
now: new Date().toISOString(),
|
||||
log: tui ? clackLogSink : undefined,
|
||||
});
|
||||
@@ -419,18 +450,19 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
const entry = buildEntry({
|
||||
dir: positional[0] || '.', repo, tag,
|
||||
zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString(),
|
||||
allowKeyChange: !!f['allow-key-change'],
|
||||
});
|
||||
if (tui) {
|
||||
note(`${entry.id} ${entry.versions[0].version}\nrepo ${repo}`, 'Submit registry PR');
|
||||
const ok = await promptConfirm({ message: 'Open the registry PR now?', initialValue: true });
|
||||
if (!ok) { outro('Cancelled — no PR opened.'); return; }
|
||||
const s = spinner(); s.start('Opening the registry PR');
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f), allowKeyChange: !!f['allow-key-change'] });
|
||||
s.stop('Registry PR opened');
|
||||
console.log(prUrl);
|
||||
} else {
|
||||
console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`);
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f), allowKeyChange: !!f['allow-key-change'] });
|
||||
console.log(prUrl);
|
||||
}
|
||||
} else if (command === 'release') {
|
||||
@@ -445,7 +477,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
const s = spinner(); s.start(`Creating GitHub release ${tag}`);
|
||||
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'pipe' });
|
||||
s.stop(`Released ${tag} on ${repo}`);
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString(), allowKeyChange: !!f['allow-key-change'] });
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
logInfo(`Registry entry (add as registry/plugins/${entry.id}.json, or run \`npx trek-plugin-sdk submit\`):`);
|
||||
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
|
||||
@@ -454,7 +486,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
console.error(`Packed ${packed.files.length} files (${packed.size} bytes).`);
|
||||
console.error(`Creating GitHub release ${tag} on ${repo}…`);
|
||||
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'inherit' });
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString(), allowKeyChange: !!f['allow-key-change'] });
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n');
|
||||
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { generateKeypair, loadPrivateKey, publicKeyBase64, verifyArtifact } from '../src/cli/sign.js';
|
||||
import { retroSignVersions } from '../src/cli/retro-sign.js';
|
||||
import { retroSignVersions, rotateSignatures } from '../src/cli/retro-sign.js';
|
||||
|
||||
const V1_BYTES = Buffer.from('the exact bytes of the v1.0.0 release asset');
|
||||
const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');
|
||||
@@ -75,3 +75,78 @@ describe('retro-sign — first signed update signs the older versions too', () =
|
||||
expect(publicKeyBase64(loadPrivateKey(keyPath))).toBe(publicKey);
|
||||
});
|
||||
});
|
||||
|
||||
const V11_BYTES = Buffer.from('the exact bytes of the v1.1.0 release asset');
|
||||
|
||||
describe('rotate — re-sign every published version with a NEW key', () => {
|
||||
let tmp: string;
|
||||
let oldKeyPath: string;
|
||||
let oldPublicKey: string;
|
||||
let newKeyPath: string;
|
||||
let newPublicKey: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rotate-sign-'));
|
||||
oldKeyPath = path.join(tmp, 'old.key');
|
||||
oldPublicKey = generateKeypair(oldKeyPath).publicKey;
|
||||
newKeyPath = path.join(tmp, 'new.key');
|
||||
newPublicKey = generateKeypair(newKeyPath).publicKey;
|
||||
vi.stubGlobal('fetch', async (url: string) => {
|
||||
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
|
||||
if (String(url).includes('v1.1.0')) return { ok: true, arrayBuffer: async () => V11_BYTES } as unknown as Response;
|
||||
return { ok: false, status: 404 } as Response;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const entry = () => ({
|
||||
id: 'rotate-plug',
|
||||
authorPublicKey: oldPublicKey,
|
||||
versions: [
|
||||
{ version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip', sha256: sha(V11_BYTES), signature: 'old-key-sig-11' },
|
||||
{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), signature: 'old-key-sig-10' },
|
||||
],
|
||||
});
|
||||
|
||||
it('re-signs EVERY version with the new key and swaps authorPublicKey to it', async () => {
|
||||
const e = entry();
|
||||
const rotated = await rotateSignatures(e, newKeyPath, () => {});
|
||||
expect(rotated).toEqual(['1.1.0', '1.0.0']);
|
||||
expect(e.authorPublicKey).toBe(newPublicKey);
|
||||
expect(verifyArtifact(V11_BYTES, e.versions[0].signature!, newPublicKey)).toBe(true);
|
||||
expect(verifyArtifact(V1_BYTES, e.versions[1].signature!, newPublicKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES when the key is already the published one — nothing to rotate', async () => {
|
||||
const e = entry();
|
||||
await expect(rotateSignatures(e, oldKeyPath, () => {})).rejects.toThrow(/already/i);
|
||||
expect(e.authorPublicKey).toBe(oldPublicKey);
|
||||
expect(e.versions[0].signature).toBe('old-key-sig-11');
|
||||
});
|
||||
|
||||
it('REFUSES an unsigned entry — that is signing late, not rotating', async () => {
|
||||
const e = { id: 'rotate-plug', versions: entry().versions } as Parameters<typeof rotateSignatures>[0];
|
||||
delete (e as { authorPublicKey?: string }).authorPublicKey;
|
||||
await expect(rotateSignatures(e, newKeyPath, () => {})).rejects.toThrow(/--sign/);
|
||||
});
|
||||
|
||||
it('is all-or-nothing: one unfetchable artifact leaves the whole entry untouched', async () => {
|
||||
const e = entry();
|
||||
e.versions[1].downloadUrl = 'https://github.com/x/y/releases/download/v9.9.9/plugin.zip'; // 404s
|
||||
await expect(rotateSignatures(e, newKeyPath, () => {})).rejects.toThrow(/1\.0\.0/);
|
||||
expect(e.authorPublicKey).toBe(oldPublicKey);
|
||||
expect(e.versions[0].signature).toBe('old-key-sig-11');
|
||||
expect(e.versions[1].signature).toBe('old-key-sig-10');
|
||||
});
|
||||
|
||||
it('is all-or-nothing: a sha256 mismatch on any version aborts the rotation', async () => {
|
||||
const e = entry();
|
||||
e.versions[1].sha256 = 'not-the-real-hash';
|
||||
await expect(rotateSignatures(e, newKeyPath, () => {})).rejects.toThrow(/1\.0\.0.*sha256/is);
|
||||
expect(e.authorPublicKey).toBe(oldPublicKey);
|
||||
expect(e.versions[0].signature).toBe('old-key-sig-11');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Key rotation — the SDK half of `allow-key-change`.
|
||||
*
|
||||
* The registry has always had a legitimate rotation path (a maintainer applies the
|
||||
* `allow-key-change` label; every admin re-trusts the plugin), but the SDK refused a changed key
|
||||
* at every choke point with no way to say "yes, on purpose". These tests pin the deliberate
|
||||
* path: `--allow-key-change` on the publishing commands, and `rotate-key` for rotating without
|
||||
* shipping a version. The accidental path — a different key with NO flag — must keep refusing.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { generateKeypair, verifyArtifact } from '../src/cli/sign.js';
|
||||
import { buildEntry } from '../src/cli/entry.js';
|
||||
import { rotateKey } from '../src/cli/rotate-key.js';
|
||||
import { NETWORK_CHECKS } from '../src/cli/checks/network.js';
|
||||
import type { CheckContext, RegistryEntry } from '../src/cli/checks/types.js';
|
||||
|
||||
const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');
|
||||
const COMMIT = 'c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00';
|
||||
|
||||
let tmp: string;
|
||||
let oldKeyPath: string;
|
||||
let oldPublicKey: string;
|
||||
let newKeyPath: string;
|
||||
let newPublicKey: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rotate-key-'));
|
||||
oldKeyPath = path.join(tmp, 'old.key');
|
||||
oldPublicKey = generateKeypair(oldKeyPath).publicKey;
|
||||
newKeyPath = path.join(tmp, 'new.key');
|
||||
newPublicKey = generateKeypair(newKeyPath).publicKey;
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('entry --merge with a changed key', () => {
|
||||
let dir: string;
|
||||
let zipPath: string;
|
||||
let mergePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = path.join(tmp, 'plug');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({
|
||||
id: 'rotate-plug', name: 'Rotate Plug', version: '1.1.0', type: 'integration',
|
||||
trek: '>=4.0.0 <5.0.0', author: 'Someone', description: 'x',
|
||||
}));
|
||||
zipPath = path.join(dir, 'plugin.zip');
|
||||
fs.writeFileSync(zipPath, Buffer.from('fresh artifact bytes'));
|
||||
mergePath = path.join(tmp, 'existing.json');
|
||||
fs.writeFileSync(mergePath, JSON.stringify({
|
||||
id: 'rotate-plug', name: 'Rotate Plug', author: 'Someone', description: 'x',
|
||||
repo: 'someone/trek-plugin-rotate-plug', type: 'integration',
|
||||
authorPublicKey: oldPublicKey,
|
||||
versions: [{
|
||||
version: '1.0.0', gitTag: 'v1.0.0', commitSha: COMMIT,
|
||||
downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip',
|
||||
sha256: 'aa'.repeat(32), trek: '>=4.0.0 <5.0.0', size: 10, apiVersion: 1,
|
||||
nativeModules: false, publishedAt: '2026-01-01T00:00:00Z', signature: 'old-key-sig',
|
||||
}],
|
||||
}));
|
||||
});
|
||||
|
||||
const build = (allowKeyChange?: boolean) => buildEntry({
|
||||
dir, repo: 'someone/trek-plugin-rotate-plug', tag: 'v1.1.0', zipPath,
|
||||
commit: COMMIT, mergePath, signKeyPath: newKeyPath, now: '2026-08-28T00:00:00Z',
|
||||
allowKeyChange,
|
||||
});
|
||||
|
||||
it('still REFUSES a changed key without the flag — and now names it', () => {
|
||||
expect(build).toThrow(/differs/i);
|
||||
expect(build).toThrow(/--allow-key-change/);
|
||||
});
|
||||
|
||||
it('with --allow-key-change: adopts the NEW key and strips the old-key signatures for retro-signing', () => {
|
||||
const entry = build(true);
|
||||
expect(entry.authorPublicKey).toBe(newPublicKey);
|
||||
const fresh = entry.versions.find((v) => v.version === '1.1.0')!;
|
||||
expect(verifyArtifact(Buffer.from('fresh artifact bytes'), fresh.signature!, newPublicKey)).toBe(true);
|
||||
// The old version's signature was made with the OLD key — dead weight under the new one.
|
||||
// Stripping it hands the version to the existing retro-sign pass, which re-signs it with
|
||||
// the new key from the pinned artifact bytes.
|
||||
const old = entry.versions.find((v) => v.version === '1.0.0')!;
|
||||
expect(old.signature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('with --allow-key-change but NO --sign: refuses — a rotation cannot ship unsigned', () => {
|
||||
expect(() => buildEntry({
|
||||
dir, repo: 'someone/trek-plugin-rotate-plug', tag: 'v1.1.0', zipPath,
|
||||
commit: COMMIT, mergePath, now: '2026-08-28T00:00:00Z', allowKeyChange: true,
|
||||
})).toThrow(/--sign/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signing-downgrade check under an intended rotation', () => {
|
||||
const signingDowngrade = NETWORK_CHECKS.find((c) => c.id === 'network.signing-downgrade')!;
|
||||
|
||||
const publishedEntry = () => ({
|
||||
id: 'rotate-plug', authorPublicKey: oldPublicKey,
|
||||
versions: [{ version: '1.0.0', signature: 'old-sig' }],
|
||||
});
|
||||
|
||||
const localEntry = (over: Partial<RegistryEntry> = {}): RegistryEntry => ({
|
||||
id: 'rotate-plug', name: 'Rotate Plug', author: 'Someone', description: 'x',
|
||||
repo: 'someone/trek-plugin-rotate-plug', type: 'integration',
|
||||
authorPublicKey: newPublicKey,
|
||||
versions: [{
|
||||
version: '1.1.0', gitTag: 'v1.1.0', commitSha: COMMIT,
|
||||
downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip',
|
||||
sha256: 'bb'.repeat(32), trek: '>=4.0.0 <5.0.0', size: 10, apiVersion: 1,
|
||||
nativeModules: false, publishedAt: '2026-08-28T00:00:00Z', signature: 'new-sig',
|
||||
}],
|
||||
...over,
|
||||
});
|
||||
|
||||
const ctx = (over: Partial<CheckContext> = {}): CheckContext => ({
|
||||
dir: tmp, exists: () => false, entry: localEntry(), ...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', async () => ({
|
||||
ok: true, text: async () => JSON.stringify(publishedEntry()),
|
||||
}) as unknown as Response);
|
||||
});
|
||||
|
||||
it('still FAILS a changed key without rotation intent', async () => {
|
||||
const r = await signingDowngrade.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/allow-key-change/);
|
||||
});
|
||||
|
||||
it('passes a changed key when the rotation is declared (allowKeyChange)', async () => {
|
||||
const r = await signingDowngrade.run(ctx({ allowKeyChange: true }));
|
||||
expect(r.status).toBe('pass');
|
||||
expect(r.detail).toMatch(/rotat/i);
|
||||
});
|
||||
|
||||
it('a declared rotation still FAILS when any version is left unsigned', async () => {
|
||||
const entry = localEntry({
|
||||
versions: [
|
||||
localEntry().versions[0],
|
||||
{ ...localEntry().versions[0], version: '1.0.0', signature: undefined },
|
||||
],
|
||||
});
|
||||
const r = await signingDowngrade.run(ctx({ entry, allowKeyChange: true }));
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/1\.0\.0/);
|
||||
});
|
||||
|
||||
it('allowKeyChange changes nothing when the key did NOT change', async () => {
|
||||
const entry = localEntry({ authorPublicKey: oldPublicKey });
|
||||
entry.versions[0].signature = 'signed-with-old-key';
|
||||
const r = await signingDowngrade.run(ctx({ entry, allowKeyChange: true }));
|
||||
expect(r.status).toBe('pass');
|
||||
expect(r.detail ?? '').not.toMatch(/rotat/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotate-key — rotate a published plugin to a new key without shipping a version', () => {
|
||||
const V1_BYTES = Buffer.from('v1.0.0 artifact');
|
||||
const V11_BYTES = Buffer.from('v1.1.0 artifact');
|
||||
let published: Record<string, unknown> | null;
|
||||
|
||||
beforeEach(() => {
|
||||
published = {
|
||||
id: 'rotate-plug', name: 'Rotate Plug', author: 'Someone', description: 'x',
|
||||
repo: 'someone/trek-plugin-rotate-plug', type: 'integration',
|
||||
authorPublicKey: oldPublicKey,
|
||||
versions: [
|
||||
{ version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip', sha256: sha(V11_BYTES), signature: 'old-sig-11' },
|
||||
{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), signature: 'old-sig-10' },
|
||||
],
|
||||
};
|
||||
vi.stubGlobal('fetch', async (url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('registry/plugins/rotate-plug.json')) {
|
||||
if (!published) return { ok: false, status: 404 } as Response;
|
||||
return { ok: true, text: async () => JSON.stringify(published), json: async () => published } as unknown as Response;
|
||||
}
|
||||
if (u.includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
|
||||
if (u.includes('v1.1.0')) return { ok: true, arrayBuffer: async () => V11_BYTES } as unknown as Response;
|
||||
return { ok: false, status: 404 } as Response;
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a fully rotated entry with --out: new key, every version re-signed', async () => {
|
||||
const out = path.join(tmp, 'rotated.json');
|
||||
const r = await rotateKey({ id: 'rotate-plug', keyPath: newKeyPath, out, log: () => {} });
|
||||
expect(r.rotatedVersions).toEqual(['1.1.0', '1.0.0']);
|
||||
expect(r.outPath).toBe(out);
|
||||
const written = JSON.parse(fs.readFileSync(out, 'utf8')) as {
|
||||
authorPublicKey: string; versions: Array<{ version: string; signature: string }>;
|
||||
};
|
||||
expect(written.authorPublicKey).toBe(newPublicKey);
|
||||
expect(verifyArtifact(V11_BYTES, written.versions[0].signature, newPublicKey)).toBe(true);
|
||||
expect(verifyArtifact(V1_BYTES, written.versions[1].signature, newPublicKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the plugin id from the directory manifest when no --id is given', async () => {
|
||||
const dir = path.join(tmp, 'plug-dir');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id: 'rotate-plug' }));
|
||||
const out = path.join(tmp, 'rotated-from-dir.json');
|
||||
const r = await rotateKey({ dir, keyPath: newKeyPath, out, log: () => {} });
|
||||
expect(r.id).toBe('rotate-plug');
|
||||
});
|
||||
|
||||
it('REFUSES when the plugin is not in the registry — nothing published, nothing to rotate', async () => {
|
||||
published = null;
|
||||
await expect(rotateKey({ id: 'rotate-plug', keyPath: newKeyPath, out: path.join(tmp, 'x.json'), log: () => {} }))
|
||||
.rejects.toThrow(/not (published|in the registry)/i);
|
||||
});
|
||||
|
||||
it('REFUSES when no plugin id can be resolved at all', async () => {
|
||||
const empty = path.join(tmp, 'not-a-plugin');
|
||||
fs.mkdirSync(empty, { recursive: true });
|
||||
await expect(rotateKey({ dir: empty, keyPath: newKeyPath, out: path.join(tmp, 'x.json'), log: () => {} }))
|
||||
.rejects.toThrow(/--id|trek-plugin\.json/);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,12 @@
|
||||
* before this, `preflight` caught that at step 4, AFTER the immutable GitHub release was cut. The
|
||||
* author's tag was burned for a problem that was knowable before a single byte was packed.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { assertSigningAllowed, type SigningState } from '../src/cli/signing.js';
|
||||
import { generateKeypair } from '../src/cli/sign.js';
|
||||
|
||||
const state = (over: Partial<SigningState> = {}): SigningState => ({
|
||||
hasKey: false,
|
||||
@@ -50,3 +54,46 @@ describe('assertSigningAllowed', () => {
|
||||
expect(run).not.toThrow(/Re-run with --sign/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertSigningAllowed — key identity (catch a rotation BEFORE the release is cut)', () => {
|
||||
let tmp: string;
|
||||
let publishedKeyPath: string;
|
||||
let publishedKey: string;
|
||||
let otherKeyPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'signing-identity-'));
|
||||
publishedKeyPath = path.join(tmp, 'published.key');
|
||||
publishedKey = generateKeypair(publishedKeyPath).publicKey;
|
||||
otherKeyPath = path.join(tmp, 'other.key');
|
||||
generateKeypair(otherKeyPath);
|
||||
});
|
||||
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
const signedState = (): SigningState => ({
|
||||
hasKey: true, keyPath: publishedKeyPath, publishedSigned: true, publishedKey,
|
||||
});
|
||||
|
||||
it('allows signing with the exact key the plugin was published under', () => {
|
||||
expect(() => assertSigningAllowed(signedState(), publishedKeyPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('REFUSES a DIFFERENT key than the published one — and names the rotation path', () => {
|
||||
// Without this, the mismatch surfaces at preflight/submit, AFTER the immutable release is
|
||||
// cut. Same trap as the unsigned-update case; same fix: fail at step 1.
|
||||
const run = () => assertSigningAllowed(signedState(), otherKeyPath);
|
||||
expect(run).toThrow(/different key|differs/i);
|
||||
expect(run).toThrow(/--allow-key-change/);
|
||||
expect(run).toThrow(/rotate-key/);
|
||||
});
|
||||
|
||||
it('lets a deliberate rotation through with allowKeyChange', () => {
|
||||
expect(() => assertSigningAllowed(signedState(), otherKeyPath, { allowKeyChange: true })).not.toThrow();
|
||||
});
|
||||
|
||||
it('an unreadable key file is not an identity mismatch — downstream has the better error', () => {
|
||||
// The guard must never invent a "wrong key" refusal out of a missing file: `entry`/`sign`
|
||||
// report a missing key with the keygen hint, which is the actionable message.
|
||||
expect(() => assertSigningAllowed(signedState(), path.join(tmp, 'nope.key'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,3 +108,88 @@ describe('submit — fork sync and retro-signing', () => {
|
||||
await expect(submitEntry(freshEntry(true), {})).rejects.toThrow(/--sign/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit — key rotation', () => {
|
||||
let tmp: string;
|
||||
let oldKeyPath: string;
|
||||
let oldPublicKey: string;
|
||||
let newKeyPath: string;
|
||||
let newPublicKey: string;
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
failSync = false;
|
||||
lastCloneDir = '';
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-rotate-'));
|
||||
oldKeyPath = path.join(tmp, 'old.key');
|
||||
oldPublicKey = generateKeypair(oldKeyPath).publicKey;
|
||||
newKeyPath = path.join(tmp, 'new.key');
|
||||
newPublicKey = generateKeypair(newKeyPath).publicKey;
|
||||
existingEntry = {
|
||||
id: 'sync-plug', name: 'Sync Plug', authorPublicKey: oldPublicKey,
|
||||
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), signature: 'old-key-sig' }],
|
||||
};
|
||||
vi.stubGlobal('fetch', async (url: string) => {
|
||||
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
|
||||
return { ok: false, status: 404 } as Response;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
existingEntry = null;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
if (lastCloneDir) fs.rmSync(lastCloneDir, { recursive: true, force: true });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const freshWithNewKey = () => ({
|
||||
id: 'sync-plug', name: 'Sync Plug', authorPublicKey: newPublicKey,
|
||||
versions: [{
|
||||
version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip',
|
||||
sha256: 'deadbeef', signature: 'new-key-sig',
|
||||
}],
|
||||
});
|
||||
|
||||
const writtenEntry = () => JSON.parse(fs.readFileSync(path.join(lastCloneDir, 'registry', 'plugins', 'sync-plug.json'), 'utf8')) as {
|
||||
authorPublicKey?: string;
|
||||
versions: Array<{ version: string; signature?: string }>;
|
||||
};
|
||||
|
||||
it('still REFUSES an update under a different key without allowKeyChange', async () => {
|
||||
await expect(submitEntry(freshWithNewKey(), { signKeyPath: newKeyPath })).rejects.toThrow(/original key/i);
|
||||
});
|
||||
|
||||
it('with allowKeyChange: adopts the new key and RE-SIGNS the older versions with it', async () => {
|
||||
await submitEntry(freshWithNewKey(), { signKeyPath: newKeyPath, allowKeyChange: true, keep: true });
|
||||
const written = writtenEntry();
|
||||
expect(written.authorPublicKey).toBe(newPublicKey);
|
||||
const v1 = written.versions.find((v) => v.version === '1.0.0')!;
|
||||
expect(verifyArtifact(V1_BYTES, v1.signature!, newPublicKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('a rotation PR says so — title and body ask for the allow-key-change label', async () => {
|
||||
await submitEntry(freshWithNewKey(), { signKeyPath: newKeyPath, allowKeyChange: true, keep: true });
|
||||
const pr = calls.find((c) => c.bin === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create')!;
|
||||
const title = pr.args[pr.args.indexOf('--title') + 1];
|
||||
const body = pr.args[pr.args.indexOf('--body') + 1];
|
||||
expect(title).toMatch(/key rotation/i);
|
||||
expect(body).toMatch(/allow-key-change/);
|
||||
expect(body).toMatch(/re-trust/i);
|
||||
});
|
||||
|
||||
it('rotateOnly: replaces the entry wholesale (no new version) with a rotation PR', async () => {
|
||||
const rotated = {
|
||||
id: 'sync-plug', name: 'Sync Plug', authorPublicKey: newPublicKey,
|
||||
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), signature: 'new-key-sig-10' }],
|
||||
};
|
||||
await submitEntry(rotated, { keep: true, rotateOnly: true });
|
||||
const written = writtenEntry();
|
||||
expect(written.authorPublicKey).toBe(newPublicKey);
|
||||
expect(written.versions).toHaveLength(1);
|
||||
expect(written.versions[0].signature).toBe('new-key-sig-10');
|
||||
const pr = calls.find((c) => c.bin === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create')!;
|
||||
const title = pr.args[pr.args.indexOf('--title') + 1];
|
||||
const body = pr.args[pr.args.indexOf('--body') + 1];
|
||||
expect(title).toMatch(/rotate/i);
|
||||
expect(body).toMatch(/allow-key-change/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user