feat(sdk): allow new signing key (#2088)

This commit is contained in:
jubnl
2026-08-28 00:56:44 +02:00
committed by GitHub
parent 922f37ab52
commit d8fd2deb12
22 changed files with 915 additions and 71 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+10 -4
View File
@@ -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');
},
};
+6
View File
@@ -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. */
+21 -3
View File
@@ -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.
+33 -2
View File
@@ -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`,
},
+1
View File
@@ -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' },
+3
View File
@@ -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 {
+14 -4
View File
@@ -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
+64 -9
View File
@@ -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;
}
+115
View File
@@ -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.`);
}
}
+26 -2
View File
@@ -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(
+51 -14
View File
@@ -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');
+46 -14
View File
@@ -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');
+76 -1
View File
@@ -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');
});
});
+226
View File
@@ -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/);
});
});
+48 -1
View File
@@ -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();
});
});
+85
View File
@@ -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/);
});
});