Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs

Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0
Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908
Hop-Task: T_06FPAWA3279RBMJAR0BHM10
Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
This commit is contained in:
2026-07-15 06:40:06 -07:00
committed by Hop
parent 219a40bea4
commit b46be5c076
54 changed files with 14409 additions and 1192 deletions
+22 -13
View File
@@ -7,8 +7,8 @@ import { eq, inArray } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { generateKeyPair } from '@/lib/crypto/keys';
import { didKeyMatchesPublicKey, generateDID } from '@/lib/crypto/did-key';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { base58btc } from 'multiformats/bases/base58';
import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { registrationDisplayName } from '@/lib/auth/display-name';
@@ -133,18 +133,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
return bcrypt.compare(password, hash);
}
/**
* Generate a DID for a new user
* Uses did:key format (W3C standard) - the DID contains the public key itself
*/
export function generateDID(publicKey: string): string {
// Encode the SPKI public key in base58btc (multibase)
const publicKeyBytes = Buffer.from(publicKey, 'base64');
const encoded = base58btc.encode(new Uint8Array(publicKeyBytes));
// Create did:key - the 'z' prefix indicates base58btc encoding
return `did:key:${encoded}`;
}
export { generateDID } from '@/lib/crypto/did-key';
/**
* Generate legacy DID format (for backward compatibility)
@@ -399,6 +388,26 @@ export async function authenticateUser(
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
}
// Older login migrations rotated an RSA signing key without rotating the
// self-certifying did:key identifier. Repair that mismatch transparently so
// clients can verify the current account key from the DID itself.
if (user.did.startsWith('did:key:') && !didKeyMatchesPublicKey(user.did, user.publicKey)) {
const expectedDid = generateDID(user.publicKey);
if (user.did !== expectedDid) {
await db.update(users)
.set({ did: expectedDid, updatedAt: new Date() })
.where(eq(users.id, user.id));
user.did = expectedDid;
await upsertHandleEntries([{
handle: user.handle,
did: expectedDid,
nodeDomain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
updatedAt: new Date().toISOString(),
}]);
}
}
return user;
}
+69 -21
View File
@@ -10,18 +10,36 @@
import { db } from '@/db';
import { users, signedActionDedupe } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { eq, lt } from 'drizzle-orm';
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
import crypto from 'crypto';
import { isRateLimited } from '@/lib/rate-limit';
// Use Node's webcrypto for server-side if not global
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
const cryptoSubtle = globalThis.crypto?.subtle || crypto.webcrypto.subtle;
const DEDUPE_RETENTION_MS = 10 * 60 * 1000;
const DEDUPE_CLEANUP_INTERVAL_MS = 60 * 1000;
let nextDedupeCleanupAt = 0;
export interface SignedAction {
async function pruneExpiredSignedActions(now: number): Promise<void> {
if (now < nextDedupeCleanupAt) return;
nextDedupeCleanupAt = now + DEDUPE_CLEANUP_INTERVAL_MS;
try {
await db.delete(signedActionDedupe).where(
lt(signedActionDedupe.createdAt, new Date(now - DEDUPE_RETENTION_MS)),
);
} catch (error) {
// Replay verification must not become unavailable because maintenance
// failed; the next process or interval will retry the bounded cleanup.
console.error('[Verify] Signed-action cleanup failed:', error);
}
}
export interface SignedAction<TData = Record<string, string>> {
action: string;
data: any;
data: TData;
did: string;
handle: string;
ts: number;
@@ -39,7 +57,7 @@ export class SignedActionError extends Error {
/**
* Verify a signed action against a specific public key
*/
export async function verifyActionSignature(signedAction: SignedAction, publicKeyStr: string): Promise<boolean> {
export async function verifyActionSignature(signedAction: SignedAction<unknown>, publicKeyStr: string): Promise<boolean> {
try {
const { sig, ...payload } = signedAction;
const canonicalString = canonicalize(payload);
@@ -74,7 +92,7 @@ export async function verifyActionSignature(signedAction: SignedAction, publicKe
* @param signedAction - The signed action payload
* @returns The user if signature is valid and not replayed
*/
export async function verifyUserAction(signedAction: SignedAction): Promise<{
export async function verifyUserAction(signedAction: SignedAction<unknown>): Promise<{
valid: boolean;
user?: typeof users.$inferSelect;
error?: string;
@@ -83,15 +101,16 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
return { valid: false, error: 'Database not available' };
}
const { sig, ...payload } = signedAction;
const payload = {
action: signedAction.action,
data: signedAction.data,
did: signedAction.did,
handle: signedAction.handle,
nonce: signedAction.nonce,
ts: signedAction.ts,
};
// 1. RATE LIMIT CHECK (Fail fast before heavy operations)
// 5 requests per minute per DID
if (isRateLimited(payload.did, 5, 60 * 1000)) {
return { valid: false, error: 'RATE_LIMITED' };
}
// 2. FRESHNESS CHECK (Fail fast before DB/Crypto)
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
const now = Date.now();
const diff = Math.abs(now - payload.ts);
// Allow 5 minutes clock skew
@@ -101,7 +120,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
}
// 3. FETCH USER & KEY
// 2. FETCH USER & KEY
const user = await db.query.users.findFirst({
where: { did: payload.did },
});
@@ -114,18 +133,43 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
return { valid: false, error: 'Handle mismatch' };
}
// 4. CRYPTOGRAPHIC VERIFICATION
// 3. CRYPTOGRAPHIC VERIFICATION
const isValid = await verifyActionSignature(signedAction, user.publicKey);
if (!isValid) {
return { valid: false, error: 'INVALID_SIGNATURE' };
}
// 5. ACTION ID HASH COMPUTATION
await pruneExpiredSignedActions(now);
// 4. ACTION ID HASH COMPUTATION
const canonicalString = canonicalize(payload);
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
// 6. REPLAY PROTECTION (DB)
// 5. EXISTING REPLAY CHECK. Avoid charging the authenticated account bucket
// for an action already recorded in durable replay storage. The unique
// insert below remains the authoritative guard for concurrent requests.
const existingReplay = await db
.select({ actionId: signedActionDedupe.actionId })
.from(signedActionDedupe)
.where(eq(signedActionDedupe.actionId, actionIdHash))
.limit(1);
if (existingReplay.length > 0) {
return { valid: false, error: 'REPLAYED_NONCE' };
}
// 6. AUTHENTICATED RATE LIMIT. Charge quota before creating durable replay
// state so a fresh, unique action rejected for excess volume leaves no row.
// Invalid signatures and attacker-controlled public DIDs never create
// per-account limiter entries or consume quota.
const requestsPerMinute = payload.action === 'chat_e2ee' ? 120 : 5;
if (isRateLimited(`${user.id}:${payload.action}`, requestsPerMinute, 60 * 1000)) {
return { valid: false, error: 'RATE_LIMITED' };
}
// 7. AUTHORITATIVE REPLAY INSERT. Another request can race the read above,
// so rely on the primary-key constraint to reject concurrent duplicates.
try {
await db.insert(signedActionDedupe).values({
actionId: actionIdHash,
@@ -133,9 +177,13 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
nonce: payload.nonce,
ts: payload.ts,
});
} catch (err: any) {
} catch (err: unknown) {
// Check for unique constraint violation (duplicate key)
if (err.code === '23505') { // Postgres unique_violation code
const errorCode = typeof err === 'object' && err !== null && 'code' in err
? (err as { code?: unknown }).code
: undefined;
const errorMessage = err instanceof Error ? err.message : '';
if (errorCode === '23505' || /unique|constraint/i.test(errorMessage)) {
return { valid: false, error: 'REPLAYED_NONCE' };
}
console.error('[Verify] Dedupe error:', err);
@@ -149,7 +197,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
* Middleware to require a signed action
* Throws an error if signature is invalid
*/
export async function requireSignedAction(signedAction: SignedAction): Promise<typeof users.$inferSelect> {
export async function requireSignedAction(signedAction: SignedAction<unknown>): Promise<typeof users.$inferSelect> {
const result = await verifyUserAction(signedAction);
if (!result.valid) {