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 { useDb } from './db'
6import { encrypt } from './encryption'
7import { generateKeypair } from './ssh-keypair'
8
9const PUBKEY_LEXICON = 'sh.tangled.publicKey'
10
11/**
12 * Generate a per-install SSH keypair, write the public half to the user's PDS
13 * as a `sh.tangled.publicKey` record, and persist the encrypted private half
14 * + the resulting record key in the `ssh_key` table.
15 *
16 * If a row already exists for `(installation_id, did)` we no-op. Rotation is a
17 * separate, explicit dashboard action (commit 16-ish) that re-runs this with
18 * the existing record then deletes the old one.
19 */
20export async function generateAndPublishKey(opts: {
21 oauthSession: OAuthSession
22 installationId: number
23 keyName?: string
24}): Promise<{ created: boolean }> {
25 const db = useDb()
26 const did = opts.oauthSession.did
27
28 const existing = await db.select({ id: sshKey.id })
29 .from(sshKey)
30 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
31 if (existing.length > 0) {
32 return { created: false }
33 }
34
35 const keyName = opts.keyName ?? `synchub.to/${opts.installationId}`
36 const keypair = generateKeypair(keyName)
37
38 // Publish to PDS first. If this fails, we surface the error and leave no
39 // half-state in the DB \u2014 the caller can retry.
40 const agent = new Agent(opts.oauthSession)
41 const result = await agent.com.atproto.repo.createRecord({
42 repo: did,
43 collection: PUBKEY_LEXICON,
44 record: {
45 $type: PUBKEY_LEXICON,
46 key: keypair.publicKeyOpenSsh,
47 name: keyName,
48 createdAt: new Date().toISOString(),
49 },
50 })
51
52 // Extract the rkey from the returned at-uri (`at://<did>/<collection>/<rkey>`).
53 const rkey = result.data.uri.split('/').pop()
54 if (!rkey) {
55 throw new Error(`could not parse rkey from publicKey record uri: ${result.data.uri}`)
56 }
57
58 const { ciphertext, nonce } = encrypt(keypair.privateKeyPem)
59 await db.insert(sshKey).values({
60 installationId: opts.installationId,
61 did,
62 publicKey: keypair.publicKeyOpenSsh,
63 privateKeyCiphertext: ciphertext,
64 privateKeyNonce: nonce,
65 tangledKeyRkey: rkey,
66 })
67
68 return { created: true }
69}
70
71/**
72 * Rotate the SSH key for `(installationId, did)`.
73 *
74 * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the
75 * record is already gone on the PDS we proceed), drop the local row, then
76 * fall through to `generateAndPublishKey` to mint a fresh key. Pushes
77 * already in flight with the old key will fail and get retried with the new
78 * one via the queue's normal backoff.
79 */
80export async function rotateKey(opts: {
81 oauthSession: OAuthSession
82 installationId: number
83 keyName?: string
84}): Promise<{ created: boolean }> {
85 const db = useDb()
86 const did = opts.oauthSession.did
87
88 const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey })
89 .from(sshKey)
90 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
91
92 if (existing.length > 0) {
93 const row = existing[0]!
94 if (row.rkey) {
95 const agent = new Agent(opts.oauthSession)
96 try {
97 await agent.com.atproto.repo.deleteRecord({
98 repo: did,
99 collection: PUBKEY_LEXICON,
100 rkey: row.rkey,
101 })
102 }
103 catch (err) {
104 // If the record is already gone (404) we can safely continue; any
105 // other error means the PDS rejected the delete and we should bail
106 // rather than leave the user with two records.
107 const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
108 ? err.status
109 : undefined
110 if (status !== 404) throw err
111 }
112 }
113 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`)
114 }
115
116 return generateAndPublishKey(opts)
117}