···158158 }
159159 }
160160}
161161+162162+/**
163163+ * Revoke the `sh.tangled.publicKey` PDS record for one `(installationId, did)`
164164+ * pair and drop its local `ssh_key` row.
165165+ *
166166+ * Used when a re-bind displaces a DID from an installation: the displaced
167167+ * DID's key is now dead for that account, so we revoke it from their PDS and
168168+ * delete the local row. Best-effort on the PDS side (a 404 or a failed
169169+ * session restore is logged, not fatal) so the re-bind always completes; the
170170+ * local row is dropped regardless.
171171+ */
172172+export async function revokeKeyForInstallationDid(installationId: number, did: string): Promise<void> {
173173+ const db = useDb()
174174+ const rows = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey })
175175+ .from(sshKey)
176176+ .where(sql`${sshKey.installationId} = ${installationId} AND ${sshKey.did} = ${did}`)
177177+178178+ const client = await useOAuthClient()
179179+180180+ for (const row of rows) {
181181+ if (row.rkey) {
182182+ try {
183183+ // eslint-disable-next-line no-await-in-loop -- one PDS session per row
184184+ const session = await client.restore(did)
185185+ const agent = new Agent(session)
186186+ // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes
187187+ await agent.com.atproto.repo.deleteRecord({
188188+ repo: did,
189189+ collection: PUBKEY_LEXICON,
190190+ rkey: row.rkey,
191191+ })
192192+ }
193193+ catch (err) {
194194+ const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
195195+ ? err.status
196196+ : undefined
197197+ if (status !== 404) {
198198+ console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err)
199199+ }
200200+ }
201201+ }
202202+ // eslint-disable-next-line no-await-in-loop -- sequential row deletes
203203+ await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`)
204204+ }
205205+}
+84-1
test/unit/tangled-pubkey.spec.ts
···2929 useOAuthClient: async () => ({ restore: restoreMock }),
3030}))
31313232-const { generateAndPublishKey, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey')
3232+const { generateAndPublishKey, revokeKeyForInstallationDid, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey')
33333434function fakeOauthSession(did: string) {
3535 // The Agent mock above ignores its constructor argument, so we only need
···309309 expect(deleteRecordMock).not.toHaveBeenCalled()
310310 })
311311})
312312+313313+describe('revokeKeyForInstallationDid', () => {
314314+ beforeEach(async () => {
315315+ process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
316316+ clearEncryptionKeyCache()
317317+318318+ setDb(await createTestDb())
319319+ await useDb().insert(installation).values({
320320+ id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
321321+ })
322322+323323+ createRecordMock.mockReset()
324324+ deleteRecordMock.mockReset()
325325+ restoreMock.mockReset()
326326+ let counter = 0
327327+ createRecordMock.mockImplementation(async () => {
328328+ counter += 1
329329+ return { data: { uri: `at://did:plc:${counter}/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } }
330330+ })
331331+ deleteRecordMock.mockResolvedValue({})
332332+ restoreMock.mockImplementation(async (did: string) => ({ did }))
333333+ })
334334+335335+ afterEach(() => {
336336+ if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
337337+ else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
338338+ clearEncryptionKeyCache()
339339+ clearDb()
340340+ })
341341+342342+ it('revokes the PDS record and drops the local row for one (install, did)', async () => {
343343+ await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 })
344344+ const db = useDb()
345345+ expect(await db.select().from(sshKey)).toHaveLength(1)
346346+347347+ await revokeKeyForInstallationDid(1, 'did:plc:1')
348348+349349+ expect(restoreMock).toHaveBeenCalledWith('did:plc:1')
350350+ const del = deleteRecordMock.mock.calls[0]![0]
351351+ expect(del.repo).toBe('did:plc:1')
352352+ expect(del.collection).toBe('sh.tangled.publicKey')
353353+ expect(del.rkey).toBe('rkey-1')
354354+ expect(await db.select().from(sshKey)).toHaveLength(0)
355355+ })
356356+357357+ it('leaves other dids on the same installation untouched', async () => {
358358+ await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 })
359359+ await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:2'), installationId: 1 })
360360+361361+ await revokeKeyForInstallationDid(1, 'did:plc:1')
362362+363363+ const db = useDb()
364364+ const rows = await db.select().from(sshKey)
365365+ expect(rows).toHaveLength(1)
366366+ expect(rows[0]!.did).toBe('did:plc:2')
367367+ })
368368+369369+ it('no-ops when no key exists for the pair', async () => {
370370+ await revokeKeyForInstallationDid(1, 'did:plc:none')
371371+ expect(restoreMock).not.toHaveBeenCalled()
372372+ expect(deleteRecordMock).not.toHaveBeenCalled()
373373+ })
374374+375375+ it('drops the local row even when the PDS delete fails', async () => {
376376+ await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 })
377377+ deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 }))
378378+379379+ await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined()
380380+381381+ const db = useDb()
382382+ expect(await db.select().from(sshKey)).toHaveLength(0)
383383+ })
384384+385385+ it('drops the local row even when session restoration fails', async () => {
386386+ await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 })
387387+ restoreMock.mockRejectedValueOnce(new Error('session gone'))
388388+389389+ await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined()
390390+391391+ const db = useDb()
392392+ expect(await db.select().from(sshKey)).toHaveLength(0)
393393+ })
394394+})