All files / api/routes encryption.ts

88.65% Statements 86/97
83.33% Branches 40/48
100% Functions 12/12
88.42% Lines 84/95

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172                                16x 16x 16x       11x 11x       357x   357x 4x     357x 4x     357x 30x 1x   29x 29x 1x   28x 1x   27x 27x 27x 27x     357x 13x 1x   12x 1x     11x 11x 3x     11x     11x 11x 5x 5x 1x   3x 6x 5x   1x     3x 3x 3x     6x 6x 6x 6x               357x 5x 1x   4x 4x 1x   3x 1x   2x 2x 2x   1x       357x 8x 1x   7x 7x 1x   6x 6x 6x   1x       357x 1x     1x 1x     1x 1x 1x                   357x 1x     1x 1x     357x 2x     2x     357x    
import { Hono } from "hono";
import {
	cancelRecoveryKeyRotation,
	changePassword,
	confirmRecoveryKeyRotation,
	hasPendingRecoveryRotation,
	initializeEncryption,
	prepareRecoveryKeyRotation,
	setPasswordFromVaultKey,
	unlockWithPassword,
	unlockWithRecovery,
} from "../../crypto/keys.js";
import type { ContainerContext } from "../../crypto/lifecycle.js";
import { transitionToUnlocked } from "../../crypto/lifecycle.js";
 
// Progressive rate limiting for failed unlock attempts (ms delays)
const UNLOCK_DELAYS = [0, 1000, 2000, 4000, 8000, 16000, 30000];
let failedUnlockAttempts = 0;
let lastFailedUnlockAt = 0;
 
function getUnlockDelay(): number {
	// Reset counter after 10 minutes of no attempts
	if (Date.now() - lastFailedUnlockAt > 600_000) failedUnlockAttempts = 0;
	return UNLOCK_DELAYS[Math.min(failedUnlockAttempts, UNLOCK_DELAYS.length - 1)];
}
 
export function encryptionRoutes(context: ContainerContext): Hono {
	const api = new Hono();
 
	api.get("/health", (c) => {
		return c.json({ status: "ok", version: "0.1.0" });
	});
 
	api.get("/status", (c) => {
		return c.json({ state: context.state });
	});
 
	api.post("/setup", async (c) => {
		if (context.state !== "setup") {
			return c.json({ error: "Already initialized" }, 409);
		}
		const body = await c.req.json();
		if (!body.password || typeof body.password !== "string") {
			return c.json({ error: "password is required" }, 400);
		}
		if (body.password.length < 12) {
			return c.json({ error: "Password must be at least 12 characters" }, 400);
		}
		const mnemonic = initializeEncryption(context.dataDir, body.password);
		const vaultKey = unlockWithPassword(context.dataDir, body.password);
		transitionToUnlocked(context, vaultKey);
		return c.json({ recoveryMnemonic: mnemonic }, 201);
	});
 
	api.post("/unlock", async (c) => {
		if (context.state === "setup") {
			return c.json({ error: "Not initialized — use /api/setup first" }, 409);
		}
		if (context.state === "unlocked") {
			return c.json({ ok: true, alreadyUnlocked: true });
		}
 
		const delay = getUnlockDelay();
		if (delay > 0) {
			await new Promise((r) => setTimeout(r, delay));
		}
 
		const body = await c.req.json();
 
		let vaultKey: Buffer;
		try {
			if (body.recoveryMnemonic) {
				vaultKey = unlockWithRecovery(context.dataDir, body.recoveryMnemonic);
				if (!body.newPassword || typeof body.newPassword !== "string") {
					return c.json({ error: "newPassword is required when using recovery mnemonic" }, 400);
				}
				setPasswordFromVaultKey(context.dataDir, vaultKey, body.newPassword);
			} else if (body.password) {
				vaultKey = unlockWithPassword(context.dataDir, body.password);
			} else {
				return c.json({ error: "password or recoveryMnemonic is required" }, 400);
			}
		} catch {
			failedUnlockAttempts++;
			lastFailedUnlockAt = Date.now();
			return c.json({ error: "Invalid password or recovery key" }, 401);
		}
 
		failedUnlockAttempts = 0;
		try {
			transitionToUnlocked(context, vaultKey);
			return c.json({ ok: true });
		} catch (e) {
			const msg = e instanceof Error ? e.message : String(e);
			console.error("Unlock succeeded but failed to open database:", msg);
			return c.json({ error: `Unlock succeeded but failed to open database: ${msg}` }, 500);
		}
	});
 
	api.post("/change-password", async (c) => {
		if (context.state !== "unlocked") {
			return c.json({ error: "Container is locked", state: context.state }, 423);
		}
		const body = await c.req.json();
		if (!body.currentPassword || !body.newPassword) {
			return c.json({ error: "currentPassword and newPassword are required" }, 400);
		}
		if (body.newPassword.length < 12) {
			return c.json({ error: "Password must be at least 12 characters" }, 400);
		}
		try {
			changePassword(context.dataDir, body.currentPassword, body.newPassword);
			return c.json({ ok: true });
		} catch {
			return c.json({ error: "Current password is incorrect" }, 401);
		}
	});
 
	api.post("/rotate-recovery-key", async (c) => {
		if (context.state !== "unlocked") {
			return c.json({ error: "Container is locked", state: context.state }, 423);
		}
		const body = await c.req.json();
		if (!body.password) {
			return c.json({ error: "password is required to authorize recovery key rotation" }, 400);
		}
		try {
			const newMnemonic = prepareRecoveryKeyRotation(context.dataDir, body.password);
			return c.json({ recoveryMnemonic: newMnemonic, pending: true });
		} catch {
			return c.json({ error: "Password is incorrect" }, 401);
		}
	});
 
	api.post("/confirm-recovery-rotation", async (c) => {
		Iif (context.state !== "unlocked") {
			return c.json({ error: "Container is locked", state: context.state }, 423);
		}
		const body = await c.req.json();
		Iif (!body.password) {
			return c.json({ error: "password is required to confirm recovery key rotation" }, 400);
		}
		try {
			confirmRecoveryKeyRotation(context.dataDir, body.password);
			return c.json({ ok: true });
		} catch (e) {
			const msg = (e as Error).message;
			if (msg.includes("No pending")) {
				return c.json({ error: msg }, 409);
			}
			return c.json({ error: "Password is incorrect" }, 401);
		}
	});
 
	api.post("/cancel-recovery-rotation", async (c) => {
		Iif (context.state !== "unlocked") {
			return c.json({ error: "Container is locked", state: context.state }, 423);
		}
		cancelRecoveryKeyRotation(context.dataDir);
		return c.json({ ok: true });
	});
 
	api.get("/recovery-rotation-status", (c) => {
		Iif (context.state !== "unlocked") {
			return c.json({ error: "Container is locked", state: context.state }, 423);
		}
		return c.json({ pending: hasPendingRecoveryRotation(context.dataDir) });
	});
 
	return api;
}