// getRecommendedDidCredentials must preserve the DID's existing rotation keys. // Those keys are what can recover or repoint the identity; dropping them on a // migration or handle change would let a lost server key lock the owner out of // their DID permanently. import { afterEach, describe, expect, it, vi } from 'vitest'; import { generateKeyPair } from '../packages/core/src/crypto.js'; import { createIdentityHandlers } from '../packages/core/src/handlers/xrpc-identity.js'; import { publicKeyToDidKey } from '../packages/core/src/plc.js'; const DID = 'did:plc:migrateexampleaccount000'; /** A storage stub already initialized with a given signing key. */ /** @param {Uint8Array} privateKey */ function storageWith(privateKey) { return { async getDid() { return DID; }, async getPrivateKey() { return privateKey; }, async getKemKey() { return null; }, async getHandle() { return 'alice.example.com'; }, }; } /** @param {Uint8Array} privateKey */ function identity(privateKey) { return createIdentityHandlers( /** @type {any} */ ({ actorStorage: storageWith(privateKey), hostname: 'pds.example.com', plcUrl: 'https://plc.test', k256Signer: null, getDid: async () => DID, }), ); } /** Stub the PLC directory's /data endpoint to report `rotationKeys`. */ /** * @param {string[]} rotationKeys * @param {{ok?: boolean}} [opts] */ function stubPlcData(rotationKeys, { ok = true } = {}) { vi.stubGlobal( 'fetch', vi.fn(async () => ok ? new Response(JSON.stringify({ rotationKeys })) : new Response('', { status: 404 }), ), ); } describe('getRecommendedDidCredentials rotation keys', () => { afterEach(() => vi.unstubAllGlobals()); it('keeps existing recovery keys and adds this server key first', async () => { const { privateKey, publicKey } = await generateKeyPair(); const ourKey = publicKeyToDidKey(publicKey); const recovery = ['did:key:zRecoveryA', 'did:key:zRecoveryB']; stubPlcData(recovery); const creds = /** @type {any} */ ( await identity(privateKey).getRecommendedDidCredentials() ); // This server's key leads (so it is never dropped and can sign its own // ops); the owner's recovery keys are preserved after it. expect(creds.rotationKeys).toEqual([ourKey, ...recovery]); }); it("includes this server's key even when the DID is already at the 5-key limit", async () => { const { privateKey, publicKey } = await generateKeyPair(); const ourKey = publicKeyToDidKey(publicKey); const five = [ 'did:key:z1', 'did:key:z2', 'did:key:z3', 'did:key:z4', 'did:key:z5', ]; stubPlcData(five); const creds = /** @type {any} */ ( await identity(privateKey).getRecommendedDidCredentials() ); // This server's key leads; the lowest-priority existing key (z5) is what // the 5-key cap drops. expect(creds.rotationKeys).toEqual([ ourKey, 'did:key:z1', 'did:key:z2', 'did:key:z3', 'did:key:z4', ]); }); it('falls back to this server key alone when the directory is unreachable', async () => { const { privateKey, publicKey } = await generateKeyPair(); const ourKey = publicKeyToDidKey(publicKey); stubPlcData([], { ok: false }); const creds = /** @type {any} */ ( await identity(privateKey).getRecommendedDidCredentials() ); expect(creds.rotationKeys).toEqual([ourKey]); }); });