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}