Files
2026-08-28 00:56:44 +02:00

227 lines
10 KiB
TypeScript

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