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>
61 lines
2.6 KiB
TypeScript
61 lines
2.6 KiB
TypeScript
// AES-256-GCM encrypt/decrypt for storing sensitive credentials at rest.
|
|
// Keys are derived from a secret env var using scrypt.
|
|
//
|
|
// 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 KEY_LEN = 32; // AES-256
|
|
const IV_LEN = 12; // GCM standard
|
|
|
|
export interface AesCipher {
|
|
encrypt(plaintext: string): string;
|
|
decrypt(encoded: string): string;
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
},
|
|
};
|
|
}
|
|
|
|
// 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;
|