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 { generateAndPublishKey, rotateKey } from '../../server/utils/tangled-pubkey'
8import { createTestDb } from '../utils/db'
9
10const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
11
12const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>()
13const deleteRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string }) => Promise<unknown>>()
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
28function fakeOauthSession(did: string) {
29 // The Agent mock above ignores its constructor argument, so we only need
30 // a `.did` field for the helper itself.
31 // eslint-disable-next-line ts/no-unsafe-type-assertion
32 return { did } as unknown as Parameters<typeof generateAndPublishKey>[0]['oauthSession']
33}
34
35describe('generateAndPublishKey', () => {
36 beforeEach(async () => {
37 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
38 clearEncryptionKeyCache()
39
40 setDb(await createTestDb())
41 const db = useDb()
42 await db.insert(installation).values({
43 id: 1,
44 accountLogin: 'alice',
45 accountId: 100,
46 accountType: 'User',
47 })
48
49 createRecordMock.mockReset()
50 deleteRecordMock.mockReset()
51 createRecordMock.mockResolvedValue({
52 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' },
53 })
54 deleteRecordMock.mockResolvedValue({})
55 })
56
57 afterEach(() => {
58 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
59 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
60 clearEncryptionKeyCache()
61 clearDb()
62 })
63
64 it('generates a key, publishes to PDS, and stores the encrypted private half', async () => {
65 const result = await generateAndPublishKey({
66 oauthSession: fakeOauthSession('did:plc:abc'),
67 installationId: 1,
68 })
69
70 expect(result.created).toBe(true)
71 expect(createRecordMock).toHaveBeenCalledTimes(1)
72 const call = createRecordMock.mock.calls[0][0]
73 expect(call.repo).toBe('did:plc:abc')
74 expect(call.collection).toBe('sh.tangled.publicKey')
75 expect(call.record.$type).toBe('sh.tangled.publicKey')
76 expect(call.record.key).toMatch(/^ssh-ed25519 /)
77 expect(call.record.name).toBe('synchub.to/1')
78
79 const db = useDb()
80 const rows = await db.select().from(sshKey)
81 .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`)
82 expect(rows).toHaveLength(1)
83 const row = rows[0]
84 expect(row.publicKey).toMatch(/^ssh-ed25519 /)
85 expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v')
86
87 const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
88 expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/)
89 expect(decrypted).toContain('-----END PRIVATE KEY-----')
90 })
91
92 it('no-ops if a key already exists for (installation, did)', async () => {
93 await generateAndPublishKey({
94 oauthSession: fakeOauthSession('did:plc:abc'),
95 installationId: 1,
96 })
97 expect(createRecordMock).toHaveBeenCalledTimes(1)
98
99 const result = await generateAndPublishKey({
100 oauthSession: fakeOauthSession('did:plc:abc'),
101 installationId: 1,
102 })
103 expect(result.created).toBe(false)
104 expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again
105
106 const db = useDb()
107 const rows = await db.select().from(sshKey)
108 expect(rows).toHaveLength(1)
109 })
110
111 it('does not write a row if the PDS publish fails', async () => {
112 createRecordMock.mockRejectedValueOnce(new Error('pds is sad'))
113
114 await expect(generateAndPublishKey({
115 oauthSession: fakeOauthSession('did:plc:abc'),
116 installationId: 1,
117 })).rejects.toThrow(/pds is sad/)
118
119 const db = useDb()
120 const rows = await db.select().from(sshKey)
121 expect(rows).toHaveLength(0)
122 })
123})
124
125describe('rotateKey', () => {
126 beforeEach(async () => {
127 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
128 clearEncryptionKeyCache()
129
130 setDb(await createTestDb())
131 await useDb().insert(installation).values({
132 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
133 })
134
135 createRecordMock.mockReset()
136 deleteRecordMock.mockReset()
137 let counter = 0
138 createRecordMock.mockImplementation(async () => {
139 counter += 1
140 return { data: { uri: `at://did:plc:abc/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } }
141 })
142 deleteRecordMock.mockResolvedValue({})
143 })
144
145 afterEach(() => {
146 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
147 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
148 clearEncryptionKeyCache()
149 clearDb()
150 })
151
152 it('deletes the old PDS record and publishes a fresh key', async () => {
153 await generateAndPublishKey({
154 oauthSession: fakeOauthSession('did:plc:abc'),
155 installationId: 1,
156 })
157 const db = useDb()
158 const before = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`)
159 expect(before).toHaveLength(1)
160 const oldPubKey = before[0].publicKey
161 const oldRkey = before[0].tangledKeyRkey
162
163 const result = await rotateKey({
164 oauthSession: fakeOauthSession('did:plc:abc'),
165 installationId: 1,
166 })
167 expect(result.created).toBe(true)
168
169 expect(deleteRecordMock).toHaveBeenCalledTimes(1)
170 const del = deleteRecordMock.mock.calls[0][0]
171 expect(del.repo).toBe('did:plc:abc')
172 expect(del.collection).toBe('sh.tangled.publicKey')
173 expect(del.rkey).toBe(oldRkey)
174
175 const after = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`)
176 expect(after).toHaveLength(1)
177 expect(after[0].publicKey).not.toBe(oldPubKey)
178 expect(after[0].tangledKeyRkey).toBe('rkey-2')
179 })
180
181 it('proceeds when the PDS reports the record is already gone (404)', async () => {
182 await generateAndPublishKey({
183 oauthSession: fakeOauthSession('did:plc:abc'),
184 installationId: 1,
185 })
186
187 const notFound: Error & { status: number } = Object.assign(new Error('not found'), { status: 404 })
188 deleteRecordMock.mockRejectedValueOnce(notFound)
189
190 const result = await rotateKey({
191 oauthSession: fakeOauthSession('did:plc:abc'),
192 installationId: 1,
193 })
194 expect(result.created).toBe(true)
195
196 const db = useDb()
197 const rows = await db.select().from(sshKey)
198 expect(rows).toHaveLength(1)
199 expect(rows[0].tangledKeyRkey).toBe('rkey-2')
200 })
201
202 it('aborts the rotation if the PDS delete fails for a non-404 reason', async () => {
203 await generateAndPublishKey({
204 oauthSession: fakeOauthSession('did:plc:abc'),
205 installationId: 1,
206 })
207
208 deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 }))
209
210 await expect(rotateKey({
211 oauthSession: fakeOauthSession('did:plc:abc'),
212 installationId: 1,
213 })).rejects.toThrow(/boom/)
214
215 // Old row still present, no second createRecord call.
216 const db = useDb()
217 const rows = await db.select().from(sshKey)
218 expect(rows).toHaveLength(1)
219 expect(rows[0].tangledKeyRkey).toBe('rkey-1')
220 expect(createRecordMock).toHaveBeenCalledTimes(1)
221 })
222
223 it('mints a fresh key even if there is no existing row', async () => {
224 const result = await rotateKey({
225 oauthSession: fakeOauthSession('did:plc:abc'),
226 installationId: 1,
227 })
228 expect(result.created).toBe(true)
229 expect(deleteRecordMock).not.toHaveBeenCalled()
230 const db = useDb()
231 const rows = await db.select().from(sshKey)
232 expect(rows).toHaveLength(1)
233 })
234})