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

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