feat(journal): federation schema + per-user signing keypairs

social-federation tasks 2.1–2.5:

- users.public_key / users.private_key_encrypted (TEXT NULL): RSA 2048
  keypairs as JWK JSON; private key AES-256-GCM encrypted at rest with
  FEDERATION_KEY_ENCRYPTION_KEY.
- remote_actors table: cache of remote AP actors (display fields,
  inbox/outbox URLs, public key, software discovery field, poll cursor).
- activities.remote_origin_iri (UNIQUE) / remote_actor_iri / audience:
  provenance + audience tagging for rows ingested from remote outboxes.
  Replay-safe ingestion keys off the unique origin IRI.
- crypto.server.ts: generalized into createAesCipher(envVar, salt)
  factory; existing INTEGRATION_SECRET surface unchanged.
- federation-keys.server.ts: generate/ensure/load keypairs.
  RSASSA-PKCS1-v1_5 because that's what Mastodon interops on.
- backfill-user-keypairs pg-boss job: enqueued once per server startup
  when FEDERATION_ENABLED=true; only touches users with NULL keys, so
  re-runs are no-ops. New users get keys at registration (both passkey
  and magic-link paths), best-effort with the backfill as safety net.
- design.md: noted open question — activities.owner_id is NOT NULL but
  remote-ingested rows have no local owner; decide in task 7.2.

Schema is additive; zero behavior change while FEDERATION_ENABLED is
off. db:push verified clean against local Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-06 14:02:57 +02:00
parent 4ef86e4dc2
commit 9a90b6db55
9 changed files with 322 additions and 34 deletions

View file

@ -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 };
},
};

View file

@ -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<void> {
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");

View file

@ -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;

View file

@ -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();
});
});

View file

@ -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<SerializedKeypair> {
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<boolean> {
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<CryptoKeyPair | null> {
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<string[]> {
const db = getDb();
const rows = await db
.select({ id: users.id })
.from(users)
.where(isNull(users.publicKey));
return rows.map((r) => r.id);
}