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
+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/);
});
});