From 7de1841f1aac80bb8d655dd4c0ea30e1edb214af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Nov 2026 03:39:05 +0000 Subject: [PATCH 01/11] Add login-name sign-in and device PIN or pattern unlock on top of the merged marketing site. Email sign-in still derives the vault key from the email. A login name works only when prelogin returns that email. PIN and pattern wrap the user key on the device after a master-password unlock, and the public downloads page reads the release manifest without replacing the home-page sign-in. Co-authored-by: Admin --- apps/web-marketing/assets/auth.js | 27 ++- apps/web-marketing/assets/device-unlock.js | 164 ++++++++++++++ apps/web-marketing/assets/vault.js | 10 + apps/web-marketing/build.py | 51 +++-- apps/web-marketing/downloads.html | 8 +- apps/web-marketing/index.html | 2 +- apps/web-marketing/komboe-downloads.json | 167 ++++++++++++++ apps/web-marketing/llms.txt | 2 +- apps/web-marketing/vault/index.html | 2 +- .../apps/web/src/app/app.component.ts | 1 + .../auth/komboe-device-access.component.ts | 187 +++++++++++++++ .../web/src/app/auth/komboe-device-unlock.mjs | 164 ++++++++++++++ .../app/layouts/user-layout.component.html | 2 + .../apps/web/src/app/oss-routing.module.ts | 13 +- .../src/angular/login/login.component.html | 5 +- .../auth/src/angular/login/login.component.ts | 10 +- .../password-login.strategy.ts | 67 ++---- .../libs/common/src/auth/kdf-identity.ts | 30 +++ .../password-prelogin.model.ts | 4 +- .../password-prelogin.response.ts | 6 + distribution/komboe-downloads.json | 167 ++++++++++++++ docker/Dockerfile.clients | 9 +- scripts/build-downloads-manifest.py | 214 ++++++++++++++++++ .../down.sql | 1 + .../up.sql | 5 + .../down.sql | 1 + .../up.sql | 4 + .../down.sql | 1 + .../up.sql | 4 + server/upstream/src/api/core/accounts.rs | 26 ++- server/upstream/src/api/core/login_name.rs | 173 ++++++++++++++ server/upstream/src/api/core/mod.rs | 2 + server/upstream/src/db/schema.rs | 10 +- tests/features/device_unlock.mjs | 71 ++++++ tests/features/device_unlock_fixture.html | 78 +++++++ 35 files changed, 1600 insertions(+), 88 deletions(-) create mode 100644 apps/web-marketing/assets/device-unlock.js create mode 100644 apps/web-marketing/komboe-downloads.json create mode 100644 clients/upstream/apps/web/src/app/auth/komboe-device-access.component.ts create mode 100644 clients/upstream/apps/web/src/app/auth/komboe-device-unlock.mjs create mode 100644 clients/upstream/libs/common/src/auth/kdf-identity.ts create mode 100644 distribution/komboe-downloads.json create mode 100644 scripts/build-downloads-manifest.py create mode 100644 server/upstream/migrations/mysql/2026-11-08-000000_komboe_login_names/down.sql create mode 100644 server/upstream/migrations/mysql/2026-11-08-000000_komboe_login_names/up.sql create mode 100644 server/upstream/migrations/postgresql/2026-11-08-000000_komboe_login_names/down.sql create mode 100644 server/upstream/migrations/postgresql/2026-11-08-000000_komboe_login_names/up.sql create mode 100644 server/upstream/migrations/sqlite/2026-11-08-000000_komboe_login_names/down.sql create mode 100644 server/upstream/migrations/sqlite/2026-11-08-000000_komboe_login_names/up.sql create mode 100644 server/upstream/src/api/core/login_name.rs create mode 100644 tests/features/device_unlock.mjs create mode 100644 tests/features/device_unlock_fixture.html diff --git a/apps/web-marketing/assets/auth.js b/apps/web-marketing/assets/auth.js index 724f151e..e00187a0 100644 --- a/apps/web-marketing/assets/auth.js +++ b/apps/web-marketing/assets/auth.js @@ -166,15 +166,16 @@ async function boot() { busy = true; submit.disabled = true; clearError(error); - const email = document.getElementById("email").value.trim().toLowerCase(); + const typed = document.getElementById("email").value.trim().toLowerCase(); const password = document.getElementById("master-password").value; try { - let hash = !twoFactor.hidden && pending && pending.email === email ? pending.hash : null; + let accountEmail = typed; + let hash = !twoFactor.hidden && pending && pending.typed === typed ? pending.hash : null; if (!hash) { const pre = await fetch("/identity/accounts/prelogin", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email: typed }), credentials: "omit", }); const preBody = await readJson(pre); @@ -186,7 +187,19 @@ async function boot() { showError(error, loginMessage(preBody, pre.status)); return; } - hash = await passwordHash(email, password, preBody); + // An email is the KDF salt, lowercased as before. A login name is used only + // when prelogin returns the stored account email. The login name is never the salt. + if (!typed.includes("@")) { + accountEmail = String(preBody.email || "").trim().toLowerCase(); + if (!accountEmail.includes("@")) { + showError(error, "That login name is not an account. Sign in with the email for this vault."); + return; + } + } + hash = await passwordHash(accountEmail, password, preBody); + pending = { typed, hash, accountEmail }; + } else { + accountEmail = pending.accountEmail; } const extra = {}; if (!twoFactor.hidden && code.value.trim()) { @@ -194,14 +207,14 @@ async function boot() { extra.twoFactorToken = code.value.trim(); extra.twoFactorRemember = "0"; } - const response = await tokenRequest(email, hash, extra); + const response = await tokenRequest(accountEmail, hash, extra); const body = await readJson(response); if (response.status === 429) { showError(error, loginMessage(body, 429)); return; } if (body.TwoFactorProviders) { - pending = { email, hash }; + pending = { typed, hash, accountEmail }; const id = providerId(body); twoFactor.hidden = false; twoFactor.dataset.provider = id; @@ -213,7 +226,7 @@ async function boot() { await fetch("/api/two-factor/send-email-login", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ email, masterPasswordHash: hash, deviceIdentifier: deviceId() }), + body: JSON.stringify({ email: accountEmail, masterPasswordHash: hash, deviceIdentifier: deviceId() }), credentials: "omit", }); } diff --git a/apps/web-marketing/assets/device-unlock.js b/apps/web-marketing/assets/device-unlock.js new file mode 100644 index 00000000..595dcfb1 --- /dev/null +++ b/apps/web-marketing/assets/device-unlock.js @@ -0,0 +1,164 @@ +/** + * Device PIN and pattern unlock. + * The secret never leaves this browser. The stored record holds only a wrapped user key. + * This is not a server login and it does not replace the master password. + */ + +export const STORAGE_PREFIX = "komboe.deviceUnlock.v1."; +export const MAX_FAILURES = 5; +export const UNLOCK_TTL_MS = 12 * 60 * 60 * 1000; +const ITERATIONS = 600000; + +const REJECTED_PINS = new Set(["000000", "111111", "123456", "654321", "121212"]); + +export function storageKey(userId) { + return `${STORAGE_PREFIX}${userId}`; +} + +export function validatePin(pin) { + if (!/^[0-9]{6,12}$/.test(pin)) { + return "Use 6 to 12 digits."; + } + if (REJECTED_PINS.has(pin)) { + return "That PIN is too easy to guess."; + } + return null; +} + +export function validatePattern(pattern) { + if (!/^[0-8]{4,9}$/.test(pattern)) { + return "Connect at least 4 dots."; + } + if (new Set(pattern).size !== pattern.length) { + return "Each dot can be used once."; + } + return null; +} + +export function biometricCapability(nav = globalThis.navigator) { + const credentials = nav && nav.credentials; + if (!credentials || typeof credentials.create !== "function" || !globalThis.PublicKeyCredential) { + return { + supported: false, + reason: + "This browser cannot use a device authenticator. Komboe does not collect fingerprints or face data. Use your master password.", + }; + } + return { + supported: true, + reason: + "On the web, biometrics stay inside the device authenticator (Touch ID, Windows Hello, or a security key) via a passkey with the PRF extension. Komboe never receives a biometric sample. The passkey control on the lock screen is the unlock. It stays unavailable until a PRF passkey is enrolled on this device.", + }; +} + +function bytesToB64(bytes) { + let text = ""; + for (const byte of bytes) { + text += String.fromCharCode(byte); + } + return btoa(text); +} + +function b64ToBytes(value) { + const text = atob(value); + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) { + bytes[i] = text.charCodeAt(i); + } + return bytes; +} + +async function wrappingKey(secret, salt) { + const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), "PBKDF2", false, [ + "deriveKey", + ]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: ITERATIONS, hash: "SHA-256" }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +export async function enrollDeviceUnlock(userKey, secret, kind) { + const problem = kind === "pin" ? validatePin(secret) : validatePattern(secret); + if (problem) { + throw new Error(problem); + } + if (!(userKey instanceof Uint8Array) || userKey.byteLength < 32) { + throw new Error("A device unlock can only wrap an existing user key."); + } + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await wrappingKey(`${kind}:${secret}`, salt); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, userKey), + ); + return { + v: 1, + kind, + kdf: "PBKDF2-SHA256", + iterations: ITERATIONS, + salt: bytesToB64(salt), + iv: bytesToB64(iv), + ciphertext: bytesToB64(ciphertext), + failures: 0, + lastSuccess: Date.now(), + }; +} + +export async function unlockDevice(record, secret) { + if (!record || record.failures >= MAX_FAILURES) { + return { wiped: true, record: null, userKey: null, expired: false, error: "Device unlock was removed." }; + } + if (Date.now() - record.lastSuccess > UNLOCK_TTL_MS) { + return { + wiped: false, + expired: true, + record, + userKey: null, + error: "This device unlock expired. Use your master password, then enroll again.", + }; + } + try { + const key = await wrappingKey(`${record.kind}:${secret}`, b64ToBytes(record.salt)); + const plain = new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: b64ToBytes(record.iv) }, + key, + b64ToBytes(record.ciphertext), + ), + ); + return { + wiped: false, + expired: false, + userKey: plain, + error: null, + record: { ...record, failures: 0, lastSuccess: Date.now() }, + }; + } catch { + const failures = record.failures + 1; + if (failures >= MAX_FAILURES) { + return { wiped: true, record: null, userKey: null, expired: false, error: "Too many attempts. Device unlock was removed." }; + } + return { + wiped: false, + expired: false, + userKey: null, + error: "That PIN or pattern did not match.", + record: { ...record, failures }, + }; + } +} + +export function recordHoldsPlaintext(record, secret, userKey) { + const text = JSON.stringify(record); + if (secret && text.includes(secret)) { + return true; + } + if (userKey && text.includes(bytesToB64(userKey))) { + return true; + } + return false; +} diff --git a/apps/web-marketing/assets/vault.js b/apps/web-marketing/assets/vault.js index 09b36981..5cb18f38 100644 --- a/apps/web-marketing/assets/vault.js +++ b/apps/web-marketing/assets/vault.js @@ -1,3 +1,5 @@ +import { biometricCapability } from "./device-unlock.js"; + const TOKEN_KEY = "komboe.accessToken"; async function load() { @@ -32,11 +34,19 @@ async function load() { document.getElementById("dash-name").textContent = profile.name || "Your vault"; document.getElementById("dash-count").textContent = count; document.getElementById("dash-body").hidden = false; + const note = document.getElementById("device-unlock-note"); + if (note) { + note.textContent = biometricCapability(navigator).reason + + " A PIN or pattern enrolls in the web vault only after the master password has unlocked that device. This page does not hold the vault key, so it does not store a PIN, pattern, or biometric sample."; + } } document.getElementById("dash-logout")?.addEventListener("click", () => { sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem("komboe.agentSession"); + Object.keys(localStorage).forEach((key) => { + if (key.startsWith("komboe.deviceUnlock.v1.")) localStorage.removeItem(key); + }); location.assign("../index.html#signin"); }); diff --git a/apps/web-marketing/build.py b/apps/web-marketing/build.py index 7a073537..55d223b0 100644 --- a/apps/web-marketing/build.py +++ b/apps/web-marketing/build.py @@ -83,7 +83,7 @@ def cards(items): ] SIGNIN = '''