From 205b0678cc67ecbde18278b8f46e7f8270fb519c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:04:01 -0400 Subject: [PATCH] Pin that pre-removal passwords still sign in Manual passwords were removed from the widget on 2026-07-31. That change took away the CONTROLS -- you can no longer type your own password when creating, recovering, or rotating -- but it did not change the derivation chain and it did not add any check on the login path. Accounts created with a hand-typed password therefore still sign in exactly as before. That distinction is load-bearing and was previously unwritten. BitLogin keeps no server-side record, so if sign-in ever stopped accepting a password shape it had previously issued, those accounts would be reachable only through their recovery phrase. Nothing in the suite said otherwise, and securityPolicy.test.ts pins only the absence of the creation/recovery/ rotation controls. Four tests: register-then-login with a hand-typed password, wrong password still refused, the real migration path (sign in with the old typed password, rotate onto a generated one, old one stops working), and derivation across the password shapes the old UI accepted -- short, spaced, symbol-laden, whitespace-padded, unicode, and 256 characters. No length floor, charset rule, or entropy gate exists on the derivation path, which is exactly what keeps those accounts reachable. Separately confirmed by diff rather than by test: normalize.ts, argon2id.ts, hkdf.ts and scalarExpand.ts are unchanged between efd994c -- the build currently vendored by bitroad, bitunlock, bitvid and satisfied -- and HEAD. Had any of them moved, every password would break, generated ones included. Suite 185 -> 189. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u --- .../src/account/legacyPassword.e2e.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 packages/core/src/account/legacyPassword.e2e.test.ts diff --git a/packages/core/src/account/legacyPassword.e2e.test.ts b/packages/core/src/account/legacyPassword.e2e.test.ts new file mode 100644 index 0000000..70e798b --- /dev/null +++ b/packages/core/src/account/legacyPassword.e2e.test.ts @@ -0,0 +1,136 @@ +/* + * Compatibility check for accounts created before manual passwords were removed + * from the widget UI. + * + * The removal (2026-07-31 hardening) took the *controls* away: you can no + * longer type your own password when creating, recovering, or rotating an + * account. It did not change the derivation chain, and nothing on the login + * path inspects the shape of the password — `derivePasswordKeys` normalizes any + * string to bytes and runs Argon2id over it. + * + * That distinction matters because BitLogin has no server-side record: if + * sign-in ever stopped accepting a password shape it previously issued, those + * accounts would be unrecoverable except through their recovery phrase. These + * tests assert the distinction holds, so a future change that quietly adds a + * format gate to the login path fails here rather than in someone's browser. + */ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { MockRelay } from "../test-support/mockRelay.js"; +import { registerAccount } from "./create.js"; +import { loginWithPassword } from "./login.js"; +import { changePassword } from "./changePassword.js"; +import { derivePasswordKeys } from "./normalize.js"; +import { bytesToHex } from "../crypto/encoding.js"; + +const ARGON2_TIMEOUT = 20000; + +// The kinds of password a human actually picks, which the old UI accepted: +// short, no spaces, mixed case, symbols, unicode, and a very long passphrase. +const LEGACY_PASSWORDS = [ + "hunter2", + "correct horse battery staple", + "P@ssw0rd!2019", + " leading and trailing ", + "übergrößé-πassword", + "x".repeat(256), +]; + +describe("passwords created before manual entry was removed still work", () => { + let relays: MockRelay[] = []; + let vaultRelayUrls: string[] = []; + + beforeEach(async () => { + relays = await Promise.all([MockRelay.start(), MockRelay.start(), MockRelay.start()]); + vaultRelayUrls = relays.map((r) => r.url); + }); + + afterEach(async () => { + await Promise.all(relays.map((r) => r.close())); + }); + + it( + "registers and signs back in with a hand-typed password", + async () => { + const password = "correct horse battery staple"; + const registered = await registerAccount({ + loginName: "legacy-user", + password, + vaultRelayUrls, + }); + + const login = await loginWithPassword({ + loginName: "legacy-user", + password, + vaultRelayUrls, + }); + + expect(login.everydayPublicKey).toBe(registered.everydayPublicKey); + expect(login.accountId).toBe(registered.accountId); + }, + ARGON2_TIMEOUT, + ); + + it( + "still refuses the wrong password for such an account", + async () => { + const password = "P@ssw0rd!2019"; + await registerAccount({ loginName: "legacy-two", password, vaultRelayUrls }); + + await expect( + loginWithPassword({ + loginName: "legacy-two", + password: "P@ssw0rd!2020", + vaultRelayUrls, + }), + ).rejects.toThrow(); + }, + ARGON2_TIMEOUT, + ); + + it( + "lets a legacy-password account rotate onto a generated one", + async () => { + // The migration path a real user takes: sign in with the old typed + // password, rotate to a generated one. Both halves must work. + const oldPassword = "hunter2"; + await registerAccount({ loginName: "legacy-rotate", password: oldPassword, vaultRelayUrls }); + + const newPassword = "casket vividly rerun dusk hazily uncle"; + await changePassword({ + loginName: "legacy-rotate", + oldPassword, + newPassword, + vaultRelayUrls, + }); + + const login = await loginWithPassword({ + loginName: "legacy-rotate", + password: newPassword, + vaultRelayUrls, + }); + expect(login.everydayPublicKey).toBeTruthy(); + + await expect( + loginWithPassword({ loginName: "legacy-rotate", password: oldPassword, vaultRelayUrls }), + ).rejects.toThrow(); + }, + ARGON2_TIMEOUT, + ); + + it( + "derives keys from every legacy password shape without inspecting it", + async () => { + // No length floor, no charset rule, no entropy gate on the derivation + // path — that is what keeps old accounts reachable. + const seen = new Set(); + for (const password of LEGACY_PASSWORDS) { + const { locatorPrivateKey, capsuleKey } = await derivePasswordKeys(password, "someone"); + expect(locatorPrivateKey).toHaveLength(32); + expect(capsuleKey).toHaveLength(32); + seen.add(bytesToHex(locatorPrivateKey)); + } + expect(seen.size).toBe(LEGACY_PASSWORDS.length); + }, + ARGON2_TIMEOUT * 3, + ); +});