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 = '''

Sign in

- + @@ -151,18 +151,40 @@ def badge(st): pricing = '

Pricing

Simple plans for individuals, families and teams.

Paid plan prices will be announced at launch.

Plans

' + "".join(f'

{n}

{p} {s}
    {"".join(f"
  • {x}
  • " for x in L)}
Choose {n}
' for n, p, s, L in tiers) + "
" PH = 'Version and SHA-256 checksum will be published with the first release.' -def dl(os_, icon, t, desc, btn, instr="", soon=False): - b = 'Coming soon' if soon else f'{btn}: coming soon' - return f'
Detected for you
{icon}

{t}

{desc}

{instr}{"" if soon else PH}{b}
' +import html as _html +import json as _json +from pathlib import Path as _Path +_MANIFEST = _json.loads((_Path(__file__).resolve().parents[2] / "distribution" / "komboe-downloads.json").read_text(encoding="utf-8")) +_OS = {"desktop-windows": "windows", "desktop-macos": "macos", "desktop-linux-deb": "linux", "desktop-linux-appimage": "linux", "android": "android", "ios": "ios", "extension-chromium-mv3": "ext-chrome ext-edge ext-brave", "extension-firefox": "ext-firefox"} -groups = [("Phone", [dl("android", "📱", "Phone", "Autofill service and biometric unlock.", "Get the phone app"), dl("ios", "📱", "Other phones", "A later build.", "", soon=True)]), -("Desktop", [dl("windows", "💻", "Windows", "Installer for current Windows systems.", "Download"), dl("macos", "💻", "macOS", "Current macOS, both chip families.", "Download"), dl("linux", "💻", "Linux", "Packages for common Linux systems.", "Download")]), -("Browser", [dl("ext-" + b.lower(), "🧩", b, f"Autofill and save logins in {b}.", "Add to " + b) for b in ["Chrome", "Edge", "Firefox", "Brave", "Safari"]]), -("Web, command line and server", [f'
🌐

Web vault

No install. Sign in on the home page.

Sign in
', -dl("cli", "⌨️", "Command line", "Script your vault and inject secrets into a process.", "Download", '

Installers ship with a SHA-256 checksum. Do not pipe a downloaded script into a shell.

'), -dl("server", "🗄️", "Self-host server", "Run your own Komboe sync server from the published container image.", "Get server", '

Local accounts are the default. Optional enterprise single sign-on can be added by the operator.

' + SH_SHORT + " " + SWITCH + "

"), -dl("mcp", "✨", "Assistant", "The signed-in assistant on this site uses the Komboe agent. It does not read vault items.", "Included", "
komboe mcp
")])] -downloads = '

Download Komboe

Every Komboe app. This browser looks like: this device

Komboe is pre-release. Installers will appear here with the first builds.

' + "".join(f'

{g}

{"".join(c)}
' for g, c in groups) + '

Check the SHA-256 checksum of a download before installing.

' +def _manifest_card(asset): + name = _html.escape(asset["name"]) + platforms = _html.escape(", ".join(asset["platforms"])) + os_attr = _html.escape(_OS.get(asset["id"], asset["id"])) + url = asset.get("url") or "" + if asset.get("status") == "published" and url.startswith(("https://", "http://")): + body = f'

Unsigned testing build

Download {_html.escape(asset.get("filename") or "file")}

' + if asset.get("sha256"): + body += f'

sha256 {_html.escape(asset["sha256"])}

' + else: + body = f'

Not available. {_html.escape(asset.get("reason") or "")}

' + docs = f'

Instructions

' + return f'

{name}

{platforms}

{body}{docs}
' + +_manifest_cards = "".join(_manifest_card(asset) for asset in _MANIFEST["assets"]) +_empty = "" if any(asset.get("status") == "published" and str(asset.get("url") or "").startswith(("https://", "http://")) for asset in _MANIFEST["assets"]) else '

No installer file is in this manifest. There is no download link and no QR code.

' +downloads = ( + '

Download Komboe

' + '

Installers come from the same release manifest as the signed-in vault. This browser looks like: this device

' + '

A link appears only when that manifest has a real file. Store listings for other password managers are not Komboe.

' + '

Release manifest

' + + _empty + + f'
{_manifest_cards}
' + + '

Web vault

' + + '
🌐

Web vault

No install. Sign in on the home page.

Sign in
' + + '

Check the SHA-256 checksum of a download before installing. ' + + SH_SHORT + " " + SWITCH + "

" +) def auth(mode): sg = mode == "signup" return f"""
Komboe logo

{"Create your Komboe account" if sg else "Log in to Komboe"}

@@ -196,7 +218,7 @@ def auth(mode): "features.html": ("Features", "Every Komboe feature, with links to other products that offer the same idea.", "Features"), "security.html": ("Security and zero-knowledge", "How Komboe keeps your vault zero-knowledge: on-device encryption, no backdoor and no admin password reset.", "Security"), "pricing.html": ("Pricing", "Komboe plans for individuals, families and businesses. The free tier is available now.", "Pricing"), - "downloads.html": ("Downloads", "Download Komboe for phone, desktop, browser, the web, the command line and a self-hosted server.", "Downloads"), + "downloads.html": ("Downloads", "Komboe downloads follow the release manifest. An installer link appears only when a build published that file.", "Downloads"), "developers.html": ("Developers", "Komboe developer resources: source, self-hosting, command line, the assistant and security disclosure.", "Developers"), "login.html": ("Log in", "Sign in to Komboe from the home page. One account for every Komboe app.", "Log in"), "signup.html": ("Sign up", "Create your Komboe account with your email and master password.", "Sign up"), @@ -220,11 +242,19 @@ def u(f): open("sitemap.xml", "w").write('\n\n' + "".join(f"{u(f)}\n" for f in pages if f not in ("privacy.html", "terms.html")) + "\n") open("robots.txt", "w").write("User-agent: *\nDisallow: /\n" if STAGING else f"User-agent: *\nAllow: /\nSitemap: {BASE}/sitemap.xml\n") -DASH = '''

Your vault

Loading your vault…

''' +DASH = '''

Your vault

Loading your vault…

''' os.makedirs("vault/signup", exist_ok=True) open("vault/index.html", "w").write(page("vault/index.html", "Your vault", DASH, "../", "Your Komboe vault.")) open("vault/signup/index.html", "w").write(page("vault/signup/index.html", "Create account", '

Create account

Create account

', "../../", "Create a Komboe account.")) os.makedirs(".well-known", exist_ok=True) open(".well-known/security.txt", "w").write("Contact: mailto:security@komboe.com\nExpires: " + (_d.datetime.now(_d.timezone.utc) + _d.timedelta(days=365)).strftime("%Y-%m-%dT00:00:00Z") + "\nPreferred-Languages: en\nCanonical: " + BASE + "/.well-known/security.txt\n") open("llms.txt", "w").write("# Komboe\n\n> Komboe is an open-source, zero-knowledge password manager. The master password stays on the device. There is no backdoor and no admin password reset.\n\n## Pages\n" + "".join(f"- [{META[f][0]}]({u(f)}): {META[f][1]}\n" for f in pages) + "\n") +open("komboe-downloads.json", "w").write(_json.dumps(_MANIFEST, indent=2) + "\n") print("built", len(pages), "pages") diff --git a/apps/web-marketing/downloads.html b/apps/web-marketing/downloads.html index c7c43cf1..76440609 100644 --- a/apps/web-marketing/downloads.html +++ b/apps/web-marketing/downloads.html @@ -1,13 +1,13 @@ -Downloads · Komboe - - +Downloads · Komboe + +
-

Download Komboe

Every Komboe app. This browser looks like: this device

Komboe is pre-release. Installers will appear here with the first builds.

Phone

Detected for you
📱

Phone

Autofill service and biometric unlock.

Version and SHA-256 checksum will be published with the first release.Get the phone app: coming soon
Detected for you
📱

Other phones

A later build.

Coming soon

Desktop

Detected for you
💻

Windows

Installer for current Windows systems.

Version and SHA-256 checksum will be published with the first release.Download: coming soon
Detected for you
💻

macOS

Current macOS, both chip families.

Version and SHA-256 checksum will be published with the first release.Download: coming soon
Detected for you
💻

Linux

Packages for common Linux systems.

Version and SHA-256 checksum will be published with the first release.Download: coming soon

Browser

Detected for you
🧩

Chrome

Autofill and save logins in Chrome.

Version and SHA-256 checksum will be published with the first release.Add to Chrome: coming soon
Detected for you
🧩

Edge

Autofill and save logins in Edge.

Version and SHA-256 checksum will be published with the first release.Add to Edge: coming soon
Detected for you
🧩

Firefox

Autofill and save logins in Firefox.

Version and SHA-256 checksum will be published with the first release.Add to Firefox: coming soon
Detected for you
🧩

Brave

Autofill and save logins in Brave.

Version and SHA-256 checksum will be published with the first release.Add to Brave: coming soon
Detected for you
🧩

Safari

Autofill and save logins in Safari.

Version and SHA-256 checksum will be published with the first release.Add to Safari: coming soon

Web, command line and server

🌐

Web vault

No install. Sign in on the home page.

Sign in
Detected for you
⌨️

Command line

Script your vault and inject secrets into a process.

Installers ship with a SHA-256 checksum. Do not pipe a downloaded script into a shell.

Version and SHA-256 checksum will be published with the first release.Download: coming soon
Detected for you
🗄️

Self-host server

Run your own Komboe sync server from the published container image.

Local accounts are the default. Optional enterprise single sign-on can be added by the operator.

Your self-hosted server never contacts Komboe, and outside services stay off unless you turn them on or run a check. Set your server URL in the apps when you first sign in. The switch list is in docs/RUNNING.md in the Komboe source.

Version and SHA-256 checksum will be published with the first release.Get server: coming soon
Detected for you
✨

Assistant

The signed-in assistant on this site uses the Komboe agent. It does not read vault items.

komboe mcp
Version and SHA-256 checksum will be published with the first release.Included: coming soon

Check the SHA-256 checksum of a download before installing.

+

Download Komboe

Installers come from the same release manifest as the signed-in vault. This browser looks like: this device

A link appears only when that manifest has a real file. Store listings for other password managers are not Komboe.

Release manifest

No installer file is in this manifest. There is no download link and no QR code.

Browser extension (Chromium)

Chrome, Edge, Brave, Opera, Vivaldi, Cerebro

Not available. The clients image has not published komboe-chrome-mv3.zip into this vault.

Instructions

Browser extension (Firefox)

Firefox, Tor Browser

Not available. No komboe-firefox.zip is in the release artifacts.

Instructions

Desktop for Windows

Windows

Not available. No verified current Windows installer. Older unsigned builds are not offered.

Instructions

Desktop for Linux (.deb)

Linux

Not available. No verified current Linux .deb. Older unsigned builds are not offered.

Instructions

Desktop for Linux (AppImage)

Linux

Not available. No verified current Linux AppImage. Older unsigned builds are not offered.

Instructions

Desktop for macOS

macOS

Not available. No macOS build. Apple signing and notarization are not set up.

Instructions

Android

Android

Not available. No production Android APK. An internal debug APK exists on a build machine and is not published: device login, autofill, PIN, and biometric are unverified.

Instructions

iPhone and iPad

iOS

Not available. No iOS build, App Store listing, or TestFlight. There is no QR code.

Instructions

Web vault

🌐

Web vault

No install. Sign in on the home page.

Sign in

Check the SHA-256 checksum of a download before installing. Your self-hosted server never contacts Komboe, and outside services stay off unless you turn them on or run a check. Set your server URL in the apps when you first sign in. The switch list is in docs/RUNNING.md in the Komboe source.

diff --git a/apps/web-marketing/index.html b/apps/web-marketing/index.html index 2120e2e6..cae02ece 100644 --- a/apps/web-marketing/index.html +++ b/apps/web-marketing/index.html @@ -11,7 +11,7 @@

Your secrets.
Only yours.

Komboe is a zero-knowledge password manager. Your master password stays on this device, and there is no backdoor and no admin password reset.