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
+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');