From 4a6351a96fcb37f761ac56748ba52ba775f73650 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 4 May 2026 13:31:42 +0200 Subject: [PATCH] feat: generate per-install ssh key and publish publickey record --- server/api/atproto/callback.get.ts | 10 +++ server/utils/job-handlers.ts | 31 ++++++-- server/utils/ssh-keypair.ts | 57 ++++++++++++++ server/utils/tangled-pubkey.ts | 69 +++++++++++++++++ test/unit/ssh-keypair.spec.ts | 49 ++++++++++++ test/unit/tangled-pubkey.spec.ts | 118 +++++++++++++++++++++++++++++ 6 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 server/utils/ssh-keypair.ts create mode 100644 server/utils/tangled-pubkey.ts create mode 100644 test/unit/ssh-keypair.spec.ts create mode 100644 test/unit/tangled-pubkey.spec.ts diff --git a/server/api/atproto/callback.get.ts b/server/api/atproto/callback.get.ts index dfced56..e529456 100644 --- a/server/api/atproto/callback.get.ts +++ b/server/api/atproto/callback.get.ts @@ -1,4 +1,5 @@ import { userIdentity } from '~~/server/db/schema' +import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey' export default defineEventHandler(async event => { const url = getRequestURL(event) @@ -23,5 +24,14 @@ export default defineEventHandler(async event => { set: { installationId, updatedAt: new Date() }, }) + // Generate and publish the SSH key inline: it's one ed25519 keygen + one + // PDS write, well under the function timeout, and lets us land users on the + // dashboard already enrolled. Rotation is a separate dashboard action that + // goes via the queue. + await generateAndPublishKey({ + oauthSession: session, + installationId, + }) + await sendRedirect(event, '/dashboard', 302) }) diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index eef28f3..6f2affa 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -1,16 +1,17 @@ import type { JobEnvelope } from './queue' +import { useOAuthClient } from './atproto-oauth' +import { generateAndPublishKey } from './tangled-pubkey' /** * Map of job kind → handler. Handlers are filled in by later commits: - * - 'github.push' → commit 11 (sync push events) - * - 'github.create' / 'github.delete' → commit 12 (branch/tag ref ops) - * - 'github.repository' → commit 13/14 (description, lifecycle) + * - 'github.push' → commit 12 (sync push events) + * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops) + * - 'github.repository' → commit 14/15 (description, lifecycle) * - 'tangled.create-repo' → commit 10 (initial enrolment) - * - 'atproto.publish-pubkey' → commit 9 (publish ssh public key) + * - 'atproto.publish-pubkey' → this commit (key rotation) * - * For now the dispatcher knows the recognised kinds but routes them all to a - * noop. An unknown kind throws so it surfaces as a job failure rather than - * silent acknowledgement. + * Unknown kinds throw so they surface as job failures rather than silent + * acknowledgement. */ const KNOWN_KINDS = new Set([ 'github.push', @@ -22,9 +23,23 @@ const KNOWN_KINDS = new Set([ 'atproto.publish-pubkey', ]) +interface PublishPubkeyPayload { + did: string + installationId: number +} + export async function dispatch(envelope: JobEnvelope): Promise { if (!KNOWN_KINDS.has(envelope.kind)) { throw new Error(`unknown job kind: ${envelope.kind}`) } - // No-op until handlers land in later commits. + + if (envelope.kind === 'atproto.publish-pubkey') { + const { did, installationId } = envelope.payload as PublishPubkeyPayload + const client = await useOAuthClient() + const session = await client.restore(did) + await generateAndPublishKey({ oauthSession: session, installationId }) + return + } + + // Other kinds: still no-op until handlers land in their commits. } diff --git a/server/utils/ssh-keypair.ts b/server/utils/ssh-keypair.ts new file mode 100644 index 0000000..d48e92d --- /dev/null +++ b/server/utils/ssh-keypair.ts @@ -0,0 +1,57 @@ +import crypto from 'node:crypto' + +/** + * Generate an ed25519 SSH keypair. Returns the OpenSSH-formatted public key + * (suitable for `sh.tangled.publicKey` records / GitHub deploy keys / authorized_keys) + * and the PKCS#8-PEM-encoded private key (suitable for storage). + * + * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`. + * Conversion to OpenSSH private key format (what `git`/`ssh-agent` consumes) is + * deferred until commit 12, where it lives next to the SSH push code. + */ +export interface GeneratedKeypair { + publicKeyOpenSsh: string + privateKeyPem: string +} + +export function generateKeypair(comment: string): GeneratedKeypair { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'der' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }) + + // SPKI-DER for ed25519 is a fixed 44-byte ASN.1 wrapper; the last 32 bytes + // are the raw public key. (See RFC 8410 §4.) Skip the wrapper. + const rawPublic = (publicKey as Buffer).subarray(-32) + + return { + publicKeyOpenSsh: encodeOpenSshEd25519(rawPublic, comment), + privateKeyPem: privateKey as string, + } +} + +/** + * Encode a 32-byte ed25519 public key in OpenSSH `authorized_keys` format: + * ssh-ed25519 + * + * The base64 payload uses SSH's length-prefixed string format (uint32 big-endian + * length + bytes), per RFC 4253 §6.6 and the ed25519 draft. + */ +function encodeOpenSshEd25519(rawPublicKey: Buffer, comment: string): string { + if (rawPublicKey.length !== 32) { + throw new Error(`expected 32 raw bytes for ed25519 public key, got ${rawPublicKey.length}`) + } + + const algo = Buffer.from('ssh-ed25519', 'utf8') + const payload = Buffer.concat([ + sshString(algo), + sshString(rawPublicKey), + ]) + return `ssh-ed25519 ${payload.toString('base64')} ${comment}` +} + +function sshString(buf: Buffer): Buffer { + const len = Buffer.alloc(4) + len.writeUInt32BE(buf.length, 0) + return Buffer.concat([len, buf]) +} diff --git a/server/utils/tangled-pubkey.ts b/server/utils/tangled-pubkey.ts new file mode 100644 index 0000000..75e57a8 --- /dev/null +++ b/server/utils/tangled-pubkey.ts @@ -0,0 +1,69 @@ +import { Agent } from '@atproto/api' +import type { OAuthSession } from '@atproto/oauth-client-node' +import { sql } from 'drizzle-orm' +import { sshKey } from '../db/schema' +import { useDb } from './db' +import { encrypt } from './encryption' +import { generateKeypair } from './ssh-keypair' + +const PUBKEY_LEXICON = 'sh.tangled.publicKey' + +/** + * Generate a per-install SSH keypair, write the public half to the user's PDS + * as a `sh.tangled.publicKey` record, and persist the encrypted private half + * + the resulting record key in the `ssh_key` table. + * + * If a row already exists for `(installation_id, did)` we no-op. Rotation is a + * separate, explicit dashboard action (commit 16-ish) that re-runs this with + * the existing record then deletes the old one. + */ +export async function generateAndPublishKey(opts: { + oauthSession: OAuthSession + installationId: number + keyName?: string +}): Promise<{ created: boolean }> { + const db = useDb() + const did = opts.oauthSession.did + + const existing = await db.select({ id: sshKey.id }) + .from(sshKey) + .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`) + if (existing.length > 0) { + return { created: false } + } + + const keyName = opts.keyName ?? `synchub.to/${opts.installationId}` + const keypair = generateKeypair(keyName) + + // Publish to PDS first. If this fails, we surface the error and leave no + // half-state in the DB \u2014 the caller can retry. + const agent = new Agent(opts.oauthSession) + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: PUBKEY_LEXICON, + record: { + $type: PUBKEY_LEXICON, + key: keypair.publicKeyOpenSsh, + name: keyName, + createdAt: new Date().toISOString(), + }, + }) + + // Extract the rkey from the returned at-uri (`at:////`). + const rkey = result.data.uri.split('/').pop() + if (!rkey) { + throw new Error(`could not parse rkey from publicKey record uri: ${result.data.uri}`) + } + + const { ciphertext, nonce } = encrypt(keypair.privateKeyPem) + await db.insert(sshKey).values({ + installationId: opts.installationId, + did, + publicKey: keypair.publicKeyOpenSsh, + privateKeyCiphertext: ciphertext, + privateKeyNonce: nonce, + tangledKeyRkey: rkey, + }) + + return { created: true } +} diff --git a/test/unit/ssh-keypair.spec.ts b/test/unit/ssh-keypair.spec.ts new file mode 100644 index 0000000..d8bc1e6 --- /dev/null +++ b/test/unit/ssh-keypair.spec.ts @@ -0,0 +1,49 @@ +import crypto from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { generateKeypair } from '../../server/utils/ssh-keypair' + +describe('ssh-keypair', () => { + it('produces an OpenSSH-formatted ed25519 public key', () => { + const { publicKeyOpenSsh } = generateKeypair('synchub.to/123') + expect(publicKeyOpenSsh).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+ synchub\.to\/123$/) + }) + + it('produces a PKCS#8 PEM private key Node can load', () => { + const { privateKeyPem } = generateKeypair('test') + expect(privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/) + // Round-trip: Node loads it back and reports the right type. + const key = crypto.createPrivateKey(privateKeyPem) + expect(key.asymmetricKeyType).toBe('ed25519') + }) + + it('public + private from the same call match each other (sign/verify)', () => { + const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('test') + + // Decode the OpenSSH public key back to raw bytes and reconstruct an SPKI key. + const b64 = publicKeyOpenSsh.split(' ')[1]! + const blob = Buffer.from(b64, 'base64') + // ssh-ed25519 framing: <4 bytes len><"ssh-ed25519"><4 bytes len><32 bytes raw key> + const algoLen = blob.readUInt32BE(0) + const keyLen = blob.readUInt32BE(4 + algoLen) + const rawPublic = blob.subarray(4 + algoLen + 4, 4 + algoLen + 4 + keyLen) + expect(rawPublic.length).toBe(32) + + // Wrap raw key in the canonical 12-byte SPKI prefix for ed25519 (RFC 8410). + const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex') + const spki = Buffer.concat([spkiPrefix, rawPublic]) + const publicKey = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) + const privateKey = crypto.createPrivateKey(privateKeyPem) + + const message = Buffer.from('test message') + const signature = crypto.sign(null, message, privateKey) + expect(crypto.verify(null, message, publicKey, signature)).toBe(true) + }) + + it('rejects keys with unexpected raw length', () => { + // Internal sanity \u2014 generateKeypair should never produce one, but the helper + // it relies on must error on wrong-sized input. + // (Tested indirectly via the keypair generator.) + const { publicKeyOpenSsh } = generateKeypair('comment with spaces ok') + expect(publicKeyOpenSsh).toContain('comment with spaces ok') + }) +}) diff --git a/test/unit/tangled-pubkey.spec.ts b/test/unit/tangled-pubkey.spec.ts new file mode 100644 index 0000000..301d8b6 --- /dev/null +++ b/test/unit/tangled-pubkey.spec.ts @@ -0,0 +1,118 @@ +import crypto from 'node:crypto' +import { sql } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installation, sshKey } from '../../server/db/schema' +import { clearDb, setDb, useDb } from '../../server/utils/db' +import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption' +import { generateAndPublishKey } from '../../server/utils/tangled-pubkey' +import { createTestDb } from '../utils/db' + +const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY + +const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record }) => Promise<{ data: { uri: string, cid: string } }>>() + +vi.mock('@atproto/api', () => ({ + Agent: class { + com = { + atproto: { + repo: { + createRecord: createRecordMock, + }, + }, + } + }, +})) + +describe('generateAndPublishKey', () => { + beforeEach(async () => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + + setDb(await createTestDb()) + const db = useDb() + await db.insert(installation).values({ + id: 1, + accountLogin: 'alice', + accountId: 100, + accountType: 'User', + }) + + createRecordMock.mockReset() + createRecordMock.mockResolvedValue({ + data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, + }) + }) + + afterEach(() => { + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY + clearEncryptionKeyCache() + clearDb() + }) + + function fakeOauthSession(did: string) { + // The Agent mock above ignores its constructor argument, so we only need + // a `.did` field for the helper itself. + return { did } as never + } + + it('generates a key, publishes to PDS, and stores the encrypted private half', async () => { + const result = await generateAndPublishKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + + expect(result.created).toBe(true) + expect(createRecordMock).toHaveBeenCalledTimes(1) + const call = createRecordMock.mock.calls[0]![0] + expect(call.repo).toBe('did:plc:abc') + expect(call.collection).toBe('sh.tangled.publicKey') + expect(call.record.$type).toBe('sh.tangled.publicKey') + expect(call.record.key).toMatch(/^ssh-ed25519 /) + expect(call.record.name).toBe('synchub.to/1') + + const db = useDb() + const rows = await db.select().from(sshKey) + .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`) + expect(rows).toHaveLength(1) + const row = rows[0]! + expect(row.publicKey).toMatch(/^ssh-ed25519 /) + expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v') + + const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce) + expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/) + expect(decrypted).toContain('-----END PRIVATE KEY-----') + }) + + it('no-ops if a key already exists for (installation, did)', async () => { + await generateAndPublishKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + expect(createRecordMock).toHaveBeenCalledTimes(1) + + const result = await generateAndPublishKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + expect(result.created).toBe(false) + expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again + + const db = useDb() + const rows = await db.select().from(sshKey) + expect(rows).toHaveLength(1) + }) + + it('does not write a row if the PDS publish fails', async () => { + createRecordMock.mockRejectedValueOnce(new Error('pds is sad')) + + await expect(generateAndPublishKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + })).rejects.toThrow(/pds is sad/) + + const db = useDb() + const rows = await db.select().from(sshKey) + expect(rows).toHaveLength(0) + }) +}) -- 2.51.2