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