diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eab119a..d7632992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,14 @@ jobs: python3 tests/features/backup_restore.py python3 tests/features/self_update.py python3 tests/features/coolify_pull.py + python3 tests/features/email_interval.py + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Extension trust token is revoked from session storage + run: node --experimental-strip-types tests/features/email_trust_storage.mjs + - name: Login-name eligibility matches the server wrap rule + run: node --experimental-strip-types tests/features/login_name_eligibility.mjs - name: Validate the pull-only compose file env: KOMBOE_SERVER_DIGEST: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa @@ -45,6 +53,9 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: server/upstream + - name: Server unit tests + working-directory: server/upstream + run: cargo test --features sqlite --bin vaultwarden -- never_and_window codes_expire email_is_not login_name_shape login_name_uses a_miss_is_stable restore_replaces - uses: actions/setup-node@v4 with: node-version: "22" @@ -56,15 +67,116 @@ jobs: working-directory: apps/web-marketing env: CARGO_BUILD_JOBS: "2" + - name: Timed email sign-in against the built server and a local mail sink + run: python3 tests/features/email_interval_flow.py + - name: Marketing sign-in keeps mixed-case salts and lowercased accounts + run: node tests/features/marketing_email_case.mjs + - name: New migrations apply on boot and survive a second boot + run: python3 tests/features/migrations_boot.py + - name: Signed-in vault, downloads page, and PIN enroll/unlock/failure + run: node tests/features/ui_proof.mjs - uses: actions/upload-artifact@v4 if: always() with: name: marketing-screenshots path: apps/web-marketing/e2e/screenshots/* + angular-vault: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.99.0 + - run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: server/upstream + - name: Build the server the vault test logs into + working-directory: server/upstream + run: cargo build --features sqlite --bin vaultwarden + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Install the web vault dependencies + working-directory: clients/upstream + run: | + set -eu + npm install -g npm@11.6.2 + npm ci --ignore-scripts + - name: Build the self-hosted web vault + working-directory: clients/upstream + env: + NODE_OPTIONS: --max-old-space-size=5120 + run: npm run dist:oss:selfhost --workspace @bitwarden/web-vault + - name: Install Playwright + working-directory: apps/web-marketing + run: | + set -eu + npm ci + npx playwright install --with-deps chromium + - name: Unlock a real encrypted cipher with PIN and pattern + env: + KOMBOE_BIN: server/upstream/target/debug/vaultwarden + KOMBOE_SHOTS: ${{ github.workspace }}/angular-shots + run: node tests/features/angular_vault_unlock.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: angular-vault-screenshots + path: angular-shots/* + + extension: + # Builds the Chromium MV3 zip inside the existing clients image and uploads it. + # Does not push to GHCR and does not publish a store listing. + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build the clients image without pushing it + run: | + set -eu + docker buildx build \ + --load \ + --platform linux/amd64 \ + --provenance=false \ + --sbom=false \ + -f docker/Dockerfile.clients \ + --build-context komboe_root=. \ + --build-arg KOMBOE_REVISION="${{ github.sha }}" \ + -t komboe-clients:ci \ + clients/upstream + cid=$(docker create komboe-clients:ci) + mkdir -p dist + docker cp "$cid:/opt/komboe-chrome-mv3.zip" dist/komboe-chrome-mv3.zip + docker cp "$cid:/src/apps/web/build/komboe-downloads.json" dist/komboe-downloads.json + docker rm "$cid" + python3 - << 'PY' + import hashlib, json + from pathlib import Path + blob = Path("dist/komboe-chrome-mv3.zip").read_bytes() + manifest = json.loads(Path("dist/komboe-downloads.json").read_text()) + if manifest.get("revision") != "${{ github.sha }}": + raise SystemExit(f"manifest revision is not this commit: {manifest.get('revision')}") + asset = next(item for item in manifest["assets"] if item["id"] == "extension-chromium-mv3") + digest = hashlib.sha256(blob).hexdigest() + if asset["sha256"] != digest or asset["status"] != "published": + raise SystemExit(f"manifest does not match the zip: {asset}") + for other in manifest["assets"]: + if other["id"] == "extension-chromium-mv3": + continue + if other["sha256"] or other["url"] or other["status"] == "published": + raise SystemExit(f"clients image manifest includes a file this image did not build: {other['id']}") + print("extension", digest, asset["size"]) + PY + - uses: actions/upload-artifact@v4 + with: + name: komboe-chrome-mv3 + path: dist/* + image: if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: checks + needs: [checks, marketing, angular-vault, extension] permissions: contents: read packages: write @@ -109,7 +221,39 @@ jobs: echo "${name}_digest=${hex}" >> "$GITHUB_OUTPUT" } publish_one komboe-server -f docker/Dockerfile.server . - publish_one komboe-clients -f docker/Dockerfile.clients --build-context komboe_root=. clients/upstream + docker buildx build \ + --load \ + --platform linux/amd64 \ + --provenance=false \ + --sbom=false \ + -f docker/Dockerfile.clients \ + --build-context komboe_root=. \ + --build-arg KOMBOE_REVISION="${IMAGE_SHA}" \ + -t komboe-clients:validate \ + clients/upstream + cid=$(docker create komboe-clients:validate) + mkdir -p dist + docker cp "$cid:/src/apps/web/build/komboe-downloads.json" dist/komboe-downloads.json + docker cp "$cid:/opt/komboe-chrome-mv3.zip" dist/komboe-chrome-mv3.zip + docker rm "$cid" + EXPECTED_REVISION="${IMAGE_SHA}" python3 - << 'PY' + import hashlib, json, os + from pathlib import Path + manifest = json.loads(Path("dist/komboe-downloads.json").read_text()) + if manifest.get("revision") != os.environ["EXPECTED_REVISION"]: + raise SystemExit(f"published clients image stamped {manifest.get('revision')}") + blob = Path("dist/komboe-chrome-mv3.zip").read_bytes() + asset = next(item for item in manifest["assets"] if item["id"] == "extension-chromium-mv3") + if asset["sha256"] != hashlib.sha256(blob).hexdigest() or asset["status"] != "published": + raise SystemExit(f"published image manifest does not match the zip: {asset}") + for other in manifest["assets"]: + if other["id"] == "extension-chromium-mv3": + continue + if other["sha256"] or other["url"] or other["status"] == "published": + raise SystemExit(f"published image manifest includes a file this image did not build: {other['id']}") + print("clients image revision", manifest["revision"]) + PY + publish_one komboe-clients -f docker/Dockerfile.clients --build-arg KOMBOE_REVISION="${IMAGE_SHA}" --build-context komboe_root=. clients/upstream for name in komboe-server komboe-clients; do gh api --method PUT "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${name}" -f visibility=public \ || echo "package ${name} stayed private; Coolify needs a read token to pull it" diff --git a/.github/workflows/client-builds.yml b/.github/workflows/client-builds.yml new file mode 100644 index 00000000..aaba5691 --- /dev/null +++ b/.github/workflows/client-builds.yml @@ -0,0 +1,159 @@ +name: client-builds + +on: + pull_request: + workflow_dispatch: + +jobs: + desktop-linux: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Install Electron native libraries + run: sudo apt-get update && sudo apt-get install -y pkg-config libsecret-1-dev libxss1 rpm fakeroot dpkg + - name: Build an unsigned Linux desktop package + working-directory: clients/upstream + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + set -eu + npm install -g npm@11.6.2 + npm ci + npm run build:oss --workspace @bitwarden/desktop + cd apps/desktop + npx electron-builder --linux deb AppImage --x64 -p never + ls -l dist + - uses: actions/upload-artifact@v4 + with: + name: desktop-linux-unsigned + path: | + clients/upstream/apps/desktop/dist/*.deb + clients/upstream/apps/desktop/dist/*.AppImage + + desktop-windows: + runs-on: windows-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Build an unsigned Windows desktop package + working-directory: clients/upstream + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + npm install -g npm@11.6.2 + npm ci + npm run build:oss --workspace @bitwarden/desktop + cd apps/desktop + npx electron-builder --win nsis --x64 -p never --config.win.target=nsis + - uses: actions/upload-artifact@v4 + with: + name: desktop-windows-unsigned + path: clients/upstream/apps/desktop/dist/*.exe + + android-release: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - name: Production Android signing check + id: sign + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + set -eu + if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then + mkdir -p dist + printf '%s\n' "No ANDROID_KEYSTORE_BASE64 secret is configured." > dist/android-signing-absent.txt + printf '%s\n' "mobile/android-build.sh only runs :app:assembleFdroidDebug." >> dist/android-signing-absent.txt + printf '%s\n' "A release APK was not built and a signing key was not created." >> dist/android-signing-absent.txt + echo "present=false" >> "$GITHUB_OUTPUT" + else + echo "present=true" >> "$GITHUB_OUTPUT" + fi + - uses: android-actions/setup-android@v3 + if: steps.sign.outputs.present == 'true' + - name: Build the F-Droid release APK when a keystore already exists + if: steps.sign.outputs.present == 'true' + run: | + set -eu + pin=$(cat mobile/ANDROID_UPSTREAM_PIN) + git clone --depth 1 -b "$pin" https://github.com/bitwarden/android.git /tmp/bw-android + python3 mobile/android_rebrand.py /tmp/bw-android + echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/komboe.keystore + cd /tmp/bw-android + ./gradlew --no-daemon :app:assembleFdroidRelease \ + -Pandroid.injected.signing.store.file=/tmp/komboe.keystore \ + -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" + mkdir -p "$GITHUB_WORKSPACE/dist" + find app/build/outputs/apk -name '*.apk' ! -name '*debug*' -exec cp {} "$GITHUB_WORKSPACE/dist/" \; + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + - uses: actions/upload-artifact@v4 + with: + name: android-release-status + path: dist/* + + download-manifest: + needs: [desktop-linux, desktop-windows, android-release] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + continue-on-error: true + with: + pattern: desktop-* + path: gathered + merge-multiple: true + - name: Record checksums without publishing a download URL + run: | + set -eu + mkdir -p dist + if compgen -G "gathered/**/*.deb" > /dev/null || compgen -G "gathered/*.deb" > /dev/null; then + deb=$(find gathered -name '*.deb' | head -1) + cp "$deb" dist/komboe-desktop-amd64.deb + fi + if find gathered -name '*.AppImage' | grep -q .; then + cp "$(find gathered -name '*.AppImage' | head -1)" dist/komboe-desktop-x86_64.AppImage + fi + if find gathered -name '*.exe' | grep -q .; then + cp "$(find gathered -name '*.exe' | head -1)" dist/komboe-desktop-win-x64-setup.exe + fi + python3 scripts/build-downloads-manifest.py \ + --artifacts dist \ + --record-checksums \ + --revision "${{ github.sha }}" \ + --out dist/komboe-downloads.json + EXPECTED_REVISION="${{ github.sha }}" python3 - << 'PY' + import json, os + from pathlib import Path + manifest = json.loads(Path("dist/komboe-downloads.json").read_text()) + if manifest.get("revision") != os.environ["EXPECTED_REVISION"]: + raise SystemExit(f"checksum manifest revision {manifest.get('revision')} is not {os.environ['EXPECTED_REVISION']}") + for asset in manifest["assets"]: + if asset["url"] or asset["status"] == "published": + raise SystemExit(f"checksum job must not publish a URL: {asset['id']}") + if asset["status"] == "checksum" and not asset["sha256"]: + raise SystemExit(f"checksum missing: {asset['id']}") + print("checksum assets", [(a["id"], a["status"], a["sha256"]) for a in manifest["assets"]]) + PY + - uses: actions/upload-artifact@v4 + with: + name: komboe-downloads-checksums + path: dist/komboe-downloads.json diff --git a/.github/workflows/release-downloads.yml b/.github/workflows/release-downloads.yml new file mode 100644 index 00000000..fc171710 --- /dev/null +++ b/.github/workflows/release-downloads.yml @@ -0,0 +1,146 @@ +name: release-downloads + +# Runs only after a person publishes a GitHub release. Attaches the files that +# actually built and writes komboe-downloads.json with those URLs and checksums. +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + chrome: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build the Chromium zip + run: | + set -eu + docker buildx build \ + --load \ + --platform linux/amd64 \ + --provenance=false \ + --sbom=false \ + -f docker/Dockerfile.clients \ + --build-context komboe_root=. \ + --build-arg KOMBOE_REVISION="${{ github.sha }}" \ + -t komboe-clients:release \ + clients/upstream + cid=$(docker create komboe-clients:release) + mkdir -p dist + docker cp "$cid:/opt/komboe-chrome-mv3.zip" dist/komboe-chrome-mv3.zip + docker rm "$cid" + - uses: actions/upload-artifact@v4 + with: + name: release-chrome + path: dist/komboe-chrome-mv3.zip + + desktop-linux: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - run: sudo apt-get update && sudo apt-get install -y pkg-config libsecret-1-dev libxss1 rpm fakeroot dpkg + - name: Build unsigned Linux packages + working-directory: clients/upstream + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + set -eu + npm install -g npm@11.6.2 + npm ci + npm run build:oss --workspace @bitwarden/desktop + cd apps/desktop + npx electron-builder --linux deb AppImage --x64 -p never + - uses: actions/upload-artifact@v4 + with: + name: release-linux + path: clients/upstream/apps/desktop/dist/* + if-no-files-found: ignore + + desktop-windows: + runs-on: windows-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Build an unsigned Windows installer + working-directory: clients/upstream + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + npm install -g npm@11.6.2 + npm ci + npm run build:oss --workspace @bitwarden/desktop + cd apps/desktop + npx electron-builder --win nsis --x64 -p never --config.win.target=nsis + - uses: actions/upload-artifact@v4 + with: + name: release-windows + path: clients/upstream/apps/desktop/dist/*.exe + if-no-files-found: ignore + + manifest: + needs: [chrome, desktop-linux, desktop-windows] + if: always() && needs.chrome.result == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: gathered + merge-multiple: true + - name: Write the manifest from the files that exist and attach the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + mkdir -p dist + find gathered -type f \( -name '*.zip' -o -name '*.deb' -o -name '*.AppImage' -o -name '*.exe' \) -exec cp {} dist/ \; + # Keep the names the manifest generator expects. + if compgen -G "dist/*.deb" > /dev/null; then + deb=$(ls dist/*.deb | head -1) + mv "$deb" dist/komboe-desktop-amd64.deb + fi + if compgen -G "dist/*.AppImage" > /dev/null; then + app=$(ls dist/*.AppImage | head -1) + mv "$app" dist/komboe-desktop-x86_64.AppImage + fi + if compgen -G "dist/*.exe" > /dev/null; then + exe=$(ls dist/*.exe | head -1) + mv "$exe" dist/komboe-desktop-win-x64-setup.exe + fi + tag="${{ github.event.release.tag_name }}" + python3 scripts/build-downloads-manifest.py \ + --artifacts dist \ + --out dist/komboe-downloads.json \ + --publish-dir dist/upload \ + --url-prefix "https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/" \ + --revision "${{ github.sha }}" + cp dist/komboe-downloads.json dist/upload/komboe-downloads.json + gh release upload "$tag" dist/upload/* --clobber + python3 - << 'PY' + import hashlib, json + from pathlib import Path + manifest = json.loads(Path("dist/komboe-downloads.json").read_text()) + if manifest.get("revision") != "${{ github.sha }}": + raise SystemExit(f"manifest revision is not this commit: {manifest.get('revision')}") + published = [a for a in manifest["assets"] if a["status"] == "published"] + print("published", [(a["id"], a["sha256"], a["url"]) for a in published]) + for asset in published: + assert asset["url"].startswith("https://github.com/") + assert asset["sha256"] + blob = Path("dist/upload") / asset["filename"] + digest = hashlib.sha256(blob.read_bytes()).hexdigest() + if digest != asset["sha256"]: + raise SystemExit(f"published bytes do not match the manifest: {asset['id']}") + PY diff --git a/apps/web-marketing/assets/auth.js b/apps/web-marketing/assets/auth.js index 724f151e..e590912b 100644 --- a/apps/web-marketing/assets/auth.js +++ b/apps/web-marketing/assets/auth.js @@ -1,4 +1,5 @@ const TOKEN_KEY = "komboe.accessToken"; +const TRUST_KEY = "komboe.deviceTrust"; const DEVICE_KEY = "komboe.deviceId"; function bytes(text) { @@ -39,11 +40,59 @@ async function masterKey(email, password, pre) { return pbkdf2(bytes(password), bytes(email), pre.kdfIterations || 600000); } -async function passwordHash(email, password, pre) { +export async function passwordHash(email, password, pre) { const key = await masterKey(email, password, pre); return b64(await pbkdf2(key, bytes(password), 1)); } +/** + * Email vault-key salts. The typed case is first so a mixed-case vault unlocks. + * The lowercased form is second so an account registered with a lowercased salt + * still unlocks when the person types different case. Identical strings are one try. + */ +export function emailSaltCandidates(typed) { + const exact = String(typed || "").trim(); + if (!exact) return []; + const lower = exact.toLowerCase(); + return exact === lower ? [exact] : [exact, lower]; +} + +function passwordRejected(body, status) { + const message = (body && (body.message || body.error_description || body.Message)) || ""; + return status === 400 && /username or password is incorrect/i.test(message); +} + +function parseLoginNamePlaintext(plain) { + const text = plain.replace(/\0+$/g, ""); + if (!text.startsWith("v2\n")) { + if (!text.includes("@")) throw new Error("That login name was not accepted."); + return { kdfEmail: text, kdf: 0, kdfIterations: 600000, kdfMemory: null, kdfParallelism: null }; + } + const parts = text.split("\n"); + if (!String(parts[1] || "").includes("@")) throw new Error("That login name was not accepted."); + return { + kdfEmail: parts[1], + kdf: Number(parts[2]), + kdfIterations: Number(parts[3]), + kdfMemory: parts[4] ? Number(parts[4]) : null, + kdfParallelism: parts[5] ? Number(parts[5]) : null, + }; +} + +async function openLoginNamePayload(rawKey, envelope) { + const text = atob(envelope); + const packed = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) packed[i] = text.charCodeAt(i); + if (packed.length !== 12 + 128 + 16) throw new Error("That login name was not accepted."); + const key = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["decrypt"]); + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: packed.slice(0, 12) }, + key, + packed.slice(12), + ); + return parseLoginNamePlaintext(new TextDecoder().decode(plain)); +} + function deviceId() { let id = sessionStorage.getItem(DEVICE_KEY); if (!id) { @@ -166,15 +215,18 @@ async function boot() { busy = true; submit.disabled = true; clearError(error); - const email = document.getElementById("email").value.trim().toLowerCase(); + const raw = document.getElementById("email").value.trim(); + const typed = raw.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; + let settled = 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,22 +238,67 @@ async function boot() { showError(error, loginMessage(preBody, pre.status)); return; } - hash = await passwordHash(email, password, preBody); + if (!typed.includes("@")) { + const opened = await fetch("/identity/accounts/login-name/open", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ name: typed }), + credentials: "omit", + }); + const openedBody = await readJson(opened); + if (!opened.ok || !openedBody.envelope || !openedBody.envelopeSalt) { + showError(error, loginMessage(openedBody, opened.status)); + return; + } + let payload; + try { + const wrappingKey = await masterKey(openedBody.envelopeSalt, password, openedBody); + payload = await openLoginNamePayload(wrappingKey, openedBody.envelope); + } catch { + showError(error, "Email or master password is incorrect."); + return; + } + accountEmail = payload.kdfEmail; + hash = await passwordHash(accountEmail, password, payload); + } else { + const salts = emailSaltCandidates(raw); + accountEmail = salts[0]; + hash = await passwordHash(accountEmail, password, preBody); + if (salts[1]) { + const probeExtra = {}; + const storedTrust = sessionStorage.getItem(TRUST_KEY); + if (storedTrust) probeExtra.komboeDeviceTrust = storedTrust; + const probe = await tokenRequest(accountEmail, hash, probeExtra); + const probeBody = await readJson(probe); + if (passwordRejected(probeBody, probe.status)) { + accountEmail = salts[1]; + hash = await passwordHash(accountEmail, password, preBody); + } else { + settled = { response: probe, body: probeBody }; + } + } + } + pending = { typed, hash, accountEmail }; + } else { + accountEmail = pending.accountEmail; } const extra = {}; + const storedTrust = sessionStorage.getItem(TRUST_KEY); + if (storedTrust) extra.komboeDeviceTrust = storedTrust; if (!twoFactor.hidden && code.value.trim()) { extra.twoFactorProvider = twoFactor.dataset.provider || "0"; extra.twoFactorToken = code.value.trim(); extra.twoFactorRemember = "0"; + if (pending && pending.challenge) extra.komboeLoginChallenge = pending.challenge; } - const response = await tokenRequest(email, hash, extra); - const body = await readJson(response); + const response = settled ? settled.response : await tokenRequest(accountEmail, hash, extra); + const body = settled ? settled.body : await readJson(response); if (response.status === 429) { showError(error, loginMessage(body, 429)); return; } if (body.TwoFactorProviders) { - pending = { email, hash }; + pending = { typed, hash, accountEmail, challenge: body.KomboeLoginChallenge || "" }; const id = providerId(body); twoFactor.hidden = false; twoFactor.dataset.provider = id; @@ -210,12 +307,22 @@ async function boot() { code.focus(); if (id === "1" && !emailCodeSent) { emailCodeSent = true; - await fetch("/api/two-factor/send-email-login", { + const sent = 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(), + komboeLoginChallenge: (pending && pending.challenge) || "", + }), credentials: "omit", }); + if (!sent.ok) { + const sentBody = await readJson(sent); + showError(error, sentBody.message || "The sign-in code could not be sent. Sign-in was not completed."); + return; + } } showError(error, id === "1" ? "Enter the code sent to your email." @@ -227,6 +334,7 @@ async function boot() { return; } sessionStorage.setItem(TOKEN_KEY, body.access_token); + if (body.KomboeDeviceTrust) sessionStorage.setItem(TRUST_KEY, body.KomboeDeviceTrust); pending = null; document.getElementById("master-password").value = ""; code.value = ""; @@ -240,4 +348,4 @@ async function boot() { }); } -boot(); +if (typeof document !== "undefined") boot(); diff --git a/apps/web-marketing/assets/device-unlock.js b/apps/web-marketing/assets/device-unlock.js new file mode 100644 index 00000000..25dacb10 --- /dev/null +++ b/apps/web-marketing/assets/device-unlock.js @@ -0,0 +1,174 @@ +/** + * 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: false, + reason: + "This page does not enroll a passkey and does not collect fingerprints or face data. The existing lock-screen passkey button works only for a PRF credential that was already enrolled. Use your master password, or a PIN or pattern that stays in this browser.", + }; +} + +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"], + ); +} + +/** + * Same acceptance rule as SymmetricCryptoKey for a full user key: + * 64-byte enc+mac, or a longer COSE key. A bare 32-byte AES key is not a user key. + * Copying localStorage still lets someone guess a short PIN offline. The attempt + * counter only stops guesses made through this browser. + */ +export function assertVaultUserKey(userKey) { + if (!(userKey instanceof Uint8Array) || userKey.byteLength < 64) { + throw new Error("Refusing to wrap anything except a vault user key."); + } +} + +export async function enrollDeviceUnlock(userKey, secret, kind) { + const problem = kind === "pin" ? validatePin(secret) : validatePattern(secret); + if (problem) { + throw new Error(problem); + } + assertVaultUserKey(userKey); + 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..8e2f675b 100644 --- a/apps/web-marketing/assets/vault.js +++ b/apps/web-marketing/assets/vault.js @@ -1,3 +1,18 @@ +import { biometricCapability } from "./device-unlock.js"; +import { passwordHash } from "./auth.js"; + +const INTERVALS = [ + [null, "Never"], + [60, "1 minute"], + [300, "5 minutes"], + [600, "10 minutes"], + [900, "15 minutes"], + [1800, "30 minutes"], + [3600, "1 hour"], + [21600, "6 hours"], + [86400, "24 hours"], +]; + const TOKEN_KEY = "komboe.accessToken"; async function load() { @@ -32,11 +47,82 @@ 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 select = document.getElementById("email-interval"); + if (select && !select.options.length) { + for (const [value, label] of INTERVALS) { + const option = document.createElement("option"); + option.value = value == null ? "" : String(value); + option.textContent = label; + select.appendChild(option); + } + } + const current = await fetch("/api/komboe/email-login-interval", { headers, credentials: "omit" }); + if (current.ok && select) { + const body = await current.json(); + select.value = body.intervalSeconds == null ? "" : String(body.intervalSeconds); + } + document.getElementById("email-interval-save")?.addEventListener("click", async () => { + const status = document.getElementById("email-interval-status"); + const password = document.getElementById("email-interval-password").value; + if (!password) { + status.textContent = "Enter your master password to confirm this change."; + return; + } + const pre = await fetch("/identity/accounts/prelogin", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ email: profile.email }), + credentials: "omit", + }); + if (!pre.ok) { + status.textContent = "The change was not saved."; + return; + } + const hash = await passwordHash(profile.email, password, await pre.json()); + const choice = select.value === "" ? null : Number(select.value); + const saved = await fetch("/api/komboe/email-login-interval", { + method: "PUT", + headers: { ...headers, "Content-Type": "application/json" }, + credentials: "omit", + body: JSON.stringify({ intervalSeconds: choice, masterPasswordHash: hash }), + }); + document.getElementById("email-interval-password").value = ""; + status.textContent = saved.ok + ? "Saved. Devices must enter a new email code the next time this timer applies." + : "The change was not saved. The master password was not accepted."; + }); + 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. A short PIN copied out of browser storage can be guessed offline; the wrong-attempt counter does not stop that."; + } } -document.getElementById("dash-logout")?.addEventListener("click", () => { +document.getElementById("dash-logout")?.addEventListener("click", async () => { + const token = sessionStorage.getItem(TOKEN_KEY); + const trust = sessionStorage.getItem("komboe.deviceTrust"); + if (token && trust) { + try { + const response = await fetch("/api/komboe/email-login-trust/clear", { + method: "POST", + headers: { + Authorization: "Bearer " + token, + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: trust }), + credentials: "omit", + }); + if (response.ok) sessionStorage.removeItem("komboe.deviceTrust"); + } catch { + // Keep the bearer token so a later logout can revoke it. + } + } 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..dfdb9749 100644 --- a/apps/web-marketing/build.py +++ b/apps/web-marketing/build.py @@ -83,7 +83,7 @@ def cards(items): ] SIGNIN = '''