trails/apps/journal/app/lib/federation-keys.server.test.ts
Ullrich Schäfer 9a90b6db55 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>
2026-06-06 14:15:51 +02:00

54 lines
1.8 KiB
TypeScript

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