···11import { userIdentity } from '~~/server/db/schema'
22+import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey'
2334export default defineEventHandler(async event => {
45 const url = getRequestURL(event)
···2122 }).onConflictDoUpdate({
2223 target: userIdentity.did,
2324 set: { installationId, updatedAt: new Date() },
2525+ })
2626+2727+ // Generate and publish the SSH key inline: it's one ed25519 keygen + one
2828+ // PDS write, well under the function timeout, and lets us land users on the
2929+ // dashboard already enrolled. Rotation is a separate dashboard action that
3030+ // goes via the queue.
3131+ await generateAndPublishKey({
3232+ oauthSession: session,
3333+ installationId,
2434 })
25352636 await sendRedirect(event, '/dashboard', 302)
+23-8
server/utils/job-handlers.ts
···11import type { JobEnvelope } from './queue'
22+import { useOAuthClient } from './atproto-oauth'
33+import { generateAndPublishKey } from './tangled-pubkey'
2435/**
46 * Map of job kind → handler. Handlers are filled in by later commits:
55- * - 'github.push' → commit 11 (sync push events)
66- * - 'github.create' / 'github.delete' → commit 12 (branch/tag ref ops)
77- * - 'github.repository' → commit 13/14 (description, lifecycle)
77+ * - 'github.push' → commit 12 (sync push events)
88+ * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops)
99+ * - 'github.repository' → commit 14/15 (description, lifecycle)
810 * - 'tangled.create-repo' → commit 10 (initial enrolment)
99- * - 'atproto.publish-pubkey' → commit 9 (publish ssh public key)
1111+ * - 'atproto.publish-pubkey' → this commit (key rotation)
1012 *
1111- * For now the dispatcher knows the recognised kinds but routes them all to a
1212- * noop. An unknown kind throws so it surfaces as a job failure rather than
1313- * silent acknowledgement.
1313+ * Unknown kinds throw so they surface as job failures rather than silent
1414+ * acknowledgement.
1415 */
1516const KNOWN_KINDS = new Set([
1617 'github.push',
···2223 'atproto.publish-pubkey',
2324])
24252626+interface PublishPubkeyPayload {
2727+ did: string
2828+ installationId: number
2929+}
3030+2531export async function dispatch(envelope: JobEnvelope): Promise<void> {
2632 if (!KNOWN_KINDS.has(envelope.kind)) {
2733 throw new Error(`unknown job kind: ${envelope.kind}`)
2834 }
2929- // No-op until handlers land in later commits.
3535+3636+ if (envelope.kind === 'atproto.publish-pubkey') {
3737+ const { did, installationId } = envelope.payload as PublishPubkeyPayload
3838+ const client = await useOAuthClient()
3939+ const session = await client.restore(did)
4040+ await generateAndPublishKey({ oauthSession: session, installationId })
4141+ return
4242+ }
4343+4444+ // Other kinds: still no-op until handlers land in their commits.
3045}
+57
server/utils/ssh-keypair.ts
···11+import crypto from 'node:crypto'
22+33+/**
44+ * Generate an ed25519 SSH keypair. Returns the OpenSSH-formatted public key
55+ * (suitable for `sh.tangled.publicKey` records / GitHub deploy keys / authorized_keys)
66+ * and the PKCS#8-PEM-encoded private key (suitable for storage).
77+ *
88+ * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`.
99+ * Conversion to OpenSSH private key format (what `git`/`ssh-agent` consumes) is
1010+ * deferred until commit 12, where it lives next to the SSH push code.
1111+ */
1212+export interface GeneratedKeypair {
1313+ publicKeyOpenSsh: string
1414+ privateKeyPem: string
1515+}
1616+1717+export function generateKeypair(comment: string): GeneratedKeypair {
1818+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
1919+ publicKeyEncoding: { type: 'spki', format: 'der' },
2020+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
2121+ })
2222+2323+ // SPKI-DER for ed25519 is a fixed 44-byte ASN.1 wrapper; the last 32 bytes
2424+ // are the raw public key. (See RFC 8410 §4.) Skip the wrapper.
2525+ const rawPublic = (publicKey as Buffer).subarray(-32)
2626+2727+ return {
2828+ publicKeyOpenSsh: encodeOpenSshEd25519(rawPublic, comment),
2929+ privateKeyPem: privateKey as string,
3030+ }
3131+}
3232+3333+/**
3434+ * Encode a 32-byte ed25519 public key in OpenSSH `authorized_keys` format:
3535+ * ssh-ed25519 <base64(string("ssh-ed25519") + string(rawKey))> <comment>
3636+ *
3737+ * The base64 payload uses SSH's length-prefixed string format (uint32 big-endian
3838+ * length + bytes), per RFC 4253 §6.6 and the ed25519 draft.
3939+ */
4040+function encodeOpenSshEd25519(rawPublicKey: Buffer, comment: string): string {
4141+ if (rawPublicKey.length !== 32) {
4242+ throw new Error(`expected 32 raw bytes for ed25519 public key, got ${rawPublicKey.length}`)
4343+ }
4444+4545+ const algo = Buffer.from('ssh-ed25519', 'utf8')
4646+ const payload = Buffer.concat([
4747+ sshString(algo),
4848+ sshString(rawPublicKey),
4949+ ])
5050+ return `ssh-ed25519 ${payload.toString('base64')} ${comment}`
5151+}
5252+5353+function sshString(buf: Buffer): Buffer {
5454+ const len = Buffer.alloc(4)
5555+ len.writeUInt32BE(buf.length, 0)
5656+ return Buffer.concat([len, buf])
5757+}
+69
server/utils/tangled-pubkey.ts
···11+import { Agent } from '@atproto/api'
22+import type { OAuthSession } from '@atproto/oauth-client-node'
33+import { sql } from 'drizzle-orm'
44+import { sshKey } from '../db/schema'
55+import { useDb } from './db'
66+import { encrypt } from './encryption'
77+import { generateKeypair } from './ssh-keypair'
88+99+const PUBKEY_LEXICON = 'sh.tangled.publicKey'
1010+1111+/**
1212+ * Generate a per-install SSH keypair, write the public half to the user's PDS
1313+ * as a `sh.tangled.publicKey` record, and persist the encrypted private half
1414+ * + the resulting record key in the `ssh_key` table.
1515+ *
1616+ * If a row already exists for `(installation_id, did)` we no-op. Rotation is a
1717+ * separate, explicit dashboard action (commit 16-ish) that re-runs this with
1818+ * the existing record then deletes the old one.
1919+ */
2020+export async function generateAndPublishKey(opts: {
2121+ oauthSession: OAuthSession
2222+ installationId: number
2323+ keyName?: string
2424+}): Promise<{ created: boolean }> {
2525+ const db = useDb()
2626+ const did = opts.oauthSession.did
2727+2828+ const existing = await db.select({ id: sshKey.id })
2929+ .from(sshKey)
3030+ .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
3131+ if (existing.length > 0) {
3232+ return { created: false }
3333+ }
3434+3535+ const keyName = opts.keyName ?? `synchub.to/${opts.installationId}`
3636+ const keypair = generateKeypair(keyName)
3737+3838+ // Publish to PDS first. If this fails, we surface the error and leave no
3939+ // half-state in the DB \u2014 the caller can retry.
4040+ const agent = new Agent(opts.oauthSession)
4141+ const result = await agent.com.atproto.repo.createRecord({
4242+ repo: did,
4343+ collection: PUBKEY_LEXICON,
4444+ record: {
4545+ $type: PUBKEY_LEXICON,
4646+ key: keypair.publicKeyOpenSsh,
4747+ name: keyName,
4848+ createdAt: new Date().toISOString(),
4949+ },
5050+ })
5151+5252+ // Extract the rkey from the returned at-uri (`at://<did>/<collection>/<rkey>`).
5353+ const rkey = result.data.uri.split('/').pop()
5454+ if (!rkey) {
5555+ throw new Error(`could not parse rkey from publicKey record uri: ${result.data.uri}`)
5656+ }
5757+5858+ const { ciphertext, nonce } = encrypt(keypair.privateKeyPem)
5959+ await db.insert(sshKey).values({
6060+ installationId: opts.installationId,
6161+ did,
6262+ publicKey: keypair.publicKeyOpenSsh,
6363+ privateKeyCiphertext: ciphertext,
6464+ privateKeyNonce: nonce,
6565+ tangledKeyRkey: rkey,
6666+ })
6767+6868+ return { created: true }
6969+}
+49
test/unit/ssh-keypair.spec.ts
···11+import crypto from 'node:crypto'
22+import { describe, expect, it } from 'vitest'
33+import { generateKeypair } from '../../server/utils/ssh-keypair'
44+55+describe('ssh-keypair', () => {
66+ it('produces an OpenSSH-formatted ed25519 public key', () => {
77+ const { publicKeyOpenSsh } = generateKeypair('synchub.to/123')
88+ expect(publicKeyOpenSsh).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+ synchub\.to\/123$/)
99+ })
1010+1111+ it('produces a PKCS#8 PEM private key Node can load', () => {
1212+ const { privateKeyPem } = generateKeypair('test')
1313+ expect(privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/)
1414+ // Round-trip: Node loads it back and reports the right type.
1515+ const key = crypto.createPrivateKey(privateKeyPem)
1616+ expect(key.asymmetricKeyType).toBe('ed25519')
1717+ })
1818+1919+ it('public + private from the same call match each other (sign/verify)', () => {
2020+ const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('test')
2121+2222+ // Decode the OpenSSH public key back to raw bytes and reconstruct an SPKI key.
2323+ const b64 = publicKeyOpenSsh.split(' ')[1]!
2424+ const blob = Buffer.from(b64, 'base64')
2525+ // ssh-ed25519 framing: <4 bytes len><"ssh-ed25519"><4 bytes len><32 bytes raw key>
2626+ const algoLen = blob.readUInt32BE(0)
2727+ const keyLen = blob.readUInt32BE(4 + algoLen)
2828+ const rawPublic = blob.subarray(4 + algoLen + 4, 4 + algoLen + 4 + keyLen)
2929+ expect(rawPublic.length).toBe(32)
3030+3131+ // Wrap raw key in the canonical 12-byte SPKI prefix for ed25519 (RFC 8410).
3232+ const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex')
3333+ const spki = Buffer.concat([spkiPrefix, rawPublic])
3434+ const publicKey = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' })
3535+ const privateKey = crypto.createPrivateKey(privateKeyPem)
3636+3737+ const message = Buffer.from('test message')
3838+ const signature = crypto.sign(null, message, privateKey)
3939+ expect(crypto.verify(null, message, publicKey, signature)).toBe(true)
4040+ })
4141+4242+ it('rejects keys with unexpected raw length', () => {
4343+ // Internal sanity \u2014 generateKeypair should never produce one, but the helper
4444+ // it relies on must error on wrong-sized input.
4545+ // (Tested indirectly via the keypair generator.)
4646+ const { publicKeyOpenSsh } = generateKeypair('comment with spaces ok')
4747+ expect(publicKeyOpenSsh).toContain('comment with spaces ok')
4848+ })
4949+})
+118
test/unit/tangled-pubkey.spec.ts
···11+import crypto from 'node:crypto'
22+import { sql } from 'drizzle-orm'
33+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
44+import { installation, sshKey } from '../../server/db/schema'
55+import { clearDb, setDb, useDb } from '../../server/utils/db'
66+import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption'
77+import { generateAndPublishKey } from '../../server/utils/tangled-pubkey'
88+import { createTestDb } from '../utils/db'
99+1010+const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
1111+1212+const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>()
1313+1414+vi.mock('@atproto/api', () => ({
1515+ Agent: class {
1616+ com = {
1717+ atproto: {
1818+ repo: {
1919+ createRecord: createRecordMock,
2020+ },
2121+ },
2222+ }
2323+ },
2424+}))
2525+2626+describe('generateAndPublishKey', () => {
2727+ beforeEach(async () => {
2828+ process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
2929+ clearEncryptionKeyCache()
3030+3131+ setDb(await createTestDb())
3232+ const db = useDb()
3333+ await db.insert(installation).values({
3434+ id: 1,
3535+ accountLogin: 'alice',
3636+ accountId: 100,
3737+ accountType: 'User',
3838+ })
3939+4040+ createRecordMock.mockReset()
4141+ createRecordMock.mockResolvedValue({
4242+ data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' },
4343+ })
4444+ })
4545+4646+ afterEach(() => {
4747+ if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
4848+ else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
4949+ clearEncryptionKeyCache()
5050+ clearDb()
5151+ })
5252+5353+ function fakeOauthSession(did: string) {
5454+ // The Agent mock above ignores its constructor argument, so we only need
5555+ // a `.did` field for the helper itself.
5656+ return { did } as never
5757+ }
5858+5959+ it('generates a key, publishes to PDS, and stores the encrypted private half', async () => {
6060+ const result = await generateAndPublishKey({
6161+ oauthSession: fakeOauthSession('did:plc:abc'),
6262+ installationId: 1,
6363+ })
6464+6565+ expect(result.created).toBe(true)
6666+ expect(createRecordMock).toHaveBeenCalledTimes(1)
6767+ const call = createRecordMock.mock.calls[0]![0]
6868+ expect(call.repo).toBe('did:plc:abc')
6969+ expect(call.collection).toBe('sh.tangled.publicKey')
7070+ expect(call.record.$type).toBe('sh.tangled.publicKey')
7171+ expect(call.record.key).toMatch(/^ssh-ed25519 /)
7272+ expect(call.record.name).toBe('synchub.to/1')
7373+7474+ const db = useDb()
7575+ const rows = await db.select().from(sshKey)
7676+ .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`)
7777+ expect(rows).toHaveLength(1)
7878+ const row = rows[0]!
7979+ expect(row.publicKey).toMatch(/^ssh-ed25519 /)
8080+ expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v')
8181+8282+ const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
8383+ expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/)
8484+ expect(decrypted).toContain('-----END PRIVATE KEY-----')
8585+ })
8686+8787+ it('no-ops if a key already exists for (installation, did)', async () => {
8888+ await generateAndPublishKey({
8989+ oauthSession: fakeOauthSession('did:plc:abc'),
9090+ installationId: 1,
9191+ })
9292+ expect(createRecordMock).toHaveBeenCalledTimes(1)
9393+9494+ const result = await generateAndPublishKey({
9595+ oauthSession: fakeOauthSession('did:plc:abc'),
9696+ installationId: 1,
9797+ })
9898+ expect(result.created).toBe(false)
9999+ expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again
100100+101101+ const db = useDb()
102102+ const rows = await db.select().from(sshKey)
103103+ expect(rows).toHaveLength(1)
104104+ })
105105+106106+ it('does not write a row if the PDS publish fails', async () => {
107107+ createRecordMock.mockRejectedValueOnce(new Error('pds is sad'))
108108+109109+ await expect(generateAndPublishKey({
110110+ oauthSession: fakeOauthSession('did:plc:abc'),
111111+ installationId: 1,
112112+ })).rejects.toThrow(/pds is sad/)
113113+114114+ const db = useDb()
115115+ const rows = await db.select().from(sshKey)
116116+ expect(rows).toHaveLength(0)
117117+ })
118118+})