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>
54 lines
1.8 KiB
TypeScript
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();
|
|
});
|
|
});
|