mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(sdk): allow new signing key (#2088)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user