From 76ce440ae921c37b0e45ebde33d0181ce9d80b9f Mon Sep 17 00:00:00 2001 From: Mateusz Russak Date: Tue, 16 Jun 2026 13:01:34 +0200 Subject: [PATCH 1/4] fix(session): make auth sessions persistent across restarts (#298) --- CLAUDE.md | 1 + deno/server/core/session/constants.ts | 1 + deno/server/core/session/create.ts | 4 ++- deno/server/infra/repo/sessionRepo.ts | 17 +++++++-- deno/server/inter/http/cookies.ts | 12 +++++++ .../routes/auth/__tests__/session.test.ts | 28 +++++++-------- .../inter/http/routes/auth/getSession.ts | 19 ++++++---- .../inter/http/routes/auth/postSession.ts | 6 ++-- ...000_sessions_add_expiry_and_token_index.ts | 36 +++++++++++++++++++ 9 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 deno/server/core/session/constants.ts create mode 100644 deno/server/inter/http/cookies.ts create mode 100644 migrations/20260616120000_sessions_add_expiry_and_token_index.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9303a7816..c94fb1452 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ PBKDF2 key derivation → ECDH key exchange (P-256) → AES-GCM message encrypti - **File naming**: Backend `camelCase.ts`, components `PascalCase.tsx`, hooks `useXxx.ts`, models `camelCase.ts`, tests `__tests__/name.test.ts` - **Backend barrels**: `mod.ts`; **Frontend**: no barrels (direct imports) - **Import order**: external libs → types → core/models → components (atoms→molecules→organisms) → utils +- **No code comments**: do not add explanatory comments; code should be self-documenting through clear naming - Never mention AI usage in code or documentation - Do not add AI co-author lines to commits diff --git a/deno/server/core/session/constants.ts b/deno/server/core/session/constants.ts new file mode 100644 index 000000000..215df4fad --- /dev/null +++ b/deno/server/core/session/constants.ts @@ -0,0 +1 @@ +export const SESSION_TTL_SECONDS = 60 * 60 * 24 * 60; diff --git a/deno/server/core/session/create.ts b/deno/server/core/session/create.ts index e2aea9df4..ed4a5b650 100644 --- a/deno/server/core/session/create.ts +++ b/deno/server/core/session/create.ts @@ -3,6 +3,7 @@ import * as argon2 from "argon2"; import { createCommand } from "../command.ts"; import * as enc from "@quack/encryption"; import { PasswordResetRequired } from "../errors.ts"; +import { SESSION_TTL_SECONDS } from "./constants.ts"; export default createCommand({ type: "session:create", @@ -23,6 +24,7 @@ export default createCommand({ } if (!await argon2.verify(user.secrets.password.hash, password)) return null; - const sessionId = await repo.session.create({ userId: user.id }); + const expires = new Date(Date.now() + SESSION_TTL_SECONDS * 1000); + const sessionId = await repo.session.create({ userId: user.id, expires }); return sessionId; }); diff --git a/deno/server/infra/repo/sessionRepo.ts b/deno/server/infra/repo/sessionRepo.ts index 28c6e2fdb..b1e0fd5ae 100644 --- a/deno/server/infra/repo/sessionRepo.ts +++ b/deno/server/infra/repo/sessionRepo.ts @@ -10,20 +10,31 @@ export class SessionRepo { } #generateToken() { - return Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); + return Array.from( + crypto.getRandomValues(new Uint8Array(32)), + (b) => b.toString(16).padStart(2, "0"), + ).join(""); } - async create(data: { userId: EntityId }): Promise { + async create(data: { userId: EntityId; expires: Date }): Promise { const { db } = await this.connect(); const newSession = serialize({ userId: data.userId, token: this.#generateToken(), + expires: data.expires, }); const ret = await db.collection("sessions").insertOne(newSession); return deserialize(ret.insertedId); } + async refresh(id: EntityId, expires: Date): Promise { + const { db } = await this.connect(); + await db.collection("sessions").updateOne( + serialize({ id }), + { $set: { expires } }, + ); + } + async remove(data: { id?: EntityId }): Promise { const { db } = await this.connect(); const { id } = data; diff --git a/deno/server/inter/http/cookies.ts b/deno/server/inter/http/cookies.ts new file mode 100644 index 000000000..9682867a9 --- /dev/null +++ b/deno/server/inter/http/cookies.ts @@ -0,0 +1,12 @@ +import type { Core } from "../../core/mod.ts"; +import { SESSION_TTL_SECONDS } from "../../core/session/constants.ts"; + +export function authCookieOptions(core: Core) { + return { + httpOnly: true, + path: "/", + maxAge: SESSION_TTL_SECONDS, + sameSite: "Lax" as const, + secure: core.config.baseUrl?.startsWith("https") ?? false, + }; +} diff --git a/deno/server/inter/http/routes/auth/__tests__/session.test.ts b/deno/server/inter/http/routes/auth/__tests__/session.test.ts index 1cc6c0163..f842f8aea 100644 --- a/deno/server/inter/http/routes/auth/__tests__/session.test.ts +++ b/deno/server/inter/http/routes/auth/__tests__/session.test.ts @@ -50,15 +50,13 @@ Deno.test("Login/logout", async (t) => { token = body.token; userId = body.userId; key = credentials.login.key; - assert( - res.headers.get("Set-Cookie")?.includes(`key=${credentials.login.key}`), - ); - assert( - /^token=.+; HttpOnly; Path=\/$/.test( - res.headers.get("Set-Cookie")?.toString() ?? "", - ), - "Set-Cookie header is not correct", - ); + const setCookie = res.headers.get("Set-Cookie")?.toString() ?? ""; + assert(setCookie.includes(`key=${credentials.login.key}`)); + assert(/token=[^;]+/.test(setCookie), "token cookie is set"); + assert(setCookie.includes("HttpOnly"), "token cookie is HttpOnly"); + assert(setCookie.includes("Path=/"), "token cookie has Path=/"); + assert(setCookie.includes("Max-Age="), "token cookie is persistent"); + assert(setCookie.includes("SameSite=Lax"), "token cookie is SameSite=Lax"); }); await t.step("GET /auth/session - Get session with bearer", async () => { @@ -109,12 +107,12 @@ Deno.test("Login/logout - cookies", async (t) => { assert(body.id); token = body.token; userId = body.userId; - assert( - /^token=.+; HttpOnly; Path=\/$/.test( - res.headers.get("Set-Cookie")?.toString() ?? "", - ), - "Set-Cookie header is not correct", - ); + const setCookie = res.headers.get("Set-Cookie")?.toString() ?? ""; + assert(/token=[^;]+/.test(setCookie), "token cookie is set"); + assert(setCookie.includes("HttpOnly"), "token cookie is HttpOnly"); + assert(setCookie.includes("Path=/"), "token cookie has Path=/"); + assert(setCookie.includes("Max-Age="), "token cookie is persistent"); + assert(setCookie.includes("SameSite=Lax"), "token cookie is SameSite=Lax"); }); await t.step("GET /auth/session - Get session with cookie", async () => { diff --git a/deno/server/inter/http/routes/auth/getSession.ts b/deno/server/inter/http/routes/auth/getSession.ts index 28804e741..1987cc618 100644 --- a/deno/server/inter/http/routes/auth/getSession.ts +++ b/deno/server/inter/http/routes/auth/getSession.ts @@ -1,23 +1,30 @@ import { Res, Route } from "@planigale/planigale"; import { Core } from "../../../../core/mod.ts"; +import { authCookieOptions } from "../../cookies.ts"; +import { SESSION_TTL_SECONDS } from "../../../../core/session/constants.ts"; -export default (_core: Core) => +export default (core: Core) => new Route({ public: true, method: "GET", url: "/session", - handler: (req) => { + handler: async (req) => { if (req.state.session) { + const expires = new Date(Date.now() + SESSION_TTL_SECONDS * 1000); + await core.repo.session.refresh(req.state.session.id, expires); + + const cookieOpts = authCookieOptions(core); const res = Res.json({ ...req.state.session, user: req.state.user.id, // FIXME: remove status: "ok", // FIXME: remove key: req.cookies.get("key"), }); - res.cookies.set("token", req.state.session.token, { - httpOnly: true, - path: "/", - }); + res.cookies.set("token", req.state.session.token, cookieOpts); + const key = req.cookies.get("key"); + if (key) { + res.cookies.set("key", key, cookieOpts); + } return res; } const res = Res.json({ status: "no-session" }); diff --git a/deno/server/inter/http/routes/auth/postSession.ts b/deno/server/inter/http/routes/auth/postSession.ts index dbab5f178..6bf5f5e27 100644 --- a/deno/server/inter/http/routes/auth/postSession.ts +++ b/deno/server/inter/http/routes/auth/postSession.ts @@ -1,6 +1,7 @@ import { Res, Route } from "@planigale/planigale"; import { AccessDenied } from "../../errors.ts"; import { Core } from "../../../../core/mod.ts"; +import { authCookieOptions } from "../../cookies.ts"; export default (core: Core) => new Route({ @@ -39,8 +40,9 @@ export default (core: Core) => throw new AccessDenied("Invalid login or password"); } const res = Res.json({ status: "ok", ...session, key: req.body.key }); - res.cookies.set("token", session.token, { httpOnly: true, path: "/" }); - res.cookies.set("key", req.body.key, { httpOnly: true, path: "/" }); + const cookieOpts = authCookieOptions(core); + res.cookies.set("token", session.token, cookieOpts); + res.cookies.set("key", req.body.key, cookieOpts); return res; }, }); diff --git a/migrations/20260616120000_sessions_add_expiry_and_token_index.ts b/migrations/20260616120000_sessions_add_expiry_and_token_index.ts new file mode 100644 index 000000000..08dc61c40 --- /dev/null +++ b/migrations/20260616120000_sessions_add_expiry_and_token_index.ts @@ -0,0 +1,36 @@ +import { Db, ObjectId } from "mongodb"; + +const SESSION_TTL_MS = 60 * 60 * 24 * 60 * 1000; + +export const up = async (db: Db) => { + await db.collection("sessions").updateMany( + { expires: { $exists: false } }, + [{ $set: { expires: { $add: ["$$NOW", SESSION_TTL_MS] } } }], + ); + + const duplicates: ObjectId[] = []; + const seen = new Set(); + const cursor = db.collection("sessions").find({}); + for await (const session of cursor) { + if (typeof session.token !== "string") continue; + if (seen.has(session.token)) { + duplicates.push(session._id); + } else { + seen.add(session.token); + } + } + if (duplicates.length) { + await db.collection("sessions").deleteMany({ _id: { $in: duplicates } }); + } + + await db.collection("sessions").createIndex( + { expires: 1 }, + { expireAfterSeconds: 0 }, + ); + await db.collection("sessions").createIndex({ token: 1 }, { unique: true }); +}; + +export const down = async (db: Db) => { + await db.collection("sessions").dropIndex("expires_1"); + await db.collection("sessions").dropIndex("token_1"); +}; From f08be2b5ab681714ff3a19cee9e831ca967c64ca Mon Sep 17 00:00:00 2001 From: Mateusz Russak Date: Tue, 16 Jun 2026 13:50:45 +0200 Subject: [PATCH 2/4] feat(auth): store private key non-extractable in IndexedDB, drop split-key (#299) --- app/src/js/core/models/channel.ts | 3 +- app/src/js/core/tools/messageEncryption.ts | 5 +- deno/api/auth.ts | 76 ++++++++++------ deno/api/cryptoStore.ts | 89 +++++++++++++++++++ deno/api/mod.ts | 4 +- deno/api/types.ts | 1 - deno/encryption/mod.ts | 44 ++++----- .../routes/auth/__tests__/session.test.ts | 5 -- .../inter/http/routes/auth/deleteSession.ts | 1 + .../inter/http/routes/auth/getSession.ts | 9 +- .../inter/http/routes/auth/postSession.ts | 4 +- 11 files changed, 166 insertions(+), 75 deletions(-) create mode 100644 deno/api/cryptoStore.ts diff --git a/app/src/js/core/models/channel.ts b/app/src/js/core/models/channel.ts index a011546c2..95867f219 100644 --- a/app/src/js/core/models/channel.ts +++ b/app/src/js/core/models/channel.ts @@ -1,4 +1,3 @@ -/* global JsonWebKey */ import { flow, makeAutoObservable } from "mobx"; import { Channel } from "../../types.ts"; import type { AppModel } from "./app.ts"; @@ -17,7 +16,7 @@ export class ChannelModel { channelType: "DIRECT" | "PRIVATE" | "PUBLIC"; root: AppModel; - channelKey: JsonWebKey | null = null; + channelKey: CryptoKey | null = null; constructor(value: Channel, root: AppModel) { makeAutoObservable(this, { root: false }); diff --git a/app/src/js/core/tools/messageEncryption.ts b/app/src/js/core/tools/messageEncryption.ts index a9c8822b1..4aa4abdea 100644 --- a/app/src/js/core/tools/messageEncryption.ts +++ b/app/src/js/core/tools/messageEncryption.ts @@ -1,4 +1,3 @@ -/* global JsonWebKey */ import * as enc from "@quack/encryption"; import { BaseMessage, FullMessage, Message, MessageData } from "../client.ts"; import { EncryptedData, EncryptedMessage } from "../../types.ts"; @@ -8,7 +7,7 @@ type Messages = Message | Message[]; export class MessageEncryption { static decrypt = async ( msg: Messages, - encryptionKey?: JsonWebKey | null, + encryptionKey?: CryptoKey | null, ): Promise => { try { if (!encryptionKey) { @@ -40,7 +39,7 @@ export class MessageEncryption { static encrypt = async ( msg: FullMessage, - encryptionKey: JsonWebKey, + encryptionKey: CryptoKey, ): Promise> => { const { clientId, channelId, parentId, ...data } = msg; if (!encryptionKey) { diff --git a/deno/api/auth.ts b/deno/api/auth.ts index e5731dc52..648524fd1 100644 --- a/deno/api/auth.ts +++ b/deno/api/auth.ts @@ -6,6 +6,12 @@ import type { } from "./types.ts"; import * as enc from "@quack/encryption"; import type API from "./mod.ts"; +import { + clearSessionKeys, + loadSessionKeys, + saveSessionKeys, + type SessionKeys, +} from "./cryptoStore.ts"; export class ApiError extends Error { payload: Record; @@ -54,7 +60,6 @@ class AuthAPI extends EventTarget { { email, password }: { email: string; password: string }, ): Promise> { const credentials = await enc.prepareCredentials(email, password); - localStorage.setItem("key", credentials.key); const ret = await this.api.fetchWithCredentials("/api/auth/session", { method: "POST", body: JSON.stringify(credentials.login), @@ -64,53 +69,66 @@ class AuthAPI extends EventTarget { return { status: "error", ...error }; } const session: UserSession = await ret.json(); - await this.validateSession(session); + await this.activateSession(session, credentials.encryptionKey); return session; } async restoreSession(): Promise> { - const key = localStorage.getItem("key"); - if (!key) return { status: "error" }; + const keys = await loadSessionKeys(); + if (!keys) return { status: "error" }; const ret = await this.api.fetchWithCredentials("/api/auth/session"); const session = await ret.json(); - if (!await this.validateSession(session)) { - localStorage.removeItem("token"); - localStorage.removeItem("userId"); - localStorage.removeItem("key"); + if (session.status !== "ok") { + await this.clear(); + return session; } + this.applyKeys(session, keys); return session; } - async validateSession(session: UserSession): Promise { + async activateSession( + session: UserSession, + encryptionKey: JsonWebKey, + ): Promise { try { - const key = localStorage.getItem("key"); - if (!key) return false; - if (session.status === "ok") { - localStorage.setItem("userId", session.userId); - this.api.token = session.token; - localStorage.setItem("token", session.token); - const encryptionKey = enc.joinJSON([key, session.key]); - const secrets: UserSessionSecrets = await enc.decrypt( - session.secrets, - encryptionKey, - ); - if (secrets.sanityCheck !== "valid") return false; - this.api.userEncryptionKey = secrets.encryptionKey; - this.api.privateKey = secrets.privateKey; - this.api.publicKey = session.publicKey; - return true; + if (session.status !== "ok") return false; + const secrets: UserSessionSecrets = await enc.decrypt( + session.secrets, + encryptionKey, + ); + if (secrets.sanityCheck !== "valid") return false; + const keys: SessionKeys = { + privateKey: await enc.importPrivateKey(secrets.privateKey), + }; + this.applyKeys(session, keys); + try { + await saveSessionKeys(keys); + } catch (e) { + console.warn("Could not persist session keys", e); } - return false; + return true; } catch (e) { - console.error("Error validating session", e); + console.error("Error activating session", e); return false; } } - async logout() { - localStorage.removeItem("key"); + applyKeys(session: UserSession, keys: SessionKeys) { + localStorage.setItem("userId", session.userId); + this.api.token = session.token; + localStorage.setItem("token", session.token); + this.api.privateKey = keys.privateKey; + this.api.publicKey = session.publicKey; + } + + async clear() { localStorage.removeItem("token"); localStorage.removeItem("userId"); + await clearSessionKeys(); + } + + async logout() { + await this.clear(); const ret = await this.api.fetchWithCredentials("/api/auth/session", { method: "DELETE", body: "{}", diff --git a/deno/api/cryptoStore.ts b/deno/api/cryptoStore.ts new file mode 100644 index 000000000..a3700529b --- /dev/null +++ b/deno/api/cryptoStore.ts @@ -0,0 +1,89 @@ +// deno-lint-ignore-file no-explicit-any + +const DB_NAME = "quack"; +const STORE = "keys"; +const RECORD_ID = "session"; + +export type SessionKeys = { + privateKey: CryptoKey; +}; + +function hasIndexedDB(): boolean { + return !!(globalThis as any).indexedDB; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const idb = (globalThis as any).indexedDB; + if (!idb) { + reject(new Error("IndexedDB not available")); + return; + } + const req = idb.open(DB_NAME, 1); + req.onupgradeneeded = (e: any) => { + const db = e.target.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE); + } + }; + req.onsuccess = (e: any) => resolve(e.target.result); + req.onerror = (e: any) => reject(e.target.error); + }); +} + +export async function saveSessionKeys(keys: SessionKeys): Promise { + if (!hasIndexedDB()) return; + const db = await openDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(keys, RECORD_ID); + tx.oncomplete = () => resolve(); + tx.onerror = (e: any) => reject(e.target.error); + }); + } finally { + db.close(); + } +} + +export async function loadSessionKeys(): Promise { + let db: any; + try { + db = await openDb(); + } catch { + return null; + } + try { + const result = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const req = tx.objectStore(STORE).get(RECORD_ID); + req.onsuccess = (e: any) => resolve(e.target.result ?? null); + req.onerror = (e: any) => reject(e.target.error); + }); + if (result && result.privateKey instanceof CryptoKey) { + return result as SessionKeys; + } + return null; + } finally { + db.close(); + } +} + +export async function clearSessionKeys(): Promise { + let db: any; + try { + db = await openDb(); + } catch { + return; + } + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(RECORD_ID); + tx.oncomplete = () => resolve(); + tx.onerror = (e: any) => reject(e.target.error); + }); + } finally { + db.close(); + } +} diff --git a/deno/api/mod.ts b/deno/api/mod.ts index 85c040c86..31718703e 100644 --- a/deno/api/mod.ts +++ b/deno/api/mod.ts @@ -75,9 +75,7 @@ class API extends EventTarget { abortController: AbortController; - userEncryptionKey: JsonWebKey | null = null; - - privateKey: JsonWebKey | null = null; + privateKey: CryptoKey | null = null; publicKey: JsonWebKey | null = null; diff --git a/deno/api/types.ts b/deno/api/types.ts index 525c90f34..640ab8e9b 100644 --- a/deno/api/types.ts +++ b/deno/api/types.ts @@ -107,7 +107,6 @@ export type UserSession = { userId: string; publicKey: JsonWebKey; secrets: EncryptedData; - key: string; }; export type LoginError = { diff --git a/deno/encryption/mod.ts b/deno/encryption/mod.ts index f323e4c7c..b4792fdba 100644 --- a/deno/encryption/mod.ts +++ b/deno/encryption/mod.ts @@ -29,17 +29,14 @@ function importKey(key: JsonWebKey | CryptoKey): Promise { ]); } -export function encryptor(jwk: JsonWebKey) { - const key = crypto.subtle.importKey("jwk", jwk, { name: "AES-GCM" }, false, [ - "encrypt", - "decrypt", - ]); +export function encryptor(key: JsonWebKey | CryptoKey) { + const keyPromise = importKey(key); return { encrypt: async (message: unknown) => { const iv = crypto.getRandomValues(new Uint8Array(12)); // GCM uses 12 bytes IV const encoded = new TextEncoder().encode(JSON.stringify(message)); - const keyy = await key; + const keyy = await keyPromise; const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, keyy, @@ -56,7 +53,7 @@ export function encryptor(jwk: JsonWebKey) { const iv = fromBase64(data._iv); const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, - await key, + await keyPromise, ciphertext, ); const decoder = new TextDecoder(); @@ -200,8 +197,7 @@ export async function generatePasswordKeys( export async function prepareCredentials(email: string, password: string) { const salt = await deriveSaltFromEmail(email); const { hash, encryptionKey } = await generatePasswordKeys(password, salt); - const keys = splitJSON(encryptionKey); - return { login: { email, password: hash, key: keys[0] }, key: keys[1] }; + return { login: { email, password: hash }, encryptionKey }; } export async function generateKey() { @@ -302,16 +298,9 @@ export async function decryptSessionSecrets( } export async function deriveSharedKey( - privateKey: JsonWebKey, + privateKey: CryptoKey, otherPublicKey: JsonWebKey, -): Promise { - const privKey = await crypto.subtle.importKey( - "jwk", - privateKey, - { name: "ECDH", namedCurve: "P-256" }, - true, - ["deriveKey"], - ); +): Promise { const pubKey = await crypto.subtle.importKey( "jwk", otherPublicKey, @@ -320,19 +309,30 @@ export async function deriveSharedKey( [], ); - const sharedKey = await crypto.subtle.deriveKey( + return await crypto.subtle.deriveKey( { name: "ECDH", public: pubKey, }, - privKey, + privateKey, { name: "AES-GCM", length: 256, }, - true, + false, ["encrypt", "decrypt"], ); +} - return await crypto.subtle.exportKey("jwk", sharedKey); +export function importPrivateKey( + jwk: JsonWebKey, + extractable = false, +): Promise { + return crypto.subtle.importKey( + "jwk", + jwk, + { name: "ECDH", namedCurve: "P-256" }, + extractable, + ["deriveKey", "deriveBits"], + ); } diff --git a/deno/server/inter/http/routes/auth/__tests__/session.test.ts b/deno/server/inter/http/routes/auth/__tests__/session.test.ts index f842f8aea..f35d8f23c 100644 --- a/deno/server/inter/http/routes/auth/__tests__/session.test.ts +++ b/deno/server/inter/http/routes/auth/__tests__/session.test.ts @@ -34,7 +34,6 @@ Deno.test("POST /auth/session - wrong params", async () => { Deno.test("Login/logout", async (t) => { let token: string | null = null; let userId: string | null = null; - let key: string | null = null; await ensureUser(repo, "admin"); await t.step("POST /auth/session - Create session", async () => { @@ -49,9 +48,7 @@ Deno.test("Login/logout", async (t) => { assert(body.id); token = body.token; userId = body.userId; - key = credentials.login.key; const setCookie = res.headers.get("Set-Cookie")?.toString() ?? ""; - assert(setCookie.includes(`key=${credentials.login.key}`)); assert(/token=[^;]+/.test(setCookie), "token cookie is set"); assert(setCookie.includes("HttpOnly"), "token cookie is HttpOnly"); assert(setCookie.includes("Path=/"), "token cookie has Path=/"); @@ -62,13 +59,11 @@ Deno.test("Login/logout", async (t) => { await t.step("GET /auth/session - Get session with bearer", async () => { const res = await Agent.request(app) .get("/api/auth/session") - .header("Cookie", `key=${key}`) .header("Authorization", `Bearer ${token}`) .expect(200); const body = await res.json(); assertEquals(body.userId, userId); assertEquals(body.token, token); - assertEquals(body.key, key); }); await t.step("DELETE /auth/session", async () => { diff --git a/deno/server/inter/http/routes/auth/deleteSession.ts b/deno/server/inter/http/routes/auth/deleteSession.ts index 84c8325ea..07dc6e259 100644 --- a/deno/server/inter/http/routes/auth/deleteSession.ts +++ b/deno/server/inter/http/routes/auth/deleteSession.ts @@ -16,6 +16,7 @@ export default (core: Core) => } const res = Res.empty(); res.cookies.delete("token", { path: "/" }); + res.cookies.delete("key", { path: "/" }); return res; }, }); diff --git a/deno/server/inter/http/routes/auth/getSession.ts b/deno/server/inter/http/routes/auth/getSession.ts index 1987cc618..c042d7c61 100644 --- a/deno/server/inter/http/routes/auth/getSession.ts +++ b/deno/server/inter/http/routes/auth/getSession.ts @@ -16,15 +16,10 @@ export default (core: Core) => const cookieOpts = authCookieOptions(core); const res = Res.json({ ...req.state.session, - user: req.state.user.id, // FIXME: remove - status: "ok", // FIXME: remove - key: req.cookies.get("key"), + user: req.state.user.id, + status: "ok", }); res.cookies.set("token", req.state.session.token, cookieOpts); - const key = req.cookies.get("key"); - if (key) { - res.cookies.set("key", key, cookieOpts); - } return res; } const res = Res.json({ status: "no-session" }); diff --git a/deno/server/inter/http/routes/auth/postSession.ts b/deno/server/inter/http/routes/auth/postSession.ts index 6bf5f5e27..e2bb44f7f 100644 --- a/deno/server/inter/http/routes/auth/postSession.ts +++ b/deno/server/inter/http/routes/auth/postSession.ts @@ -15,7 +15,6 @@ export default (core: Core) => properties: { email: { type: "string" }, password: { type: "string" }, - key: { type: "string" }, }, }, }, @@ -39,10 +38,9 @@ export default (core: Core) => if (!session) { throw new AccessDenied("Invalid login or password"); } - const res = Res.json({ status: "ok", ...session, key: req.body.key }); + const res = Res.json({ status: "ok", ...session }); const cookieOpts = authCookieOptions(core); res.cookies.set("token", session.token, cookieOpts); - res.cookies.set("key", req.body.key, cookieOpts); return res; }, }); From 0a6123cc5e42a127e9116e8e3dd23f9b22aba1bf Mon Sep 17 00:00:00 2001 From: Mateusz Russak Date: Tue, 16 Jun 2026 15:27:11 +0200 Subject: [PATCH 3/4] fix(auth): stop sending the encryption key to the server on login (#300) --- deno.jsonc | 3 +- deno/encryption/mod.ts | 13 +++++-- deno/server/core/session/create.ts | 18 ++++++++-- deno/server/core/user/create.ts | 1 + .../inter/http/routes/__tests__/users.ts | 1 + .../routes/auth/__tests__/session.test.ts | 36 +++++++++++++++++++ .../inter/http/routes/auth/postSession.ts | 2 ++ deno/server/types.ts | 9 ++++- deno/tools/auth-migration-status.ts | 25 +++++++++++++ 9 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 deno/tools/auth-migration-status.ts diff --git a/deno.jsonc b/deno.jsonc index 14eb40968..c6939d988 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -65,7 +65,8 @@ "test": "DATABASE_URL='mongodb://chat:chat@localhost:27017/tests?authSource=admin' deno test -A", "ssl": "deno run -A deno/tools/generate-ssl.ts", "check": "deno fmt && deno lint && deno task test", - "mobile:config": "deno run -A deno/tools/export-mobile-config.ts" + "mobile:config": "deno run -A deno/tools/export-mobile-config.ts", + "auth:status": "deno run -A deno/tools/auth-migration-status.ts" }, "compilerOptions": { "jsx": "react", diff --git a/deno/encryption/mod.ts b/deno/encryption/mod.ts index b4792fdba..f36d1948b 100644 --- a/deno/encryption/mod.ts +++ b/deno/encryption/mod.ts @@ -181,12 +181,20 @@ export async function hashPassword(password: string, salt: string) { return keyBase64; } +async function deriveAuthSalt(salt: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${salt}::auth`), + ); + return toBase64(digest); +} + export async function generatePasswordKeys( password: string, salt: string, ): Promise<{ hash: string; encryptionKey: JsonWebKey }> { return { - hash: await hashPassword(password, salt), + hash: await hashPassword(password, await deriveAuthSalt(salt)), encryptionKey: await deriveEncryptionKeyFromPassword( password, salt, @@ -197,7 +205,8 @@ export async function generatePasswordKeys( export async function prepareCredentials(email: string, password: string) { const salt = await deriveSaltFromEmail(email); const { hash, encryptionKey } = await generatePasswordKeys(password, salt); - return { login: { email, password: hash }, encryptionKey }; + const legacyPassword = await hashPassword(password, salt); + return { login: { email, password: hash, legacyPassword }, encryptionKey }; } export async function generateKey() { diff --git a/deno/server/core/session/create.ts b/deno/server/core/session/create.ts index ed4a5b650..55338d33d 100644 --- a/deno/server/core/session/create.ts +++ b/deno/server/core/session/create.ts @@ -10,8 +10,9 @@ export default createCommand({ body: v.object({ email: v.string(), password: v.string(), + legacyPassword: v.optional(v.string()), }), -}, async ({ email, password }, { repo }) => { +}, async ({ email, password, legacyPassword }, { repo }) => { const user = await repo.user.get({ email }); if (!user) return null; if (user.password) { @@ -23,7 +24,20 @@ export default createCommand({ ); } - if (!await argon2.verify(user.secrets.password.hash, password)) return null; + const storedHash = user.secrets.password.hash; + if (!await argon2.verify(storedHash, password)) { + if ( + !legacyPassword || !await argon2.verify(storedHash, legacyPassword) + ) { + return null; + } + await repo.user.updateCredentials({ email }, "password", { + ...user.secrets.password, + hash: await argon2.hash(password), + kdf: "v2", + }); + } + const expires = new Date(Date.now() + SESSION_TTL_SECONDS * 1000); const sessionId = await repo.session.create({ userId: user.id, expires }); return sessionId; diff --git a/deno/server/core/user/create.ts b/deno/server/core/user/create.ts index 0e1763ccd..78606d9d3 100644 --- a/deno/server/core/user/create.ts +++ b/deno/server/core/user/create.ts @@ -48,6 +48,7 @@ export default createCommand({ hash: await hash(password), data: secrets, createdAt: new Date(), + kdf: "v2", }, }, mainChannelId: invitation.channelId, diff --git a/deno/server/inter/http/routes/__tests__/users.ts b/deno/server/inter/http/routes/__tests__/users.ts index 26a4d89f4..ae331260c 100644 --- a/deno/server/inter/http/routes/__tests__/users.ts +++ b/deno/server/inter/http/routes/__tests__/users.ts @@ -21,6 +21,7 @@ export const ensureUser = async ( hash: await hash(data.password), data: data.secrets, createdAt: new Date(), + kdf: "v2", }, }, ...rest, diff --git a/deno/server/inter/http/routes/auth/__tests__/session.test.ts b/deno/server/inter/http/routes/auth/__tests__/session.test.ts index f35d8f23c..ba89e9c69 100644 --- a/deno/server/inter/http/routes/auth/__tests__/session.test.ts +++ b/deno/server/inter/http/routes/auth/__tests__/session.test.ts @@ -1,5 +1,6 @@ import { assert, assertEquals } from "@std/assert"; import { Agent } from "@planigale/testing"; +import { hash as argonHash } from "argon2"; import { createApp } from "../../__tests__/app.ts"; import { ensureUser } from "../../__tests__/users.ts"; import * as enc from "@quack/encryption"; @@ -139,3 +140,38 @@ Deno.test("Login/logout - cookies", async (t) => { core.close(); }); + +Deno.test("Login - migrates legacy auth hash", async (t) => { + const email = "legacy-user"; + await ensureUser(repo, email); + const creds = await enc.prepareCredentials(email, "123"); + + const user = await repo.user.get({ email }); + assert(user); + await repo.user.updateCredentials({ email }, "password", { + hash: await argonHash(creds.login.legacyPassword), + data: user.secrets.password.data, + createdAt: user.secrets.password.createdAt, + }); + + await t.step( + "legacy login succeeds and migrates the stored credential", + async () => { + await Agent.request(app) + .post("/api/auth/session") + .json(creds.login) + .expect(200); + const migrated = await repo.user.get({ email }); + assertEquals(migrated?.secrets.password.kdf, "v2"); + }, + ); + + await t.step("subsequent login works with the new hash alone", async () => { + await Agent.request(app) + .post("/api/auth/session") + .json({ email, password: creds.login.password }) + .expect(200); + }); + + core.close(); +}); diff --git a/deno/server/inter/http/routes/auth/postSession.ts b/deno/server/inter/http/routes/auth/postSession.ts index e2bb44f7f..61455bfa5 100644 --- a/deno/server/inter/http/routes/auth/postSession.ts +++ b/deno/server/inter/http/routes/auth/postSession.ts @@ -15,6 +15,7 @@ export default (core: Core) => properties: { email: { type: "string" }, password: { type: "string" }, + legacyPassword: { type: "string" }, }, }, }, @@ -29,6 +30,7 @@ export default (core: Core) => body: { email: req.body.email, password: req.body.password, + legacyPassword: req.body.legacyPassword, }, }); if (!sessionId) { diff --git a/deno/server/types.ts b/deno/server/types.ts index 3e4832dad..30e7c5119 100644 --- a/deno/server/types.ts +++ b/deno/server/types.ts @@ -5,9 +5,15 @@ export * from "@quack/api"; export type DbUser = User & { password?: string; resetToken?: string; + system?: boolean; secrets: { - password: { hash: string; data: EncryptedData; createdAt: Date }; + password: { + hash: string; + data: EncryptedData; + createdAt: Date; + kdf?: string; + }; backup?: { hash: string; data: EncryptedData; createdAt: Date }; }; @@ -21,6 +27,7 @@ export type Secret = { _iv: string; }; createdAt: Date; + kdf?: string; }; export type Interaction = { diff --git a/deno/tools/auth-migration-status.ts b/deno/tools/auth-migration-status.ts new file mode 100644 index 000000000..464f1eb87 --- /dev/null +++ b/deno/tools/auth-migration-status.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env -S deno run -A +import config from "@quack/config/load"; +import { Repository } from "../server/infra/mod.ts"; + +const repo = new Repository(config); +const users = await repo.user.getAll({}); +const accounts = users.filter((u) => !u.system); +const unmigrated = accounts.filter((u) => u.secrets?.password?.kdf !== "v2"); +const systemCount = users.length - accounts.length; + +console.log( + `auth kdf migration: ${ + accounts.length - unmigrated.length + }/${accounts.length} migrated` + + (systemCount > 0 ? ` (${systemCount} system accounts excluded)` : ""), +); +if (unmigrated.length > 0) { + console.log("unmigrated accounts:"); + for (const u of unmigrated) { + console.log(` - ${u.email}`); + } +} + +await repo.close(); +Deno.exit(unmigrated.length > 0 ? 1 : 0); From 62a4dde2b4ed7c97585e5db81263cd1f5e8a5288 Mon Sep 17 00:00:00 2001 From: Mateusz Russak Date: Tue, 16 Jun 2026 16:44:09 +0200 Subject: [PATCH 4/4] ci(release): trigger docker build via workflow_dispatch (#297) --- .github/workflows/docker.yml | 4 ++++ .github/workflows/release.yml | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7fd1925b5..ec32e7dee 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -10,6 +10,10 @@ on: required: true type: string workflow_dispatch: + inputs: + version: + required: false + type: string permissions: contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3baf3151..fb2cc08dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - outputs: - version: ${{ steps.version.outputs.result }} + actions: write steps: - name: Extract version from labels @@ -50,9 +49,16 @@ jobs: core.info(`Created release ${version}`); - docker: - needs: release - uses: ./.github/workflows/docker.yml - with: - version: ${{ needs.release.outputs.version }} - secrets: inherit + - name: Trigger Docker image build + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.version.outputs.result }}'; + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'docker.yml', + ref: 'main', + inputs: { version }, + }); + core.info(`Dispatched docker.yml build for ${version}`);