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/** HTTP status off an unknown thrown value, or undefined if it has none. */
13function errorStatus(err: unknown): number | undefined {
14 return err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
15 ? err.status
16 : undefined
17}
18
19/**
20 * Delete one `sh.tangled.publicKey` record from `did`'s PDS, treating an
21 * already-gone record (404) as success. Any other PDS error re-throws.
22 */
23async function deletePubkeyRecord(session: OAuthSession, did: string, rkey: string): Promise<void> {
24 const agent = new Agent(session)
25 try {
26 await agent.com.atproto.repo.deleteRecord({ repo: did, collection: PUBKEY_LEXICON, rkey })
27 }
28 catch (err) {
29 if (errorStatus(err) !== 404) throw err
30 }
31}
32
33/**
34 * Restore `did`'s OAuth session and delete one `sh.tangled.publicKey` record,
35 * never throwing: a 404 is already-gone, and any other failure (including a
36 * session that can no longer be restored) is logged so the caller's cleanup
37 * proceeds.
38 */
39async function bestEffortRevoke(did: string, rkey: string, installationId: number): Promise<void> {
40 const client = await useOAuthClient()
41 try {
42 const session = await client.restore(did)
43 await deletePubkeyRecord(session, did, rkey)
44 }
45 catch (err) {
46 console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err)
47 }
48}
49
50/**
51 * Generate a per-install SSH keypair, write the public half to the user's PDS
52 * as a `sh.tangled.publicKey` record, and persist the encrypted private half
53 * + the resulting record key in the `ssh_key` table.
54 *
55 * If a row already exists for `(installation_id, did)` we no-op. Rotation is a
56 * separate, explicit dashboard action (`rotateKey`) that deletes the existing
57 * record then re-runs this.
58 */
59export async function generateAndPublishKey(opts: {
60 oauthSession: OAuthSession
61 installationId: number
62 keyName?: string
63}): Promise<{ created: boolean }> {
64 const db = useDb()
65 const did = opts.oauthSession.did
66
67 const existing = await db.select({ id: sshKey.id })
68 .from(sshKey)
69 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
70 if (existing.length > 0) {
71 return { created: false }
72 }
73
74 const keyName = opts.keyName ?? `synchub.to/${opts.installationId}`
75 const keypair = generateKeypair(keyName)
76
77 // Publish to PDS first. If this fails, we surface the error and leave no
78 // half-state in the DB; the caller can retry.
79 const agent = new Agent(opts.oauthSession)
80 const result = await agent.com.atproto.repo.createRecord({
81 repo: did,
82 collection: PUBKEY_LEXICON,
83 record: {
84 $type: PUBKEY_LEXICON,
85 key: keypair.publicKeyOpenSsh,
86 name: keyName,
87 createdAt: new Date().toISOString(),
88 },
89 })
90
91 // Extract the rkey from the returned at-uri (`at://<did>/<collection>/<rkey>`).
92 const rkey = result.data.uri.split('/').pop()
93 if (!rkey) {
94 throw new Error(`could not parse rkey from publicKey record uri: ${result.data.uri}`)
95 }
96
97 const { ciphertext, nonce } = encrypt(keypair.privateKeyPem)
98 await db.insert(sshKey).values({
99 installationId: opts.installationId,
100 did,
101 publicKey: keypair.publicKeyOpenSsh,
102 privateKeyCiphertext: ciphertext,
103 privateKeyNonce: nonce,
104 tangledKeyRkey: rkey,
105 })
106
107 return { created: true }
108}
109
110/**
111 * Rotate the SSH key for `(installationId, did)`.
112 *
113 * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the
114 * record is already gone on the PDS we proceed), drop the local row, then
115 * fall through to `generateAndPublishKey` to mint a fresh key. Pushes
116 * already in flight with the old key will fail and get retried with the new
117 * one via the queue's normal backoff.
118 */
119export async function rotateKey(opts: {
120 oauthSession: OAuthSession
121 installationId: number
122 keyName?: string
123}): Promise<{ created: boolean }> {
124 const db = useDb()
125 const did = opts.oauthSession.did
126
127 const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey })
128 .from(sshKey)
129 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
130
131 if (existing.length > 0) {
132 const row = existing[0]!
133 if (row.rkey) {
134 await deletePubkeyRecord(opts.oauthSession, did, row.rkey)
135 }
136 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`)
137 }
138
139 return generateAndPublishKey(opts)
140}
141
142/**
143 * Delete every `sh.tangled.publicKey` record we published for an installation
144 * from the owning user's PDS.
145 *
146 * Called on `installation.deleted` *before* the local cascade delete, so the
147 * user isn't left with a dead key to clean up by hand. Restoring the OAuth
148 * session can fail if the user revoked the app on their PDS before uninstalling
149 * the GitHub App; in that case there's nothing for us to delete, so we swallow
150 * the error and let the caller proceed with the local cascade. A 404 on the
151 * delete itself is likewise treated as already-gone.
152 */
153export async function revokeKeysForInstallation(installationId: number): Promise<void> {
154 const db = useDb()
155 const rows = await db.select({ did: sshKey.did, rkey: sshKey.tangledKeyRkey })
156 .from(sshKey)
157 .where(sql`${sshKey.installationId} = ${installationId}`)
158
159 for (const row of rows) {
160 if (!row.rkey) continue
161 // eslint-disable-next-line no-await-in-loop -- one PDS session per row
162 await bestEffortRevoke(row.did, row.rkey, installationId)
163 }
164}
165
166/**
167 * Revoke the `sh.tangled.publicKey` PDS record for one `(installationId, did)`
168 * pair and drop its local `ssh_key` row.
169 *
170 * Used when a re-bind displaces a DID from an installation: the displaced
171 * DID's key is now dead for that account, so we revoke it from their PDS and
172 * delete the local row. Best-effort on the PDS side (a 404 or a failed
173 * session restore is logged, not fatal) so the re-bind always completes; the
174 * local row is dropped regardless.
175 */
176export async function revokeKeyForInstallationDid(installationId: number, did: string): Promise<void> {
177 const db = useDb()
178 const rows = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey })
179 .from(sshKey)
180 .where(sql`${sshKey.installationId} = ${installationId} AND ${sshKey.did} = ${did}`)
181
182 for (const row of rows) {
183 if (row.rkey) {
184 // eslint-disable-next-line no-await-in-loop -- one PDS session per row
185 await bestEffortRevoke(did, row.rkey, installationId)
186 }
187 // eslint-disable-next-line no-await-in-loop -- sequential row deletes
188 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`)
189 }
190}