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(
+50 -13
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'
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}`;
: `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/);
});
});
+18 -4
View File
@@ -1375,7 +1375,8 @@ whatever else it needs. Pass a command explicitly to skip the menu (and for scri
CI). `trek-plugin help <command>` — or `trek-plugin <command> --help` — prints a full
page for any command.
**The path is four commands**, and the other ten are steps one of them already does:
**The path is four commands**, and the others are steps one of them already does
(with two exceptions that undo or repair: `unrelease` and `rotate-key`):
```bash
trek-plugin create [name] [--type integration|page|widget|trip-page]
@@ -1412,6 +1413,15 @@ trek-plugin shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-
trek-plugin keygen [--key file]
trek-plugin sign [zip] [--key file]
# Move a published plugin to a NEW signing key WITHOUT shipping a version (lost or
# compromised key, planned rotation): fetches your published entry, re-signs every
# pinned version's artifact with the new key (each verified against its sha256 first,
# all-or-nothing), swaps authorPublicKey, and opens a registry PR flagged as a
# rotation. The PR needs a maintainer's `allow-key-change` label, and every admin who
# has the plugin must re-trust the new key. To rotate WHILE shipping a version, use
# `publish --sign --allow-key-change` instead.
trek-plugin rotate-key [dir] [--id plugin-id] [--key file] [--out entry.json] [--draft]
# Emit the ready-to-PR registry entry: commitSha (resolved from the git tag),
# downloadUrl, sha256, size, and the manifest's `trek` range verbatim — plus
# requiredAddons and pluginDependencies, which the registry parity-checks against the
@@ -1422,8 +1432,9 @@ trek-plugin entry --repo owner/name --tag vX.Y.Z [--zip plugin.zip] [--merge ent
# The registry checks that genuinely need the network: the tag resolves to the commit
# the entry pins, the released artifact downloads and hashes to the pinned sha256, the
# id is not bound to another GitHub owner, and an update does not drop or rotate a
# signing key you already published under. `publish` runs this for you.
trek-plugin preflight [dir] --repo owner/name --tag vX.Y.Z [--entry file.json] [--all]
# signing key you already published under (`--allow-key-change` declares a deliberate
# rotation, turning that refusal into a pass). `publish` runs this for you.
trek-plugin preflight [dir] --repo owner/name --tag vX.Y.Z [--entry file.json] [--all] [--allow-key-change]
# Pack -> cut the GitHub release -> print the entry, without opening the registry PR.
trek-plugin release [dir] --repo owner/name --tag vX.Y.Z [--sign] [--merge entry.json]
@@ -1454,7 +1465,10 @@ opts out). See [[Publishing a Plugin|Plugin-Publishing]].
- **Optional author signing:** an entry may carry `authorPublicKey` (stable,
TOFU-pinned on first install) and each version a `signature` over the artifact
bytes. Unsigned plugins install on sha256 alone; a plugin that was signed can't
later go unsigned or swap its key without an explicit admin re-trust.
later go unsigned, and a key *rotation* is a deliberate act the SDK drives —
`trek-plugin rotate-key`, or `publish --sign --allow-key-change` — that still
needs a registry maintainer's `allow-key-change` label plus an explicit admin
re-trust on every instance.
Full walkthrough: [[Publishing a Plugin|Plugin-Publishing]]. Overview:
[[Plugins|Plugins]].
+5 -3
View File
@@ -163,9 +163,9 @@ flow is in [[Publishing a Plugin|Plugin-Publishing]].
| `trek-plugin validate [dir]` | Manifest + layout checks, and the gate for publishing: parses the manifest with the same rules as install, then runs every registry gate that can be answered from the working tree — exiting non-zero on any of them. It **fails** if `README.md` is missing, is missing one of the four required sections, has too little prose, still holds template placeholders, does not explain every permission the manifest declares, or has no usable screenshot (no image at all, or every local image path is missing on disk — a remote `https://` image passes here and is verified later by `preflight`); it also fails if `server/index.js` was never built. Failures that only the registry cares about do **not** block `trek-plugin pack`, so the dev loop keeps working while the docs are still a stub — but they do stop `publish`, which runs these same checks first and refuses before anything is packed, tagged or released. A handful of checks are *warnings* that never fail the command: directory name ≠ plugin id, an unbounded `trek` range, an unknown required addon, emoji in manifest text, an undeclared egress host, and a page/widget not using the design kit. This is a **subset** of registry CI — CI additionally verifies the release tag/commit, the artifact's sha256, and the README over the network. A local pass predicts a CI pass. |
| `trek-plugin preflight --repo <o/n> --tag <vX>` | Runs the **full** registry CI checks locally over the network (tag→commit, manifest parity, artifact sha256/size, native scan, README quality gate) against your pushed release — so you catch a CI failure before opening the PR. |
| `trek-plugin submit --repo <o/n> --tag <vX>` | Opens the registry PR for you: forks TREK-Plugins, fast-forwards the fork, branches off the registry's current main, writes/merges `registry/plugins/<id>.json`, pushes, and creates the PR. Requires `gh`. |
| `trek-plugin publish --repo <o/n> --tag <vX>` | **The one-command release**: local gates → pack → tag + GitHub release → preflight → open the registry PR. Nothing is packed, tagged or released until the local gates pass (`--no-checks` skips them). A failure after the release is cut rolls back the tag and release that run created, so the same tag can be re-used (`--keep-release` opts out). Add `--sign` to sign it. Requires `git` + `gh`. |
| `trek-plugin publish --repo <o/n> --tag <vX>` | **The one-command release**: local gates → pack → tag + GitHub release → preflight → open the registry PR. Nothing is packed, tagged or released until the local gates pass (`--no-checks` skips them). A failure after the release is cut rolls back the tag and release that run created, so the same tag can be re-used (`--keep-release` opts out). Add `--sign` to sign it; `--sign --allow-key-change` rotates your signing key as part of the release. Requires `git` + `gh`. |
| `trek-plugin unrelease <vX> --repo <o/n>` | Deletes a stranded GitHub release + remote tag + local tag. Refuses a version that is published in the registry (its artifact is immutable); `--yes` consents when the registry index can't be checked. |
| `trek-plugin keygen` / `sign` | `keygen` creates an Ed25519 signing key; `sign` (or `--sign` on `entry`/`release`/`submit`) signs the artifact and fills `authorPublicKey` + `signature` so TREK pins your identity (TOFU). |
| `trek-plugin keygen` / `sign` / `rotate-key` | `keygen` creates an Ed25519 signing key; `sign` (or `--sign` on `entry`/`release`/`submit`) signs the artifact and fills `authorPublicKey` + `signature` so TREK pins your identity (TOFU). `rotate-key` moves a published plugin to a **new** key without shipping a version: it re-signs every pinned artifact with the new key and opens a rotation PR (a maintainer must label it `allow-key-change`; every admin must re-trust). `publish --sign --allow-key-change` rotates as part of a release. |
| `trek-plugin pack [dir] [--out plugin.zip] [--json]` | Validates, then builds `plugin.zip` in the installer's exact layout (`trek-plugin.json`, `README.md`, `LICENSE`, `package.json` at the root; `server/` and `client/` recursed) and prints its **sha256 + byte size**. Skips `node_modules`, `.git`, `.ts` and `.map` files, and **refuses native binaries** (`.node`, `binding.gyp`, `prebuilds/`) and over-size archives, same as the installer. **`docs/` is intentionally NOT shipped** — the store fetches your screenshot from `docs/screenshot.png` in the repo. |
| `trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip] [--merge entry.json] [--out file]` | Emits the ready-to-PR registry entry: `commitSha` (resolved from the tag), `downloadUrl`, `sha256` + `size` (from the packed zip), and the manifest's **`trek` range verbatim** — the entry's only compatibility field. `--merge` prepends this version onto an existing `registry/plugins/<id>.json` for an update, keeping versions newest-first (registry CI enforces that ordering). |
| `trek-plugin release [dir] --repo <owner/name> --tag <vX.Y.Z>` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. |
@@ -181,7 +181,9 @@ flow is in [[Publishing a Plugin|Plugin-Publishing]].
(stable across versions) and a per-version `signature`. TREK verifies it offline
and pins the key trust-on-first-use. Signing is opt-in — an unsigned entry
installs on `sha256` alone — but once a plugin has shipped signed, an unsigned
update for it is refused. See [[Publishing a Plugin|Plugin-Publishing]].
update for it is refused, and a key change is only accepted as a deliberate,
maintainer-approved rotation (`rotate-key` / `--allow-key-change`) that each
admin must re-trust. See [[Publishing a Plugin|Plugin-Publishing]].
## Not a permission — settings-page actions
+59 -6
View File
@@ -218,7 +218,8 @@ the network: the tag resolves to the pinned `commitSha`; the **manifest** at tha
matches the entry; the **README** at that commit passes the quality gate; the released
artifact downloads, hashes to the pinned **sha256**, carries **no native binaries** and
verifies against your key; your plugin id is not bound to a different GitHub owner; and
an update does not drop or rotate a signing key you already published under.
an update does not drop or rotate a signing key you already published under
(`--allow-key-change` declares a deliberate rotation — see "Rotating your signing key").
Re-grading the manifest and the README *at the commit* is not redundant with the local
pass: an author who writes the README and forgets to commit it has a green tree and a
@@ -316,10 +317,12 @@ npx trek-plugin-sdk publish --repo you/repo --tag v1.2.0 --sign
`--sign` signs the exact artifact bytes and fills both `authorPublicKey` (entry)
and `signature` (version) for you. If the plugin was already published signed,
`publish` **refuses an unsigned release at step 1** — before anything is packed,
tagged or released — because a GitHub release is immutable and learning this at
step 4 would have burned the tag. **Back up `~/.trek-plugin/signing.key`** —
losing it means you can't ship signed updates.
`publish` **refuses an unsigned release at step 1** — and, since SDK 1.7.0, one
whose signing key's *identity* differs from the published `authorPublicKey` —
before anything is packed, tagged or released, because a GitHub release is
immutable and learning this at step 4 would have burned the tag. **Back up
`~/.trek-plugin/signing.key`** — losing it means a full key rotation (see below),
which strands every install until its admin re-trusts you.
**By hand with minisign**, if you prefer:
@@ -361,7 +364,57 @@ matches its pin). What
you can never do is go back: once a plugin has shipped signed, an *unsigned* update
is refused (`SIGNATURE_MISSING`) on every instance that already has it, and a key
*rotation* needs a registry maintainer override (`allow-key-change`) plus an admin
re-trust on each instance. So sign whenever you like — but back the key up.
re-trust on each instance — see the next section. So sign whenever you like — but
back the key up.
### Rotating your signing key
A rotation — lost key, compromised machine, planned hygiene — is the one signing
change with a sanctioned path, and since SDK 1.7.0 the SDK drives its whole
artifact half. Two flows, one rule: a rotated entry must have **every** version
re-signed with the new key, because TREK verifies whichever version it installs
against the entry's single `authorPublicKey`.
**Without shipping a version:**
```bash
npx trek-plugin-sdk rotate-key # from the plugin dir; --id <plugin-id> from elsewhere
# --key <file> for a key not at ~/.trek-plugin/signing.key
# --out entry.json writes the rotated entry for a hand-made PR instead of opening one
```
It fetches your published registry entry, downloads every pinned artifact,
verifies each against its pinned `sha256`, re-signs all of them with the new key
(all-or-nothing — one unfetchable or tampered artifact aborts the whole rotation),
swaps `authorPublicKey`, and opens a registry PR titled as a rotation. (If the key
was merely *lost*, `keygen` first — the default path is free again. A
*compromised* key still on disk needs `--key` for the new one; `keygen` refuses to
overwrite.)
**As part of a release:**
```bash
npx trek-plugin-sdk publish --repo you/repo --tag v1.3.0 --sign --allow-key-change
```
Step 1 accepts the new key, the update merges onto your existing entry with the
older versions re-signed under the new key, and the PR title carries
"(key rotation)". The same `--allow-key-change` flag exists on
`entry`/`preflight`/`submit`/`release` for hand-assembled flows.
Either way, the SDK can only do the artifact half. The PR body says the rest out
loud, and neither step is a formality:
1. a **registry maintainer** must apply the `allow-key-change` label — CI refuses
a changed `authorPublicKey` without it, and authors can't self-serve the label;
2. **every admin** who already installed the plugin sees `SIGNATURE_KEY_CHANGED`
(with both key fingerprints, to verify the new one with you out of band) and
must re-trust the new key before their instance receives another update.
Without `--allow-key-change`, a differing key is still refused everywhere —
deliberately, because the *accidental* version of this (publishing from a second
machine whose freshly-generated key isn't the one you published under) wants the
original key restored, not a rotation.
## Updating
+5 -1
View File
@@ -306,4 +306,8 @@ are refused (they'd collide with admin API routes) — and an `id` stays bound t
the GitHub owner who first registered it, so
nobody can repoint an existing plugin. Entries may optionally carry an author
signing key (`authorPublicKey` + a per-version `signature`) for offline signature
verification on top of the SHA-256 pin.
verification on top of the SHA-256 pin. A signed plugin's key changes only through
a maintainer-approved rotation; an instance that already has the plugin then shows
`SIGNATURE_KEY_CHANGED` — with both key fingerprints, so you can check the new one
with the author out of band — and pauses that plugin's updates until you re-trust
the new key.