mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: generate per-install ssh key and publish publickey record

+326 -8
+10
server/api/atproto/callback.get.ts
··· 1 1 import { userIdentity } from '~~/server/db/schema' 2 + import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey' 2 3 3 4 export default defineEventHandler(async event => { 4 5 const url = getRequestURL(event) ··· 21 22 }).onConflictDoUpdate({ 22 23 target: userIdentity.did, 23 24 set: { installationId, updatedAt: new Date() }, 25 + }) 26 + 27 + // Generate and publish the SSH key inline: it's one ed25519 keygen + one 28 + // PDS write, well under the function timeout, and lets us land users on the 29 + // dashboard already enrolled. Rotation is a separate dashboard action that 30 + // goes via the queue. 31 + await generateAndPublishKey({ 32 + oauthSession: session, 33 + installationId, 24 34 }) 25 35 26 36 await sendRedirect(event, '/dashboard', 302)
+23 -8
server/utils/job-handlers.ts
··· 1 1 import type { JobEnvelope } from './queue' 2 + import { useOAuthClient } from './atproto-oauth' 3 + import { generateAndPublishKey } from './tangled-pubkey' 2 4 3 5 /** 4 6 * Map of job kind → handler. Handlers are filled in by later commits: 5 - * - 'github.push' → commit 11 (sync push events) 6 - * - 'github.create' / 'github.delete' → commit 12 (branch/tag ref ops) 7 - * - 'github.repository' → commit 13/14 (description, lifecycle) 7 + * - 'github.push' → commit 12 (sync push events) 8 + * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops) 9 + * - 'github.repository' → commit 14/15 (description, lifecycle) 8 10 * - 'tangled.create-repo' → commit 10 (initial enrolment) 9 - * - 'atproto.publish-pubkey' → commit 9 (publish ssh public key) 11 + * - 'atproto.publish-pubkey' → this commit (key rotation) 10 12 * 11 - * For now the dispatcher knows the recognised kinds but routes them all to a 12 - * noop. An unknown kind throws so it surfaces as a job failure rather than 13 - * silent acknowledgement. 13 + * Unknown kinds throw so they surface as job failures rather than silent 14 + * acknowledgement. 14 15 */ 15 16 const KNOWN_KINDS = new Set([ 16 17 'github.push', ··· 22 23 'atproto.publish-pubkey', 23 24 ]) 24 25 26 + interface PublishPubkeyPayload { 27 + did: string 28 + installationId: number 29 + } 30 + 25 31 export async function dispatch(envelope: JobEnvelope): Promise<void> { 26 32 if (!KNOWN_KINDS.has(envelope.kind)) { 27 33 throw new Error(`unknown job kind: ${envelope.kind}`) 28 34 } 29 - // No-op until handlers land in later commits. 35 + 36 + if (envelope.kind === 'atproto.publish-pubkey') { 37 + const { did, installationId } = envelope.payload as PublishPubkeyPayload 38 + const client = await useOAuthClient() 39 + const session = await client.restore(did) 40 + await generateAndPublishKey({ oauthSession: session, installationId }) 41 + return 42 + } 43 + 44 + // Other kinds: still no-op until handlers land in their commits. 30 45 }
+57
server/utils/ssh-keypair.ts
··· 1 + import crypto from 'node:crypto' 2 + 3 + /** 4 + * Generate an ed25519 SSH keypair. Returns the OpenSSH-formatted public key 5 + * (suitable for `sh.tangled.publicKey` records / GitHub deploy keys / authorized_keys) 6 + * and the PKCS#8-PEM-encoded private key (suitable for storage). 7 + * 8 + * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`. 9 + * Conversion to OpenSSH private key format (what `git`/`ssh-agent` consumes) is 10 + * deferred until commit 12, where it lives next to the SSH push code. 11 + */ 12 + export interface GeneratedKeypair { 13 + publicKeyOpenSsh: string 14 + privateKeyPem: string 15 + } 16 + 17 + export function generateKeypair(comment: string): GeneratedKeypair { 18 + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { 19 + publicKeyEncoding: { type: 'spki', format: 'der' }, 20 + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, 21 + }) 22 + 23 + // SPKI-DER for ed25519 is a fixed 44-byte ASN.1 wrapper; the last 32 bytes 24 + // are the raw public key. (See RFC 8410 §4.) Skip the wrapper. 25 + const rawPublic = (publicKey as Buffer).subarray(-32) 26 + 27 + return { 28 + publicKeyOpenSsh: encodeOpenSshEd25519(rawPublic, comment), 29 + privateKeyPem: privateKey as string, 30 + } 31 + } 32 + 33 + /** 34 + * Encode a 32-byte ed25519 public key in OpenSSH `authorized_keys` format: 35 + * ssh-ed25519 <base64(string("ssh-ed25519") + string(rawKey))> <comment> 36 + * 37 + * The base64 payload uses SSH's length-prefixed string format (uint32 big-endian 38 + * length + bytes), per RFC 4253 §6.6 and the ed25519 draft. 39 + */ 40 + function encodeOpenSshEd25519(rawPublicKey: Buffer, comment: string): string { 41 + if (rawPublicKey.length !== 32) { 42 + throw new Error(`expected 32 raw bytes for ed25519 public key, got ${rawPublicKey.length}`) 43 + } 44 + 45 + const algo = Buffer.from('ssh-ed25519', 'utf8') 46 + const payload = Buffer.concat([ 47 + sshString(algo), 48 + sshString(rawPublicKey), 49 + ]) 50 + return `ssh-ed25519 ${payload.toString('base64')} ${comment}` 51 + } 52 + 53 + function sshString(buf: Buffer): Buffer { 54 + const len = Buffer.alloc(4) 55 + len.writeUInt32BE(buf.length, 0) 56 + return Buffer.concat([len, buf]) 57 + }
+69
server/utils/tangled-pubkey.ts
··· 1 + import { Agent } from '@atproto/api' 2 + import type { OAuthSession } from '@atproto/oauth-client-node' 3 + import { sql } from 'drizzle-orm' 4 + import { sshKey } from '../db/schema' 5 + import { useDb } from './db' 6 + import { encrypt } from './encryption' 7 + import { generateKeypair } from './ssh-keypair' 8 + 9 + const 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 + */ 20 + export 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 + }
+49
test/unit/ssh-keypair.spec.ts
··· 1 + import crypto from 'node:crypto' 2 + import { describe, expect, it } from 'vitest' 3 + import { generateKeypair } from '../../server/utils/ssh-keypair' 4 + 5 + describe('ssh-keypair', () => { 6 + it('produces an OpenSSH-formatted ed25519 public key', () => { 7 + const { publicKeyOpenSsh } = generateKeypair('synchub.to/123') 8 + expect(publicKeyOpenSsh).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+ synchub\.to\/123$/) 9 + }) 10 + 11 + it('produces a PKCS#8 PEM private key Node can load', () => { 12 + const { privateKeyPem } = generateKeypair('test') 13 + expect(privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/) 14 + // Round-trip: Node loads it back and reports the right type. 15 + const key = crypto.createPrivateKey(privateKeyPem) 16 + expect(key.asymmetricKeyType).toBe('ed25519') 17 + }) 18 + 19 + it('public + private from the same call match each other (sign/verify)', () => { 20 + const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('test') 21 + 22 + // Decode the OpenSSH public key back to raw bytes and reconstruct an SPKI key. 23 + const b64 = publicKeyOpenSsh.split(' ')[1]! 24 + const blob = Buffer.from(b64, 'base64') 25 + // ssh-ed25519 framing: <4 bytes len><"ssh-ed25519"><4 bytes len><32 bytes raw key> 26 + const algoLen = blob.readUInt32BE(0) 27 + const keyLen = blob.readUInt32BE(4 + algoLen) 28 + const rawPublic = blob.subarray(4 + algoLen + 4, 4 + algoLen + 4 + keyLen) 29 + expect(rawPublic.length).toBe(32) 30 + 31 + // Wrap raw key in the canonical 12-byte SPKI prefix for ed25519 (RFC 8410). 32 + const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex') 33 + const spki = Buffer.concat([spkiPrefix, rawPublic]) 34 + const publicKey = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) 35 + const privateKey = crypto.createPrivateKey(privateKeyPem) 36 + 37 + const message = Buffer.from('test message') 38 + const signature = crypto.sign(null, message, privateKey) 39 + expect(crypto.verify(null, message, publicKey, signature)).toBe(true) 40 + }) 41 + 42 + it('rejects keys with unexpected raw length', () => { 43 + // Internal sanity \u2014 generateKeypair should never produce one, but the helper 44 + // it relies on must error on wrong-sized input. 45 + // (Tested indirectly via the keypair generator.) 46 + const { publicKeyOpenSsh } = generateKeypair('comment with spaces ok') 47 + expect(publicKeyOpenSsh).toContain('comment with spaces ok') 48 + }) 49 + })
+118
test/unit/tangled-pubkey.spec.ts
··· 1 + import crypto from 'node:crypto' 2 + import { sql } from 'drizzle-orm' 3 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 4 + import { installation, sshKey } from '../../server/db/schema' 5 + import { clearDb, setDb, useDb } from '../../server/utils/db' 6 + import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption' 7 + import { generateAndPublishKey } from '../../server/utils/tangled-pubkey' 8 + import { createTestDb } from '../utils/db' 9 + 10 + const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 11 + 12 + const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>() 13 + 14 + vi.mock('@atproto/api', () => ({ 15 + Agent: class { 16 + com = { 17 + atproto: { 18 + repo: { 19 + createRecord: createRecordMock, 20 + }, 21 + }, 22 + } 23 + }, 24 + })) 25 + 26 + describe('generateAndPublishKey', () => { 27 + beforeEach(async () => { 28 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 29 + clearEncryptionKeyCache() 30 + 31 + setDb(await createTestDb()) 32 + const db = useDb() 33 + await db.insert(installation).values({ 34 + id: 1, 35 + accountLogin: 'alice', 36 + accountId: 100, 37 + accountType: 'User', 38 + }) 39 + 40 + createRecordMock.mockReset() 41 + createRecordMock.mockResolvedValue({ 42 + data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, 43 + }) 44 + }) 45 + 46 + afterEach(() => { 47 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 48 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 49 + clearEncryptionKeyCache() 50 + clearDb() 51 + }) 52 + 53 + function fakeOauthSession(did: string) { 54 + // The Agent mock above ignores its constructor argument, so we only need 55 + // a `.did` field for the helper itself. 56 + return { did } as never 57 + } 58 + 59 + it('generates a key, publishes to PDS, and stores the encrypted private half', async () => { 60 + const result = await generateAndPublishKey({ 61 + oauthSession: fakeOauthSession('did:plc:abc'), 62 + installationId: 1, 63 + }) 64 + 65 + expect(result.created).toBe(true) 66 + expect(createRecordMock).toHaveBeenCalledTimes(1) 67 + const call = createRecordMock.mock.calls[0]![0] 68 + expect(call.repo).toBe('did:plc:abc') 69 + expect(call.collection).toBe('sh.tangled.publicKey') 70 + expect(call.record.$type).toBe('sh.tangled.publicKey') 71 + expect(call.record.key).toMatch(/^ssh-ed25519 /) 72 + expect(call.record.name).toBe('synchub.to/1') 73 + 74 + const db = useDb() 75 + const rows = await db.select().from(sshKey) 76 + .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`) 77 + expect(rows).toHaveLength(1) 78 + const row = rows[0]! 79 + expect(row.publicKey).toMatch(/^ssh-ed25519 /) 80 + expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v') 81 + 82 + const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce) 83 + expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/) 84 + expect(decrypted).toContain('-----END PRIVATE KEY-----') 85 + }) 86 + 87 + it('no-ops if a key already exists for (installation, did)', async () => { 88 + await generateAndPublishKey({ 89 + oauthSession: fakeOauthSession('did:plc:abc'), 90 + installationId: 1, 91 + }) 92 + expect(createRecordMock).toHaveBeenCalledTimes(1) 93 + 94 + const result = await generateAndPublishKey({ 95 + oauthSession: fakeOauthSession('did:plc:abc'), 96 + installationId: 1, 97 + }) 98 + expect(result.created).toBe(false) 99 + expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again 100 + 101 + const db = useDb() 102 + const rows = await db.select().from(sshKey) 103 + expect(rows).toHaveLength(1) 104 + }) 105 + 106 + it('does not write a row if the PDS publish fails', async () => { 107 + createRecordMock.mockRejectedValueOnce(new Error('pds is sad')) 108 + 109 + await expect(generateAndPublishKey({ 110 + oauthSession: fakeOauthSession('did:plc:abc'), 111 + installationId: 1, 112 + })).rejects.toThrow(/pds is sad/) 113 + 114 + const db = useDb() 115 + const rows = await db.select().from(sshKey) 116 + expect(rows).toHaveLength(0) 117 + }) 118 + })