mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: rebind installation to one identity, revoking the displaced key

+164 -3
+28 -2
server/api/atproto/callback.get.ts
··· 1 - import { eq } from 'drizzle-orm' 1 + import { and, eq, ne } from 'drizzle-orm' 2 2 import { userIdentity } from '~~/server/db/schema' 3 3 import { enqueue } from '~~/server/utils/queue' 4 4 import { writeSession } from '~~/server/utils/server-session' 5 - import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey' 5 + import { generateAndPublishKey, revokeKeyForInstallationDid } from '~~/server/utils/tangled-pubkey' 6 6 7 7 export default defineEventHandler(async event => { 8 8 const url = getRequestURL(event) ··· 36 36 throw createError({ statusCode: 400, statusMessage: 'invalid state (non-numeric installation id)' }) 37 37 } 38 38 installationId = parsed 39 + 40 + // One installation maps to exactly one DID. If another DID is currently 41 + // bound to this installation, this connect displaces it: revoke that DID's 42 + // now-dead SSH key (PDS record + local row) and null its installationId so 43 + // the worker stops syncing for it. The displaced user_identity row is 44 + // preserved — the user keeps their identity and can re-bind elsewhere. 45 + const displaced = await db.select({ did: userIdentity.did }) 46 + .from(userIdentity) 47 + .where(and( 48 + eq(userIdentity.installationId, installationId), 49 + ne(userIdentity.did, session.did), 50 + )) 51 + 52 + for (const row of displaced) { 53 + // eslint-disable-next-line no-await-in-loop -- sequential PDS revocations 54 + await revokeKeyForInstallationDid(installationId, row.did) 55 + } 56 + 57 + if (displaced.length > 0) { 58 + await db.update(userIdentity) 59 + .set({ installationId: null, updatedAt: new Date() }) 60 + .where(and( 61 + eq(userIdentity.installationId, installationId), 62 + ne(userIdentity.did, session.did), 63 + )) 64 + } 39 65 40 66 await db.insert(userIdentity).values({ 41 67 did: session.did,
+7
server/api/me/logout.post.ts
··· 1 + import { sessionConfig } from '~~/server/utils/server-session' 2 + 3 + export default defineEventHandler(async event => { 4 + const session = await useSession(event, sessionConfig()) 5 + await session.clear() 6 + return { ok: true } 7 + })
+45
server/utils/tangled-pubkey.ts
··· 158 158 } 159 159 } 160 160 } 161 + 162 + /** 163 + * Revoke the `sh.tangled.publicKey` PDS record for one `(installationId, did)` 164 + * pair and drop its local `ssh_key` row. 165 + * 166 + * Used when a re-bind displaces a DID from an installation: the displaced 167 + * DID's key is now dead for that account, so we revoke it from their PDS and 168 + * delete the local row. Best-effort on the PDS side (a 404 or a failed 169 + * session restore is logged, not fatal) so the re-bind always completes; the 170 + * local row is dropped regardless. 171 + */ 172 + export async function revokeKeyForInstallationDid(installationId: number, did: string): Promise<void> { 173 + const db = useDb() 174 + const rows = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey }) 175 + .from(sshKey) 176 + .where(sql`${sshKey.installationId} = ${installationId} AND ${sshKey.did} = ${did}`) 177 + 178 + const client = await useOAuthClient() 179 + 180 + for (const row of rows) { 181 + if (row.rkey) { 182 + try { 183 + // eslint-disable-next-line no-await-in-loop -- one PDS session per row 184 + const session = await client.restore(did) 185 + const agent = new Agent(session) 186 + // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes 187 + await agent.com.atproto.repo.deleteRecord({ 188 + repo: did, 189 + collection: PUBKEY_LEXICON, 190 + rkey: row.rkey, 191 + }) 192 + } 193 + catch (err) { 194 + const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 195 + ? err.status 196 + : undefined 197 + if (status !== 404) { 198 + console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err) 199 + } 200 + } 201 + } 202 + // eslint-disable-next-line no-await-in-loop -- sequential row deletes 203 + await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) 204 + } 205 + }
+84 -1
test/unit/tangled-pubkey.spec.ts
··· 29 29 useOAuthClient: async () => ({ restore: restoreMock }), 30 30 })) 31 31 32 - const { generateAndPublishKey, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey') 32 + const { generateAndPublishKey, revokeKeyForInstallationDid, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey') 33 33 34 34 function fakeOauthSession(did: string) { 35 35 // The Agent mock above ignores its constructor argument, so we only need ··· 309 309 expect(deleteRecordMock).not.toHaveBeenCalled() 310 310 }) 311 311 }) 312 + 313 + describe('revokeKeyForInstallationDid', () => { 314 + beforeEach(async () => { 315 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 316 + clearEncryptionKeyCache() 317 + 318 + setDb(await createTestDb()) 319 + await useDb().insert(installation).values({ 320 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 321 + }) 322 + 323 + createRecordMock.mockReset() 324 + deleteRecordMock.mockReset() 325 + restoreMock.mockReset() 326 + let counter = 0 327 + createRecordMock.mockImplementation(async () => { 328 + counter += 1 329 + return { data: { uri: `at://did:plc:${counter}/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } } 330 + }) 331 + deleteRecordMock.mockResolvedValue({}) 332 + restoreMock.mockImplementation(async (did: string) => ({ did })) 333 + }) 334 + 335 + afterEach(() => { 336 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 337 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 338 + clearEncryptionKeyCache() 339 + clearDb() 340 + }) 341 + 342 + it('revokes the PDS record and drops the local row for one (install, did)', async () => { 343 + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 344 + const db = useDb() 345 + expect(await db.select().from(sshKey)).toHaveLength(1) 346 + 347 + await revokeKeyForInstallationDid(1, 'did:plc:1') 348 + 349 + expect(restoreMock).toHaveBeenCalledWith('did:plc:1') 350 + const del = deleteRecordMock.mock.calls[0]![0] 351 + expect(del.repo).toBe('did:plc:1') 352 + expect(del.collection).toBe('sh.tangled.publicKey') 353 + expect(del.rkey).toBe('rkey-1') 354 + expect(await db.select().from(sshKey)).toHaveLength(0) 355 + }) 356 + 357 + it('leaves other dids on the same installation untouched', async () => { 358 + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 359 + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:2'), installationId: 1 }) 360 + 361 + await revokeKeyForInstallationDid(1, 'did:plc:1') 362 + 363 + const db = useDb() 364 + const rows = await db.select().from(sshKey) 365 + expect(rows).toHaveLength(1) 366 + expect(rows[0]!.did).toBe('did:plc:2') 367 + }) 368 + 369 + it('no-ops when no key exists for the pair', async () => { 370 + await revokeKeyForInstallationDid(1, 'did:plc:none') 371 + expect(restoreMock).not.toHaveBeenCalled() 372 + expect(deleteRecordMock).not.toHaveBeenCalled() 373 + }) 374 + 375 + it('drops the local row even when the PDS delete fails', async () => { 376 + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 377 + deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) 378 + 379 + await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined() 380 + 381 + const db = useDb() 382 + expect(await db.select().from(sshKey)).toHaveLength(0) 383 + }) 384 + 385 + it('drops the local row even when session restoration fails', async () => { 386 + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 387 + restoreMock.mockRejectedValueOnce(new Error('session gone')) 388 + 389 + await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined() 390 + 391 + const db = useDb() 392 + expect(await db.select().from(sshKey)).toHaveLength(0) 393 + }) 394 + })