diff --git a/apps/journal/app/jobs/backfill-user-keypairs.ts b/apps/journal/app/jobs/backfill-user-keypairs.ts new file mode 100644 index 0000000..cd267cf --- /dev/null +++ b/apps/journal/app/jobs/backfill-user-keypairs.ts @@ -0,0 +1,26 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * One-shot backfill: generate a federation keypair for every user who + * predates federation (spec: "Existing-user backfill at deploy"). The + * server enqueues this once at startup whenever FEDERATION_ENABLED is + * on (singleton-keyed, so repeat startups don't stack runs); each run + * only touches users whose public_key IS NULL, so re-runs are no-ops. + * New users get keys at registration and never appear in this workload. + */ +export const backfillUserKeypairsJob: JobDefinition = { + name: "backfill-user-keypairs", + retryLimit: 3, + expireInSeconds: 300, + async handler() { + const ids = await listUsersWithoutKeypair(); + let generated = 0; + for (const id of ids) { + if (await ensureUserKeypair(id)) generated++; + } + logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs"); + return { candidates: ids.length, generated }; + }, +}; diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 04d7500..7623e94 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -13,6 +13,9 @@ import type { } from "@simplewebauthn/types"; import { getDb } from "./db.ts"; import { getOrigin } from "./config.server.ts"; +import { federationEnabled } from "./federation.server.ts"; +import { ensureUserKeypair } from "./federation-keys.server.ts"; +import { logger } from "./logger.server.ts"; import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal"; import type { Visibility } from "@trails-cool/db/schema/journal"; @@ -92,9 +95,27 @@ export async function finishRegistration( transports: response.response.transports, }); + await generateFederationKeysBestEffort(userId); + return userId; } +/** + * Spec (social-federation): new users get federation signing keys at + * registration. Best-effort — a keygen failure must never fail the + * registration itself; the `backfill-user-keypairs` job is the safety + * net for any user this skips. No-op while federation is off (the + * backfill covers everyone when the flag first flips on). + */ +async function generateFederationKeysBestEffort(userId: string): Promise { + if (!federationEnabled()) return; + try { + await ensureUserKeypair(userId); + } catch (err) { + logger.warn({ err, userId }, "federation keypair generation failed at registration; backfill will retry"); + } +} + // --- Add Passkey to Existing Account --- export async function addPasskeyStart(userId: string) { @@ -176,6 +197,8 @@ export async function registerWithMagicLink( termsVersion, }); + await generateFederationKeysBestEffort(userId); + // Same shape as login's createMagicToken — token for the click-through // link, 6-digit code for paste-from-email/SMS flows (mobile). const token = randomBytes(32).toString("base64url"); diff --git a/apps/journal/app/lib/crypto.server.ts b/apps/journal/app/lib/crypto.server.ts index 815ce3c..8ede16d 100644 --- a/apps/journal/app/lib/crypto.server.ts +++ b/apps/journal/app/lib/crypto.server.ts @@ -1,41 +1,61 @@ -// AES-256-GCM encrypt/decrypt for storing sensitive integration credentials. -// Key is derived from INTEGRATION_SECRET env var using scrypt. +// AES-256-GCM encrypt/decrypt for storing sensitive credentials at rest. +// Keys are derived from a secret env var using scrypt. // -// Encoded format: base64(salt_hex:iv_hex:ciphertext_hex:authtag_hex) +// Encoded format: base64(iv_hex:ciphertext_hex:authtag_hex) // All fields are hex-encoded before joining so the separator is unambiguous. import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; -const SALT = "trails-cool-integrations"; const KEY_LEN = 32; // AES-256 const IV_LEN = 12; // GCM standard -function getKey(): Buffer { - const secret = process.env.INTEGRATION_SECRET; - if (!secret) throw new Error("INTEGRATION_SECRET env var is not set"); - return scryptSync(secret, SALT, KEY_LEN) as Buffer; +export interface AesCipher { + encrypt(plaintext: string): string; + decrypt(encoded: string): string; } -export function encrypt(plaintext: string): string { - const key = getKey(); - const iv = randomBytes(IV_LEN); - const cipher = createCipheriv("aes-256-gcm", key, iv); - const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - const authTag = cipher.getAuthTag(); - const payload = [iv.toString("hex"), encrypted.toString("hex"), authTag.toString("hex")].join(":"); - return Buffer.from(payload).toString("base64"); +/** + * Build an encrypt/decrypt pair keyed from `secretEnvVar`. The key is + * derived lazily on first use so importing this module never throws — + * only actually encrypting/decrypting requires the env var to be set. + * Distinct `salt` per use-case keeps derived keys independent even if + * two env vars were ever set to the same value. + */ +export function createAesCipher(secretEnvVar: string, salt: string): AesCipher { + function getKey(): Buffer { + const secret = process.env[secretEnvVar]; + if (!secret) throw new Error(`${secretEnvVar} env var is not set`); + return scryptSync(secret, salt, KEY_LEN) as Buffer; + } + + return { + encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const payload = [iv.toString("hex"), encrypted.toString("hex"), authTag.toString("hex")].join(":"); + return Buffer.from(payload).toString("base64"); + }, + decrypt(encoded: string): string { + const key = getKey(); + const payload = Buffer.from(encoded, "base64").toString("utf8"); + const parts = payload.split(":"); + if (parts.length !== 3) throw new Error("Invalid encrypted payload format"); + const [ivHex, encryptedHex, authTagHex] = parts as [string, string, string]; + const iv = Buffer.from(ivHex, "hex"); + const encrypted = Buffer.from(encryptedHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final("utf8"); + }, + }; } -export function decrypt(encoded: string): string { - const key = getKey(); - const payload = Buffer.from(encoded, "base64").toString("utf8"); - const parts = payload.split(":"); - if (parts.length !== 3) throw new Error("Invalid encrypted payload format"); - const [ivHex, encryptedHex, authTagHex] = parts as [string, string, string]; - const iv = Buffer.from(ivHex, "hex"); - const encrypted = Buffer.from(encryptedHex, "hex"); - const authTag = Buffer.from(authTagHex, "hex"); - const decipher = createDecipheriv("aes-256-gcm", key, iv); - decipher.setAuthTag(authTag); - return decipher.update(encrypted) + decipher.final("utf8"); -} +// Integration-credential cipher (Komoot web-login, etc). Pre-existing +// callers import { encrypt, decrypt } directly; keep that surface. +const integrations = createAesCipher("INTEGRATION_SECRET", "trails-cool-integrations"); +export const encrypt = integrations.encrypt; +export const decrypt = integrations.decrypt; diff --git a/apps/journal/app/lib/federation-keys.server.test.ts b/apps/journal/app/lib/federation-keys.server.test.ts new file mode 100644 index 0000000..095e4c3 --- /dev/null +++ b/apps/journal/app/lib/federation-keys.server.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +beforeEach(() => { + process.env.FEDERATION_KEY_ENCRYPTION_KEY = "test-federation-key-secret"; +}); + +const { generateUserKeypair, loadUserKeypair } = await import( + "./federation-keys.server.ts" +); + +describe("federation keypairs", () => { + it("generates a serializable RSA keypair with encrypted private key", async () => { + const pair = await generateUserKeypair(); + + const publicJwk = JSON.parse(pair.publicKey); + expect(publicJwk.kty).toBe("RSA"); + // 2048-bit modulus → 256 bytes → 342 base64url chars (spec wants RSA 2048) + expect(publicJwk.n.length).toBeGreaterThanOrEqual(342); + + // Private key must not be stored as plaintext JWK + expect(pair.privateKeyEncrypted).not.toContain('"kty"'); + expect(() => JSON.parse(pair.privateKeyEncrypted)).toThrow(); + }); + + it("roundtrips through load into usable CryptoKeys that sign and verify", async () => { + const stored = await generateUserKeypair(); + const loaded = await loadUserKeypair(stored); + expect(loaded).not.toBeNull(); + + const data = new TextEncoder().encode("signed federation payload"); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + loaded!.privateKey, + data, + ); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + loaded!.publicKey, + signature, + data, + ); + expect(valid).toBe(true); + }); + + it("returns null for users without stored keys", async () => { + expect(await loadUserKeypair({ publicKey: null, privateKeyEncrypted: null })).toBeNull(); + }); + + it("fails to load when the encryption key is wrong", async () => { + const stored = await generateUserKeypair(); + process.env.FEDERATION_KEY_ENCRYPTION_KEY = "a-different-secret"; + await expect(loadUserKeypair(stored)).rejects.toThrow(); + }); +}); diff --git a/apps/journal/app/lib/federation-keys.server.ts b/apps/journal/app/lib/federation-keys.server.ts new file mode 100644 index 0000000..3cd9c95 --- /dev/null +++ b/apps/journal/app/lib/federation-keys.server.ts @@ -0,0 +1,98 @@ +// Per-user federation signing keypairs (spec: social-federation, +// "Per-user signing keypairs"). +// +// Every federating user has an RSA keypair: the public key is embedded +// in their actor object for inbound signature verification by remotes; +// the private key signs our outbound requests (HTTP Signatures). Keys +// are stored as JWK JSON on journal.users — public in plaintext, +// private encrypted at rest with FEDERATION_KEY_ENCRYPTION_KEY. +// +// RSASSA-PKCS1-v1_5 (RSA 2048) is the algorithm Mastodon and the wider +// fediverse interoperate on for HTTP Signatures; Ed25519 support is +// still patchy across implementations. +// +// Key-rotation runbook: rotating FEDERATION_KEY_ENCRYPTION_KEY requires +// decrypt-with-old + encrypt-with-new over journal.users — documented in +// docs/deployment.md (task 10.2). + +import { generateCryptoKeyPair, exportJwk, importJwk } from "@fedify/fedify"; +import { and, eq, isNull } from "drizzle-orm"; +import { users } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { createAesCipher } from "./crypto.server.ts"; +import { requireSecret } from "./config.server.ts"; + +const cipher = createAesCipher("FEDERATION_KEY_ENCRYPTION_KEY", "trails-cool-federation-keys"); + +/** + * Fail fast in production when the encryption key is missing or the + * known-public dev fallback. Called before any key material is written. + */ +function assertEncryptionKeyConfigured(): void { + requireSecret("FEDERATION_KEY_ENCRYPTION_KEY", "dev-only-federation-key"); + // requireSecret throws in prod for unset/fallback values; in dev it + // returns the fallback, which createAesCipher's env lookup won't see — + // so mirror it into the env for the cipher when unset. + process.env.FEDERATION_KEY_ENCRYPTION_KEY ??= "dev-only-federation-key"; +} + +export interface SerializedKeypair { + publicKey: string; + privateKeyEncrypted: string; +} + +/** Generate a fresh RSA 2048 keypair, serialized for journal.users. */ +export async function generateUserKeypair(): Promise { + assertEncryptionKeyConfigured(); + const { publicKey, privateKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + return { + publicKey: JSON.stringify(await exportJwk(publicKey)), + privateKeyEncrypted: cipher.encrypt(JSON.stringify(await exportJwk(privateKey))), + }; +} + +/** + * Generate and store a keypair for `userId` if it doesn't have one. + * Concurrency-safe: the UPDATE is guarded on `public_key IS NULL`, so + * a racing generation loses and the first written pair stays canonical. + * Returns true when this call generated the pair. + */ +export async function ensureUserKeypair(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ publicKey: users.publicKey }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!row || row.publicKey !== null) return false; + const pair = await generateUserKeypair(); + const updated = await db + .update(users) + .set({ publicKey: pair.publicKey, privateKeyEncrypted: pair.privateKeyEncrypted }) + .where(and(eq(users.id, userId), isNull(users.publicKey))) + .returning({ id: users.id }); + return updated.length > 0; +} + +/** Deserialize a user's stored keypair into CryptoKeys for Fedify. */ +export async function loadUserKeypair(row: { + publicKey: string | null; + privateKeyEncrypted: string | null; +}): Promise { + if (!row.publicKey || !row.privateKeyEncrypted) return null; + assertEncryptionKeyConfigured(); + return { + publicKey: await importJwk(JSON.parse(row.publicKey), "public"), + privateKey: await importJwk(JSON.parse(cipher.decrypt(row.privateKeyEncrypted)), "private"), + }; +} + +/** All user ids still lacking a keypair (backfill workload). */ +export async function listUsersWithoutKeypair(): Promise { + const db = getDb(); + const rows = await db + .select({ id: users.id }) + .from(users) + .where(isNull(users.publicKey)); + return rows.map((r) => r.id); +} diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 38b8751..9aaa341 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -178,6 +178,11 @@ server.listen(port, async () => { const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.ts"); const { consumedJtiSweepJob } = await import("./app/jobs/consumed-jti-sweep.ts"); jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob); + // Federation keypair backfill — registered only when federation is on. + if (process.env.FEDERATION_ENABLED === "true") { + const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts"); + jobs.push(backfillUserKeypairsJob); + } const boss = createBoss(getDatabaseUrl()); await startWorker(boss, jobs); @@ -186,4 +191,12 @@ server.listen(port, async () => { const { setBoss } = await import("./app/lib/boss.server.ts"); setBoss(boss); logger.info("Background job worker started"); + + // One-shot federation keypair backfill per startup (spec: existing + // users get keys before any federation traffic). Each run only + // touches users whose public_key IS NULL, so repeats are no-ops. + if (process.env.FEDERATION_ENABLED === "true") { + await boss.send("backfill-user-keypairs", {}); + logger.info("federation keypair backfill enqueued"); + } }); diff --git a/openspec/changes/social-federation/design.md b/openspec/changes/social-federation/design.md index 5cf3a64..0ed78c6 100644 --- a/openspec/changes/social-federation/design.md +++ b/openspec/changes/social-federation/design.md @@ -108,6 +108,14 @@ Inbound signature verification uses the actor's public key from their actor obje ## Open Questions +- **`activities.owner_id` is NOT NULL but remote-ingested rows have no local + owner** (surfaced during task 2.3, 2026-06-06). Options: make `owner_id` + nullable with a check constraint (`owner_id IS NOT NULL OR remote_actor_iri + IS NOT NULL`), or key remote rows purely off `remote_actor_iri` in a way + that never touches owner-joined queries. Decide in task 7.2 (ingestion) + before any remote row is written; the columns landed in 2.3 don't prejudge + either option. + - Custom AS extension for activity-type vs. plain `Create(Note)`? Mastodon will only render Notes; an extension type means non-trails clients see nothing useful. Lean toward `Create(Note)` with structured metadata in `attachment` so Mastodon shows the text + GPX link, and trails clients consuming the outbox can read the structured fields. - Per-instance inbox vs per-user inbox? Mastodon supports a "shared inbox" optimization. Worth doing for delivery efficiency once we have any volume; v1 can use per-user inboxes only. - How does the trails-to-trails check interact with self-hosters who fork or rebrand? Probably the `software` field is `"trails.cool"` (or a derivative we list) and the `/.well-known/trails-cool` endpoint is the authoritative discovery. Tasks phase decides the exact shape. diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index dcbea6f..ca37cee 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -6,11 +6,11 @@ ## 2. Schema -- [ ] 2.1 Add `users.public_key TEXT NULL`, `users.private_key_encrypted TEXT NULL` (encrypted with `FEDERATION_KEY_ENCRYPTION_KEY`) -- [ ] 2.2 Add `remote_actors` table: `actor_iri PK`, `display_name`, `username`, `domain`, `avatar_url`, `inbox_url`, `outbox_url`, `public_key`, `software` (the discovery field), `last_polled_at`, `created_at` -- [ ] 2.3 Extend `journal.activities` with `remote_origin_iri TEXT NULL UNIQUE`, `remote_actor_iri TEXT NULL`, `audience TEXT NOT NULL DEFAULT 'public'` -- [ ] 2.4 Run `pnpm db:push` locally and confirm migration is clean -- [ ] 2.5 One-shot pg-boss job `backfill-user-keypairs` that generates a keypair for every existing user lacking one. Runs once at deploy when feature flag flips on +- [x] 2.1 Add `users.public_key TEXT NULL`, `users.private_key_encrypted TEXT NULL` (encrypted with `FEDERATION_KEY_ENCRYPTION_KEY`) +- [x] 2.2 Add `remote_actors` table: `actor_iri PK`, `display_name`, `username`, `domain`, `avatar_url`, `inbox_url`, `outbox_url`, `public_key`, `software` (the discovery field), `last_polled_at`, `created_at` +- [x] 2.3 Extend `journal.activities` with `remote_origin_iri TEXT NULL UNIQUE`, `remote_actor_iri TEXT NULL`, `audience TEXT NOT NULL DEFAULT 'public'` +- [x] 2.4 Run `pnpm db:push` locally and confirm migration is clean +- [x] 2.5 One-shot pg-boss job `backfill-user-keypairs` that generates a keypair for every existing user lacking one. Runs once at deploy when feature flag flips on ## 3. Identity surface diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index ca438ec..1dec133 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -44,6 +44,14 @@ export const users = journalSchema.table("users", { // content defaults; existing users were backfilled to `public` by an // earlier migration so behavior didn't change for them. profileVisibility: text("profile_visibility").$type().notNull().default("private"), + // Federation signing keypair (spec: social-federation). The public key + // is a JWK JSON string embedded in the actor object; the private key is + // a JWK encrypted at rest with FEDERATION_KEY_ENCRYPTION_KEY (see + // apps/journal app/lib/federation-keys.server.ts). NULL until generated — + // at registration for new users, or by the one-shot + // `backfill-user-keypairs` job for users predating federation. + publicKey: text("public_key"), + privateKeyEncrypted: text("private_key_encrypted"), termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }), termsVersion: text("terms_version"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), @@ -123,6 +131,16 @@ export const routeVersions = journalSchema.table("route_versions", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); +/** + * Audience of an activity cached from a remote actor's outbox (spec: + * social-federation, "Audience-aware feed filtering"). Local activities + * are always 'public' here — their actual visibility lives in + * `visibility`; this column only matters for remote rows, where + * 'followers-only' content must reach only the local viewer whose + * accepted follow brought it in. + */ +export type Audience = "public" | "followers-only"; + export const activities = journalSchema.table("activities", { id: text("id").primaryKey(), ownerId: text("owner_id") @@ -142,6 +160,14 @@ export const activities = journalSchema.table("activities", { participants: jsonb("participants").$type(), visibility: text("visibility").$type().notNull().default("private"), synthetic: boolean("synthetic").notNull().default(false), + // Federation provenance (spec: social-federation). NULL for local + // activities. For rows ingested from a remote trails actor's outbox: + // `remoteOriginIri` is the activity's IRI on the origin instance + // (unique → replay-safe `ON CONFLICT DO NOTHING` ingestion), and + // `remoteActorIri` keys into `remote_actors`. + remoteOriginIri: text("remote_origin_iri").unique(), + remoteActorIri: text("remote_actor_iri"), + audience: text("audience").$type().notNull().default("public"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ // Hot paths: listActivities (sort by started_at) and listPublicActivitiesForOwner. @@ -318,6 +344,26 @@ export const follows = journalSchema.table("follows", { followedUserIdx: index("follows_followed_user_idx").on(t.followedUserId), })); +// Cache of remote ActivityPub actors we interact with (spec: +// social-federation). One row per actor IRI: display fields for feed +// cards, inbox/outbox URLs for delivery and polling, the public key for +// inbound HTTP-Signature verification, and `software` (the discovery +// field) for the trails-to-trails outbound check. Refreshed during +// outbox polls so feed renders never re-fetch the actor document. +export const remoteActors = journalSchema.table("remote_actors", { + actorIri: text("actor_iri").primaryKey(), + displayName: text("display_name"), + username: text("username"), + domain: text("domain"), + avatarUrl: text("avatar_url"), + inboxUrl: text("inbox_url"), + outboxUrl: text("outbox_url"), + publicKey: text("public_key"), + software: text("software"), + lastPolledAt: timestamp("last_polled_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + // Notifications. Each row is a single event the recipient should be // informed about. v1 types: follow_request_received, follow_request_approved, // follow_received, activity_published. The `payload` JSONB snapshots the