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:
+22
-13
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user