Skip to content

fix(cli): sign in to auth-v2 accounts without ever sending the password - #8

Merged
chogarcia merged 2 commits into
mainfrom
fix/auth-v2-desktop
Oct 1, 2026
Merged

chogarcia merged 2 commits into
mainfrom
fix/auth-v2-desktop

Conversation

@chogarcia

@chogarcia chogarcia commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

0.3.0 posts the raw password straight to Supabase GoTrue (src/auth.mjs). Accounts on the client-derived login secret (auth v2: every new account, and every account migrated on its next web/app sign-in) cannot sign in through sf login / sf push / sf sync. Each failed attempt also sends the password to the server.

What changes

  • Sign-in goes through ShieldFive's version-aware route, the one Android uses: POST /api/auth/salt first, then POST /api/mobile/auth/login, then the session is installed in supabase-js and the existing TOTP step-up runs.
    • version 2 → { email, loginSecret }. The password is not sent.
    • version 1 with migration on → { email, password, loginSecret, authSalt }. This is the web/Android migration body: the server checks the password, then rotates the account to v2.
    • version 1 with migration off → { email, password }.
    • Salt lookup fails in any way (network error, 403, 429, 5xx, non-JSON, unknown or missing version, missing salt) → sign-in stops before any credential is posted. The web's fetchAuthSalt falls back to v1 here. The CLI deliberately does not: for a v2 account that fallback would leak the password and still fail to sign in.
  • Login secret derivation is base64url(Argon2id("shieldfive/v1/auth/login-secret\n" + password, authSalt, moderate)): Argon2id v0x13, t=3, m=256 MiB, p=1, 32 bytes. Salts longer than 16 bytes go through HKDF-SHA-256 compression (zero salt, info shieldfive/v1/argon2id/salt-compression). The salt can be base64 or base64url. The password is used exactly as typed, not trimmed. That matches web sign-in, signup, reset and change-password (resetPasswordClientForm.tsx and settingsContentClient.tsx both say "NOT trimmed"), so it is what the secret stored in GoTrue was derived from.
  • Vault unlock tries the trimmed password first, because the web keyring (utils/keyring.ts) trims before every wrap and unwrap. If AES-GCM rejects it and the password has surrounding whitespace, it retries with the password as typed. Before this, a vault password with leading or trailing spaces could not open a web-created vault from the CLI.
  • Argon2id backend: argon2@0.45.1, pinned exactly, is now an optionalDependency. When the native module is present it runs async on the libuv pool, so the event loop keeps running. When it is missing, src/argon2.mjs falls back to @shieldfive/crypto's libsodium WASM path, which gives identical bytes and is always installed. Parameters are unchanged on both paths.
  • Email is trimmed and lowercased before the lookup, as on Android.
  • docs/desktop-readiness.md is removed from the PR. It is desktop planning, not part of this fix, and it named a customer in a public repo. The text is still in commit 4910371 if you want to move it somewhere private.

Verified

  • Byte-identical to web/Android: pinned vectors YRxqCqxx… (16-byte salt) and -3MAPAD6… (32-byte salt). These are the same vectors Android's loginSecret.test.ts pins, produced by the real web library. They pass on the native backend and, in a fresh process, on the forced WASM backend.
  • Native == WASM == @shieldfive/crypto deriveMasterSecret (the web vault reference) for moderate with 16- and 32-byte salts and a Unicode passphrase, and for sensitive (1 GiB). That closes the "sensitive-preset interoperability pending" gap.
  • Server contract checked against web/app/api/auth/salt/route.ts, app/api/mobile/auth/login/route.ts and utils/auth/loginSecretSignIn.ts: request shapes, v2 refusing a body without loginSecret, and the accessToken/refreshToken response fields.
  • MFA step-up: the v2 path with nextLevel=aal2 challenges and verifies, returns the AAL2 token, and returns the refresh token from the stepped-up session. The existing auth.mfa tests are unchanged and pass.
  • Install: I ran npm pack and then npm i -g from the tarball into a temp prefix twice. On macOS arm64 the native backend is used. With the native build forced to fail (npm_config_build_from_source=true CC=false), the install still exits 0, npm drops argon2, sf runs, and the backend is wasm. argon2 0.45.1 ships prebuilds for darwin-arm64, linux-x64/arm64/armv7 (glibc and musl), win32-x64 and freebsd. It has none for Intel macOS or Windows on ARM; those get the WASM path (slower, same keys). Its only install script is node-gyp-build, which uses a prebuild or compiles. There is no network fetch and no other postinstall.

No-password-leak test

test/auth.noPasswordLeak.test.mjs records everything that leaves the process: every call to signIn's fetch, the global fetch (which is stubbed to trap), and every argument passed to the Supabase client (createClient, setSession, mfa.challenge, mfa.verify, signInWithPassword). It then searches each URL and body for the password:

  • v2 success, v2 + MFA step-up, v2 login 401, v2 login 429, v2 login route unreachable → the password appears nowhere.
  • Ten salt-lookup failure shapes → the password appears nowhere, the login route is never called, and no Supabase client is created.
  • v1 (migration on and off) → the password appears in exactly one request: POST /api/mobile/auth/login. It never goes to the salt lookup or Supabase.
  • supabase.auth.signInWithPassword and the global fetch are never called.

Tests

npm test: 127/127 pass locally (163 s; the 1 GiB sensitive interop test takes most of that).

Release (founder)

Follows the repo convention from #7 ("release: 0.3.0"): the fix PR adds an ## Unreleased CHANGELOG entry, and the version bump goes in a separate release PR. There is no version bump here. This repo has no publish workflow, so a tag push does not publish; publishing is manual.

  1. Merge this PR.
  2. Open release: 0.3.1: bump package.json to 0.3.1, run npm install --package-lock-only so the lockfile matches, and rename ## Unreleased to ## 0.3.1 - <date> (keep an empty ## Unreleased above it). Merge.
  3. Smoke test from a clean checkout of main: npm ci && node src/cli.mjs login against a disposable QA account that is already on auth v2. Then sf push one small file, sf verify it, and sf logout.
  4. npm publish (with npm login first if needed), then git tag v0.3.1 && git push origin v0.3.1 so the tag marks what was published.
  5. Check it: npm i -g @shieldfive/cli@0.3.1 && sf --help.

@chogarcia chogarcia changed the title fix(cli): support migrated accounts with native async key derivation fix(cli): sign in to auth-v2 accounts without ever sending the password Sep 30, 2026
@chogarcia
chogarcia marked this pull request as ready for review September 30, 2026 21:51
…ive is missing

- Salt lookup network errors and 429 now abort with a clear message before
  anything is posted; the password is only sent for an account the server
  affirmatively reports as version 1, and only to the version-aware login route.
- test/auth.noPasswordLeak.test.mjs captures every outbound request (signIn
  fetch, global fetch, every Supabase client call) across v2 success, v2 MFA
  step-up, v2 401/429/unreachable, ten salt-lookup failure shapes and both v1
  arms, and asserts where the password may appear.
- argon2 moves to optionalDependencies (still pinned 0.45.1). A missing native
  module falls back to @shieldfive/crypto's libsodium WASM path, so npm i -g
  never fails on a platform without a prebuilt binary. Tests pin native ==
  WASM == @shieldfive/crypto for moderate (16/32-byte salts) and sensitive.
- Vault unlock tries the trimmed password first, as the web keyring wraps
  with, then the password as typed. The login secret stays untrimmed, as web
  sign-in, signup, reset and change-password derive it.
- Email is trimmed and lowercased before the salt lookup, as on Android.
- Drop docs/desktop-readiness.md: desktop planning is not this fix and does
  not belong in the public repo.
- CHANGELOG Unreleased entry; README layout.
@chogarcia
chogarcia merged commit ef13019 into main Oct 1, 2026
1 check passed
@chogarcia chogarcia mentioned this pull request Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant