mirror your GitHub repos to tangled.org automatically
1import { Agent } from '@atproto/api'
2import type { OAuthSession } from '@atproto/oauth-client-node'
3import { sql } from 'drizzle-orm'
4import { sshKey } from '../db/schema'
5import { useOAuthClient } from './atproto-oauth'
6import { useDb } from './db'
7import { encrypt } from './encryption'
8import { generateKeypair } from './ssh-keypair'
9
10const PUBKEY_LEXICON = 'sh.tangled.publicKey'
11
12/**
13 * Generate a per-install SSH keypair, write the public half to the user's PDS
14 * as a `sh.tangled.publicKey` record, and persist the encrypted private half
15 * + the resulting record key in the `ssh_key` table.
16 *
17 * If a row already exists for `(installation_id, did)` we no-op. Rotation is a
18 * separate, explicit dashboard action (commit 16-ish) that re-runs this with
19 * the existing record then deletes the old one.
20 */
21export async function generateAndPublishKey(opts: {
22 oauthSession: OAuthSession
23 installationId: number
24 keyName?: string
25}): Promise<{ created: boolean }> {
26 const db = useDb()
27 const did = opts.oauthSession.did
28
29 const existing = await db.select({ id: sshKey.id })
30 .from(sshKey)
31 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
32 if (existing.length > 0) {
33 return { created: false }
34 }
35
36 const keyName = opts.keyName ?? `synchub.to/${opts.installationId}`
37 const keypair = generateKeypair(keyName)
38
39 // Publish to PDS first. If this fails, we surface the error and leave no
40 // half-state in the DB \u2014 the caller can retry.
41 const agent = new Agent(opts.oauthSession)
42 const result = await agent.com.atproto.repo.createRecord({
43 repo: did,
44 collection: PUBKEY_LEXICON,
45 record: {
46 $type: PUBKEY_LEXICON,
47 key: keypair.publicKeyOpenSsh,
48 name: keyName,
49 createdAt: new Date().toISOString(),
50 },
51 })
52
53 // Extract the rkey from the returned at-uri (`at://<did>/<collection>/<rkey>`).
54 const rkey = result.data.uri.split('/').pop()
55 if (!rkey) {
56 throw new Error(`could not parse rkey from publicKey record uri: ${result.data.uri}`)
57 }
58
59 const { ciphertext, nonce } = encrypt(keypair.privateKeyPem)
60 await db.insert(sshKey).values({
61 installationId: opts.installationId,
62 did,
63 publicKey: keypair.publicKeyOpenSsh,
64 privateKeyCiphertext: ciphertext,
65 privateKeyNonce: nonce,
66 tangledKeyRkey: rkey,
67 })
68
69 return { created: true }
70}
71
72/**
73 * Rotate the SSH key for `(installationId, did)`.
74 *
75 * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the
76 * record is already gone on the PDS we proceed), drop the local row, then
77 * fall through to `generateAndPublishKey` to mint a fresh key. Pushes
78 * already in flight with the old key will fail and get retried with the new
79 * one via the queue's normal backoff.
80 */
81export async function rotateKey(opts: {
82 oauthSession: OAuthSession
83 installationId: number
84 keyName?: string
85}): Promise<{ created: boolean }> {
86 const db = useDb()
87 const did = opts.oauthSession.did
88
89 const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey })
90 .from(sshKey)
91 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
92
93 if (existing.length > 0) {
94 const row = existing[0]!
95 if (row.rkey) {
96 const agent = new Agent(opts.oauthSession)
97 try {
98 await agent.com.atproto.repo.deleteRecord({
99 repo: did,
100 collection: PUBKEY_LEXICON,
101 rkey: row.rkey,
102 })
103 }
104 catch (err) {
105 // If the record is already gone (404) we can safely continue; any
106 // other error means the PDS rejected the delete and we should bail
107 // rather than leave the user with two records.
108 const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
109 ? err.status
110 : undefined
111 if (status !== 404) throw err
112 }
113 }
114 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`)
115 }
116
117 return generateAndPublishKey(opts)
118}
119
120/**
121 * Delete every `sh.tangled.publicKey` record we published for an installation
122 * from the owning user's PDS.
123 *
124 * Called on `installation.deleted` *before* the local cascade delete, so the
125 * user isn't left with a dead key to clean up by hand. Restoring the OAuth
126 * session can fail if the user revoked the app on their PDS before uninstalling
127 * the GitHub App; in that case there's nothing for us to delete, so we swallow
128 * the error and let the caller proceed with the local cascade. A 404 on the
129 * delete itself is likewise treated as already-gone.
130 */
131export async function revokeKeysForInstallation(installationId: number): Promise<void> {
132 const db = useDb()
133 const rows = await db.select({ did: sshKey.did, rkey: sshKey.tangledKeyRkey })
134 .from(sshKey)
135 .where(sql`${sshKey.installationId} = ${installationId}`)
136
137 const client = await useOAuthClient()
138
139 for (const row of rows) {
140 if (!row.rkey) continue
141 try {
142 // eslint-disable-next-line no-await-in-loop -- one PDS session per row
143 const session = await client.restore(row.did)
144 const agent = new Agent(session)
145 // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes
146 await agent.com.atproto.repo.deleteRecord({
147 repo: row.did,
148 collection: PUBKEY_LEXICON,
149 rkey: row.rkey,
150 })
151 }
152 catch (err) {
153 const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
154 ? err.status
155 : undefined
156 if (status === 404) continue
157 console.error(`failed to revoke publicKey record for did ${row.did} (installation ${installationId})`, err)
158 }
159 }
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 */
172export 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}