mirror your GitHub repos to tangled.org automatically
1import crypto from 'node:crypto'
2import { sql } from 'drizzle-orm'
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4import { installation, sshKey } from '../../server/db/schema'
5import { clearDb, setDb, useDb } from '../../server/utils/db'
6import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption'
7import { createTestDb } from '../utils/db'
8
9const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
10
11const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>()
12const deleteRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string }) => Promise<unknown>>()
13const restoreMock = vi.fn<(did: string) => Promise<{ did: string }>>()
14
15vi.mock('@atproto/api', () => ({
16 Agent: class {
17 com = {
18 atproto: {
19 repo: {
20 createRecord: createRecordMock,
21 deleteRecord: deleteRecordMock,
22 },
23 },
24 }
25 },
26}))
27
28vi.mock('../../server/utils/atproto-oauth', () => ({
29 useOAuthClient: async () => ({ restore: restoreMock }),
30}))
31
32const { generateAndPublishKey, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey')
33
34function fakeOauthSession(did: string) {
35 // The Agent mock above ignores its constructor argument, so we only need
36 // a `.did` field for the helper itself.
37 // eslint-disable-next-line ts/no-unsafe-type-assertion
38 return { did } as unknown as Parameters<typeof generateAndPublishKey>[0]['oauthSession']
39}
40
41describe('generateAndPublishKey', () => {
42 beforeEach(async () => {
43 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
44 clearEncryptionKeyCache()
45
46 setDb(await createTestDb())
47 const db = useDb()
48 await db.insert(installation).values({
49 id: 1,
50 accountLogin: 'alice',
51 accountId: 100,
52 accountType: 'User',
53 })
54
55 createRecordMock.mockReset()
56 deleteRecordMock.mockReset()
57 createRecordMock.mockResolvedValue({
58 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' },
59 })
60 deleteRecordMock.mockResolvedValue({})
61 })
62
63 afterEach(() => {
64 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
65 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
66 clearEncryptionKeyCache()
67 clearDb()
68 })
69
70 it('generates a key, publishes to PDS, and stores the encrypted private half', async () => {
71 const result = await generateAndPublishKey({
72 oauthSession: fakeOauthSession('did:plc:abc'),
73 installationId: 1,
74 })
75
76 expect(result.created).toBe(true)
77 expect(createRecordMock).toHaveBeenCalledTimes(1)
78 const call = createRecordMock.mock.calls[0][0]
79 expect(call.repo).toBe('did:plc:abc')
80 expect(call.collection).toBe('sh.tangled.publicKey')
81 expect(call.record.$type).toBe('sh.tangled.publicKey')
82 expect(call.record.key).toMatch(/^ssh-ed25519 /)
83 expect(call.record.name).toBe('synchub.to/1')
84
85 const db = useDb()
86 const rows = await db.select().from(sshKey)
87 .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`)
88 expect(rows).toHaveLength(1)
89 const row = rows[0]
90 expect(row.publicKey).toMatch(/^ssh-ed25519 /)
91 expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v')
92
93 const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
94 expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/)
95 expect(decrypted).toContain('-----END PRIVATE KEY-----')
96 })
97
98 it('no-ops if a key already exists for (installation, did)', async () => {
99 await generateAndPublishKey({
100 oauthSession: fakeOauthSession('did:plc:abc'),
101 installationId: 1,
102 })
103 expect(createRecordMock).toHaveBeenCalledTimes(1)
104
105 const result = await generateAndPublishKey({
106 oauthSession: fakeOauthSession('did:plc:abc'),
107 installationId: 1,
108 })
109 expect(result.created).toBe(false)
110 expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again
111
112 const db = useDb()
113 const rows = await db.select().from(sshKey)
114 expect(rows).toHaveLength(1)
115 })
116
117 it('does not write a row if the PDS publish fails', async () => {
118 createRecordMock.mockRejectedValueOnce(new Error('pds is sad'))
119
120 await expect(generateAndPublishKey({
121 oauthSession: fakeOauthSession('did:plc:abc'),
122 installationId: 1,
123 })).rejects.toThrow(/pds is sad/)
124
125 const db = useDb()
126 const rows = await db.select().from(sshKey)
127 expect(rows).toHaveLength(0)
128 })
129})
130
131describe('rotateKey', () => {
132 beforeEach(async () => {
133 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
134 clearEncryptionKeyCache()
135
136 setDb(await createTestDb())
137 await useDb().insert(installation).values({
138 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
139 })
140
141 createRecordMock.mockReset()
142 deleteRecordMock.mockReset()
143 let counter = 0
144 createRecordMock.mockImplementation(async () => {
145 counter += 1
146 return { data: { uri: `at://did:plc:abc/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } }
147 })
148 deleteRecordMock.mockResolvedValue({})
149 })
150
151 afterEach(() => {
152 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
153 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
154 clearEncryptionKeyCache()
155 clearDb()
156 })
157
158 it('deletes the old PDS record and publishes a fresh key', async () => {
159 await generateAndPublishKey({
160 oauthSession: fakeOauthSession('did:plc:abc'),
161 installationId: 1,
162 })
163 const db = useDb()
164 const before = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`)
165 expect(before).toHaveLength(1)
166 const oldPubKey = before[0].publicKey
167 const oldRkey = before[0].tangledKeyRkey
168
169 const result = await rotateKey({
170 oauthSession: fakeOauthSession('did:plc:abc'),
171 installationId: 1,
172 })
173 expect(result.created).toBe(true)
174
175 expect(deleteRecordMock).toHaveBeenCalledTimes(1)
176 const del = deleteRecordMock.mock.calls[0][0]
177 expect(del.repo).toBe('did:plc:abc')
178 expect(del.collection).toBe('sh.tangled.publicKey')
179 expect(del.rkey).toBe(oldRkey)
180
181 const after = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`)
182 expect(after).toHaveLength(1)
183 expect(after[0].publicKey).not.toBe(oldPubKey)
184 expect(after[0].tangledKeyRkey).toBe('rkey-2')
185 })
186
187 it('proceeds when the PDS reports the record is already gone (404)', async () => {
188 await generateAndPublishKey({
189 oauthSession: fakeOauthSession('did:plc:abc'),
190 installationId: 1,
191 })
192
193 const notFound: Error & { status: number } = Object.assign(new Error('not found'), { status: 404 })
194 deleteRecordMock.mockRejectedValueOnce(notFound)
195
196 const result = await rotateKey({
197 oauthSession: fakeOauthSession('did:plc:abc'),
198 installationId: 1,
199 })
200 expect(result.created).toBe(true)
201
202 const db = useDb()
203 const rows = await db.select().from(sshKey)
204 expect(rows).toHaveLength(1)
205 expect(rows[0].tangledKeyRkey).toBe('rkey-2')
206 })
207
208 it('aborts the rotation if the PDS delete fails for a non-404 reason', async () => {
209 await generateAndPublishKey({
210 oauthSession: fakeOauthSession('did:plc:abc'),
211 installationId: 1,
212 })
213
214 deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 }))
215
216 await expect(rotateKey({
217 oauthSession: fakeOauthSession('did:plc:abc'),
218 installationId: 1,
219 })).rejects.toThrow(/boom/)
220
221 // Old row still present, no second createRecord call.
222 const db = useDb()
223 const rows = await db.select().from(sshKey)
224 expect(rows).toHaveLength(1)
225 expect(rows[0].tangledKeyRkey).toBe('rkey-1')
226 expect(createRecordMock).toHaveBeenCalledTimes(1)
227 })
228
229 it('mints a fresh key even if there is no existing row', async () => {
230 const result = await rotateKey({
231 oauthSession: fakeOauthSession('did:plc:abc'),
232 installationId: 1,
233 })
234 expect(result.created).toBe(true)
235 expect(deleteRecordMock).not.toHaveBeenCalled()
236 const db = useDb()
237 const rows = await db.select().from(sshKey)
238 expect(rows).toHaveLength(1)
239 })
240})
241
242describe('revokeKeysForInstallation', () => {
243 beforeEach(async () => {
244 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
245 clearEncryptionKeyCache()
246
247 setDb(await createTestDb())
248 await useDb().insert(installation).values({
249 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
250 })
251
252 createRecordMock.mockReset()
253 deleteRecordMock.mockReset()
254 restoreMock.mockReset()
255 createRecordMock.mockResolvedValue({
256 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' },
257 })
258 deleteRecordMock.mockResolvedValue({})
259 restoreMock.mockImplementation(async (did: string) => ({ did }))
260 })
261
262 afterEach(() => {
263 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
264 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
265 clearEncryptionKeyCache()
266 clearDb()
267 })
268
269 it('deletes the publicKey PDS record for the installation', async () => {
270 await generateAndPublishKey({
271 oauthSession: fakeOauthSession('did:plc:abc'),
272 installationId: 1,
273 })
274
275 await revokeKeysForInstallation(1)
276
277 expect(restoreMock).toHaveBeenCalledWith('did:plc:abc')
278 expect(deleteRecordMock).toHaveBeenCalledTimes(1)
279 const del = deleteRecordMock.mock.calls[0][0]
280 expect(del.repo).toBe('did:plc:abc')
281 expect(del.collection).toBe('sh.tangled.publicKey')
282 expect(del.rkey).toBe('3kh2y4xq2lk2v')
283 })
284
285 it('no-ops when the installation has no keys', async () => {
286 await revokeKeysForInstallation(1)
287 expect(restoreMock).not.toHaveBeenCalled()
288 expect(deleteRecordMock).not.toHaveBeenCalled()
289 })
290
291 it('swallows a 404 from the PDS delete', async () => {
292 await generateAndPublishKey({
293 oauthSession: fakeOauthSession('did:plc:abc'),
294 installationId: 1,
295 })
296 deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('not found'), { status: 404 }))
297
298 await expect(revokeKeysForInstallation(1)).resolves.toBeUndefined()
299 })
300
301 it('continues when OAuth session restoration fails', async () => {
302 await generateAndPublishKey({
303 oauthSession: fakeOauthSession('did:plc:abc'),
304 installationId: 1,
305 })
306 restoreMock.mockRejectedValueOnce(new Error('session gone'))
307
308 await expect(revokeKeysForInstallation(1)).resolves.toBeUndefined()
309 expect(deleteRecordMock).not.toHaveBeenCalled()
310 })
311})