diff --git a/CHANGELOG.md b/CHANGELOG.md index 203c15e..995490d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- **Sign-in works for accounts on ShieldFive's current sign-in scheme.** 0.3.0 + posted your password straight to Supabase, which fails for every account that + signs in with a client-derived login secret (all new accounts, and every + account migrated on its next web or app sign-in), and sent the password to + the server on each failed attempt. `sf login`, `sf push` and `sf sync` now ask + ShieldFive how the account signs in, derive the login secret locally + (Argon2id, the same parameters and bytes as the web and Android apps) and + send only that. The raw password is sent only for an account the server + reports as not yet migrated, and only to ShieldFive's sign-in route. If that + lookup fails for any reason, sign-in stops before anything is sent. +- A vault password typed with leading or trailing spaces now unlocks a vault + created on the web, which trims them. + +### Changed + +- Argon2id runs natively (the `argon2` package, off the main thread) when it + installs, and falls back to the bundled WebAssembly implementation when it + does not, so installation never fails on a platform without a prebuilt + binary. Both produce identical keys; parameters are unchanged. + ## 0.3.0 - 2026-09-16 ### Added diff --git a/README.md b/README.md index 8e56221..fc5b811 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,11 @@ file — a machine-checked demonstration that only ciphertext is uploaded. ## Layout - `src/cli.mjs` — the `sf` entry point (argument parsing, command dispatch) -- `src/auth.mjs` — Supabase sign-in to a Bearer token +- `src/auth.mjs` — sign-in to a Bearer token through ShieldFive's sign-in route, + with two-factor step-up +- `src/loginSecret.mjs` — the login secret derived from your password; for + accounts on the current scheme the password itself is never sent +- `src/argon2.mjs` — Argon2id, native when available, WebAssembly otherwise - `src/vault.mjs` / `src/unlock.mjs` — fetch the wrapped vault key, unwrap the root key from your password (Argon2id / PBKDF2) - `src/uploadCrypto.mjs` — filename and chunk encryption, upload proof, multipart diff --git a/package-lock.json b/package-lock.json index 8c7612e..6af7ac4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,18 @@ }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "argon2": "0.45.1" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT", + "optional": true + }, "node_modules/@noble/ciphers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", @@ -78,6 +88,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/@shieldfive/crypto": { "version": "1.0.0-beta.1", "resolved": "https://registry.npmjs.org/@shieldfive/crypto/-/crypto-1.0.0-beta.1.tgz", @@ -183,6 +203,56 @@ "node": ">=22.0.0" } }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "optional": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -192,6 +262,13 @@ "node": ">=20.0.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, "node_modules/libsodium-sumo": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz", @@ -207,11 +284,82 @@ "libsodium-sumo": "^0.8.0" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "optional": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } } } } diff --git a/package.json b/package.json index 7139587..492bed0 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,8 @@ "@shieldfive/crypto": "^1.0.0-beta.1", "@supabase/supabase-js": "^2", "libsodium-wrappers-sumo": "^0.8.2" + }, + "optionalDependencies": { + "argon2": "0.45.1" } } diff --git a/src/argon2.mjs b/src/argon2.mjs new file mode 100644 index 0000000..d33bfce --- /dev/null +++ b/src/argon2.mjs @@ -0,0 +1,69 @@ +import { hkdfSync } from 'node:crypto' + +// Argon2id master-secret derivation, byte-identical to +// @shieldfive/crypto/kdf/argon2id deriveMasterSecret (the web's and the +// vault's reference implementation): libsodium presets, Argon2id v0x13, +// parallelism 1, 32-byte output, and salts longer than 16 bytes compressed +// through HKDF-SHA-256 (zero salt, info 'shieldfive/v1/argon2id/salt-compression'). +// +// Two backends, same bytes: +// +// - native `argon2` (an optionalDependency): async, runs on the libuv thread +// pool so the event loop keeps turning, and several times faster. +// - @shieldfive/crypto over libsodium WASM: always installed. Used when the +// native module is missing, which is expected on platforms `argon2` ships no +// prebuilt binary for (Intel macOS, Windows on ARM) and where compiling it +// failed. npm drops a failed optional dependency instead of failing the +// install, so `npm i -g @shieldfive/cli` never hard-fails on it. +// +// Parameters are never reduced on either path. test/argon2.backends.test.mjs +// pins the two backends against each other and against web-produced vectors. + +const PRESETS = { + moderate: { timeCost: 3, memoryCost: 262144 }, // OPSLIMIT/MEMLIMIT_MODERATE (256 MiB) + sensitive: { timeCost: 4, memoryCost: 1048576 }, // OPSLIMIT/MEMLIMIT_SENSITIVE (1 GiB) +} + +let nativePromise +async function loadNative() { + if (process.env.SF_ARGON2_BACKEND === 'wasm') return null + nativePromise ??= import('argon2').then( + (m) => (typeof m.hash === 'function' ? m : m.default ?? null), + () => null, + ) + return nativePromise +} + +/** 'native' or 'wasm': which backend the next derivation will use. */ +export async function argon2Backend() { + return (await loadNative()) ? 'native' : 'wasm' +} + +function validate({ passphrase, salt, preset }) { + if (typeof passphrase !== 'string' || !passphrase) throw new Error('Passphrase is required') + if (!(salt instanceof Uint8Array) || salt.length < 16) throw new Error('Argon2id salt must be at least 16 bytes') + const params = Object.hasOwn(PRESETS, preset) ? PRESETS[preset] : null + if (!params) throw new Error(`Unsupported Argon2id preset: ${preset}`) + return params +} + +export async function deriveMasterSecret({ passphrase, salt, preset = 'moderate' }) { + const params = validate({ passphrase, salt, preset }) + const native = await loadNative() + if (!native) { + const { deriveMasterSecret: wasmDerive } = await import('@shieldfive/crypto/kdf/argon2id') + const { masterSecret } = await wasmDerive({ passphrase, salt, preset }) + return { masterSecret, salt, preset } + } + const usableSalt = salt.length === 16 ? Buffer.from(salt) : Buffer.from(hkdfSync( + 'sha256', salt, Buffer.alloc(32), 'shieldfive/v1/argon2id/salt-compression', 16, + )) + const input = Buffer.from(passphrase, 'utf8') + try { + const masterSecret = await native.hash(input, { + ...params, salt: usableSalt, type: native.argon2id, version: 0x13, + parallelism: 1, hashLength: 32, raw: true, + }) + return { masterSecret, salt, preset } // a Buffer, i.e. a Uint8Array the caller zeroes + } finally { input.fill(0); usableSalt.fill(0) } +} diff --git a/src/auth.mjs b/src/auth.mjs index 2c1991e..b78977a 100644 --- a/src/auth.mjs +++ b/src/auth.mjs @@ -1,6 +1,7 @@ // ShieldFive CLI — authentication. // -// Signs in via Supabase to obtain a Bearer access token, elevating to AAL2 when +// Signs in through ShieldFive's version-aware login route to obtain a Bearer +// session, then installs it in Supabase and elevates to AAL2 when // the account has two-factor authentication (TOTP) enabled. The upload routes // accept this token, and their server-side MFA gate requires an AAL2 token for // enrolled accounts — so the step-up here is what makes `sf push` / `sf sync` @@ -11,6 +12,7 @@ // via getAuthenticatorAssuranceLevel. import { createClient } from '@supabase/supabase-js' +import { buildSignInCredentials } from './loginSecret.mjs' // If the account requires an AAL2 step-up, run the TOTP challenge/verify flow // and return the elevated access token. Returns null when no step-up is needed @@ -77,24 +79,48 @@ export async function stepUpMfaIfRequired({ supabase, getTotpCode }) { } export async function signIn({ + apiBaseUrl, supabaseUrl, anonKey, email, password, getTotpCode, + fetchImpl = fetch, + createClientImpl = createClient, }) { - const supabase = createClient(supabaseUrl, anonKey, { + // The raw password is never handed to Supabase directly. The salt lookup + // decides what the version-aware login route receives: only the derived + // login secret for a version-2 account; the password only for an account the + // server affirmatively reported as version 1. test/auth.noPasswordLeak.test.mjs + // pins this across every path. + email = String(email ?? '').trim().toLowerCase() + const credentials = await buildSignInCredentials({ apiBaseUrl, email, password, fetchImpl }) + let response + try { + response = await fetchImpl(new URL('/api/mobile/auth/login', apiBaseUrl), { + method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ email, ...credentials }), signal: AbortSignal.timeout(30_000), + }) + } catch { + throw new Error('Sign-in failed: could not reach ShieldFive. Check your connection and try again.') + } + const payload = await response.json().catch(() => null) + if (!response.ok || typeof payload?.accessToken !== 'string' || !payload.accessToken || + typeof payload?.refreshToken !== 'string' || !payload.refreshToken) { + throw new Error(response.status === 429 ? 'Too many sign-in attempts. Try again shortly.' : 'Sign-in failed. Check your credentials and try again.') + } + const supabase = createClientImpl(supabaseUrl, anonKey, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, }, }) - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, + const { data, error } = await supabase.auth.setSession({ + access_token: payload.accessToken, + refresh_token: payload.refreshToken, }) - if (error) throw new Error(`Sign-in failed: ${error.message}`) + if (error) throw new Error('Could not establish the sign-in session. Please try again.') const aal1Token = data.session?.access_token if (!aal1Token) { diff --git a/src/cli.mjs b/src/cli.mjs index 32a7bac..333fb42 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -144,6 +144,7 @@ async function authAndUnlock(cfg) { process.stderr.write('signing in…\n') const { accessToken } = await signIn({ + apiBaseUrl: cfg.apiBaseUrl, supabaseUrl: cfg.supabaseUrl, anonKey: cfg.anonKey, email: cfg.email, diff --git a/src/loginSecret.mjs b/src/loginSecret.mjs new file mode 100644 index 0000000..223ba20 --- /dev/null +++ b/src/loginSecret.mjs @@ -0,0 +1,63 @@ +// Client-side derivation of the LOGIN SECRET, byte-identical to the web +// (utils/auth/loginSecret.ts) and Android (src/features/auth/loginSecret.ts): +// +// loginSecret = base64url_unpadded( +// Argon2id( DOMAIN || "\n" || password, authSalt, moderate ) ) +// +// The password is used EXACTLY as typed: no trim, no Unicode normalisation. +// That is what web sign-in, signup, reset and change-password all feed this +// derivation, so it is what the secret GoTrue holds was derived from. (The +// vault KDF is different: the web keyring trims the vault password. See +// src/unlock.mjs.) +import { deriveMasterSecret } from './argon2.mjs' + +const DOMAIN = 'shieldfive/v1/auth/login-secret' + +export async function deriveLoginSecret({ password, authSalt }) { + if (typeof password !== 'string' || !password) throw new Error('Password is required') + if (typeof authSalt !== 'string' || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(authSalt)) { + throw new Error('Invalid secure sign-in salt') + } + // Node's base64 decoder accepts base64 and base64url alike, as the web does. + const salt = new Uint8Array(Buffer.from(authSalt, 'base64')) + if (salt.length < 16 || salt.length > 64) throw new Error('Invalid secure sign-in salt') + const { masterSecret } = await deriveMasterSecret({ + passphrase: `${DOMAIN}\n${password}`, salt, preset: 'moderate', + }) + try { return Buffer.from(masterSecret).toString('base64url') } + finally { masterSecret.fill(0) } +} + +/** + * The credential half of the sign-in body, per POST /api/auth/salt: + * + * version 2 -> { loginSecret } (no password) + * version 1, migration on -> { password, loginSecret, authSalt } (server verifies, then migrates) + * version 1, migration off -> { password } + * + * The raw password is only ever returned when the server AFFIRMATIVELY said + * version 1. Unlike the web's fetchAuthSalt, a failed, throttled or malformed + * lookup never falls back to version 1: for a migrated account that would post + * the password to the server and still not sign in. Throws before anything is + * posted to the login route. + */ +export async function buildSignInCredentials({ apiBaseUrl, email, password, fetchImpl = fetch }) { + let response + try { + response = await fetchImpl(new URL('/api/auth/salt', apiBaseUrl), { + method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ email }), signal: AbortSignal.timeout(30_000), + }) + } catch { + throw new Error('Unable to prepare secure sign-in. Check your connection and try again.') + } + if (response.status === 429) throw new Error('Too many sign-in attempts. Try again shortly.') + const body = await response.json().catch(() => null) + if (!response.ok || !body || ![1, 2].includes(body.authVersion) || + typeof body.authSalt !== 'string' || !body.authSalt) { + throw new Error('Unable to prepare secure sign-in. Please try again.') + } + if (body.authVersion === 1 && body.migrationEnabled !== true) return { password } + const loginSecret = await deriveLoginSecret({ password, authSalt: body.authSalt }) + return body.authVersion === 2 ? { loginSecret } : { password, loginSecret, authSalt: body.authSalt } +} diff --git a/src/unlock.mjs b/src/unlock.mjs index a66be76..941e58c 100644 --- a/src/unlock.mjs +++ b/src/unlock.mjs @@ -10,7 +10,7 @@ import { webcrypto } from 'node:crypto' -import { deriveMasterSecret } from '@shieldfive/crypto/kdf/argon2id' +import { deriveMasterSecret } from './argon2.mjs' const subtle = webcrypto.subtle @@ -62,9 +62,9 @@ export async function deriveUserKey({ salt, preset: argon2Preset, }) - return subtle.importKey('raw', masterSecret, { name: 'AES-GCM' }, false, [ - 'decrypt', - ]) + try { + return await subtle.importKey('raw', masterSecret, { name: 'AES-GCM' }, false, ['decrypt']) + } finally { masterSecret.fill(0) } } throw new Error(`Unsupported vault KDF: ${kdf}`) } @@ -73,21 +73,37 @@ export async function deriveUserKey({ * Unwrap the vault root key from the password. `vaultKey` carries the base64 * fields of the server's vault_keys row (same shape as the export bundle's * vault.json): ukSalt, ukIv, ukKdf, ukIterations?, ukArgon2Preset?, rkWrappedByUk. + * + * The web keyring (utils/keyring.ts) TRIMS the vault password before every + * wrap and unwrap, so a vault created or re-keyed on the web was wrapped under + * the trimmed password. That is tried first. Only if it fails AES-GCM + * authentication, and the password actually has surrounding whitespace, is the + * password tried exactly as typed (a vault wrapped by a client that does not + * trim). Nothing here is sent anywhere. * @returns {Promise} the 32-byte root key */ export async function unlockRootKey({ password, vaultKey }) { - const uk = await deriveUserKey({ - password, - salt: base64ToBytes(vaultKey.ukSalt), - kdf: vaultKey.ukKdf, - iterations: vaultKey.ukIterations, - argon2Preset: vaultKey.ukArgon2Preset, - }) - return aesGcmDecrypt( - uk, - base64ToBytes(vaultKey.ukIv), - base64ToBytes(vaultKey.rkWrappedByUk), - ) + if (typeof password !== 'string' || !password) throw new Error('Vault password is required') + const trimmed = password.trim() + const candidates = trimmed && trimmed !== password ? [trimmed, password] : [password] + for (let i = 0; i < candidates.length; i++) { + const uk = await deriveUserKey({ + password: candidates[i], + salt: base64ToBytes(vaultKey.ukSalt), + kdf: vaultKey.ukKdf, + iterations: vaultKey.ukIterations, + argon2Preset: vaultKey.ukArgon2Preset, + }) + try { + return await aesGcmDecrypt( + uk, + base64ToBytes(vaultKey.ukIv), + base64ToBytes(vaultKey.rkWrappedByUk), + ) + } catch (err) { + if (i === candidates.length - 1 || err?.name !== 'OperationError') throw err + } + } } /** Recovery-key path: unwrap the root key with the base64 recovery key. */ diff --git a/test/agent.server.test.mjs b/test/agent.server.test.mjs index dfd59e1..d53e0cb 100644 --- a/test/agent.server.test.mjs +++ b/test/agent.server.test.mjs @@ -378,19 +378,22 @@ test('status probes do not count as use: polling cannot keep the agent unlocked' }) test('logout during a sync stops it, and the upload in flight keeps an intact key', async () => { + let uploadStarted + const started = new Promise((resolve) => { uploadStarted = resolve }) const h = await harness({ uploadHook: async ({ phase }) => { - if (phase === 'after-read') await sleep(150) + if (phase === 'after-read') { uploadStarted(); await sleep(150) } }, }) const folder = await mkdtemp('/tmp/sfa-sync-') for (let i = 0; i < 5; i++) await writeFile(join(folder, `f${i}.txt`), `file ${i}`) const syncing = h.call('sync', { folder }, { timeoutMs: 0 }) - await sleep(80) + const rejected = assert.rejects(syncing, (e) => e.code === 'locked') + await started await h.call('logout') - await assert.rejects(syncing, (e) => e.code === 'locked') + await rejected assert.ok(h.calls.uploads.length >= 1) assert.ok( h.calls.uploads.every((u) => u.keyIntactAtEnd), diff --git a/test/argon2.backends.test.mjs b/test/argon2.backends.test.mjs new file mode 100644 index 0000000..0bf05b1 --- /dev/null +++ b/test/argon2.backends.test.mjs @@ -0,0 +1,63 @@ +// The native and WASM Argon2id backends must produce the same bytes as the +// reference @shieldfive/crypto deriveMasterSecret the web wraps vaults with. +// If `argon2` failed to install (it is optional), only the WASM half runs. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { deriveMasterSecret as reference } from '@shieldfive/crypto/kdf/argon2id' + +import { argon2Backend, deriveMasterSecret } from '../src/argon2.mjs' + +const hex = (u8) => Buffer.from(u8).toString('hex') + +test('the native backend is used when installed', async () => { + let installed = true + try { await import('argon2') } catch { installed = false } + assert.equal(await argon2Backend(), installed ? 'native' : 'wasm') +}) + +for (const saltLength of [16, 32]) { + test(`moderate preset, ${saltLength}-byte salt: CLI derivation == @shieldfive/crypto`, async () => { + const salt = new Uint8Array(saltLength).fill(0x2a) + const passphrase = 'pässwörd with ünïcode ✓' + const [ours, ref] = await Promise.all([ + deriveMasterSecret({ passphrase, salt, preset: 'moderate' }), + reference({ passphrase, salt, preset: 'moderate' }), + ]) + assert.equal(hex(ours.masterSecret), hex(ref.masterSecret)) + }) +} + +test('sensitive preset (1 GiB): CLI derivation == @shieldfive/crypto', { timeout: 300_000 }, async () => { + const salt = new Uint8Array(16).fill(0x11) + const passphrase = 'sensitive preset interop' + const ours = await deriveMasterSecret({ passphrase, salt, preset: 'sensitive' }) + const ref = await reference({ passphrase, salt, preset: 'sensitive' }) + assert.equal(hex(ours.masterSecret), hex(ref.masterSecret)) +}) + +test('forcing the WASM fallback yields the pinned web vector', { timeout: 120_000 }, () => { + // A fresh process, so the backend choice is not cached from this one. + const script = ` + import { argon2Backend } from ${JSON.stringify(fileURLToPath(new URL('../src/argon2.mjs', import.meta.url)))} + import { deriveLoginSecret } from ${JSON.stringify(fileURLToPath(new URL('../src/loginSecret.mjs', import.meta.url)))} + const backend = await argon2Backend() + const secret = await deriveLoginSecret({ password: 'correct horse battery staple 42', authSalt: Buffer.alloc(32, 7).toString('base64') }) + process.stdout.write(JSON.stringify({ backend, secret })) + ` + const out = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + env: { ...process.env, SF_ARGON2_BACKEND: 'wasm' }, encoding: 'utf8', + }) + assert.equal(out.status, 0, out.stderr) + assert.deepEqual(JSON.parse(out.stdout), { + backend: 'wasm', secret: '-3MAPAD6FFMypDmTdLMY0ivfscMAlhyFK5aj5-1c8N8', + }) +}) + +test('unsupported presets and short salts are refused', async () => { + await assert.rejects(deriveMasterSecret({ passphrase: 'x', salt: new Uint8Array(16), preset: 'interactive' }), /Unsupported/) + await assert.rejects(deriveMasterSecret({ passphrase: 'x', salt: new Uint8Array(8) }), /at least 16/) + await assert.rejects(deriveMasterSecret({ passphrase: 'x', salt: new Uint8Array(16), preset: 'toString' }), /Unsupported/) +}) diff --git a/test/auth.noPasswordLeak.test.mjs b/test/auth.noPasswordLeak.test.mjs new file mode 100644 index 0000000..8665a87 --- /dev/null +++ b/test/auth.noPasswordLeak.test.mjs @@ -0,0 +1,158 @@ +// The raw password must never leave the machine for a version-2 account, on +// ANY path: success, MFA step-up, a refused login, or a salt lookup that fails +// in any way. For a version-1 account it may go to exactly one place, the +// version-aware login route, and only after the salt endpoint affirmatively +// said version 1. +// +// Every outbound request is captured: signIn's fetch, the global fetch (so +// nothing can slip out through a default), and every argument handed to the +// Supabase client. Each is serialised and searched for the password. +import assert from 'node:assert/strict' +import { afterEach, beforeEach, test } from 'node:test' + +import { signIn } from '../src/auth.mjs' + +const PASSWORD = 'Unique-Pa55word-🔑-never-on-the-wire' +const SALT = Buffer.alloc(32, 7).toString('base64') +const EMAIL = 'qa@example.test' +const API = 'https://api.test' + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +let wire +let realFetch +beforeEach(() => { + wire = [] + realFetch = globalThis.fetch + globalThis.fetch = async (url, options) => { + wire.push({ via: 'global fetch', url: String(url), body: String(options?.body ?? '') }) + throw new Error('unexpected global fetch') + } +}) +afterEach(() => { globalThis.fetch = realFetch }) + +function supabaseStub({ mfa = false } = {}) { + const record = (method) => (...args) => { + wire.push({ via: `supabase.${method}`, url: '', body: JSON.stringify(args) }) + } + const session = { access_token: 'AAL1', refresh_token: 'R1', expires_at: 1, user: { id: 'U' } } + const stepped = { access_token: 'AAL2', refresh_token: 'R2', expires_at: 2, user: { id: 'U' } } + return (...createArgs) => { + record('createClient')(...createArgs) + let current = session + return { + auth: { + signInWithPassword: async (...a) => { record('signInWithPassword')(...a); throw new Error('must not be called') }, + setSession: async (...a) => { record('setSession')(...a); return { data: { session, user: session.user }, error: null } }, + getSession: async () => ({ data: { session: current }, error: null }), + mfa: { + getAuthenticatorAssuranceLevel: async () => ({ + data: { currentLevel: 'aal1', nextLevel: mfa ? 'aal2' : 'aal1' }, error: null, + }), + listFactors: async () => ({ data: { totp: [{ id: 'F', status: 'verified' }] }, error: null }), + challenge: async (...a) => { record('mfa.challenge')(...a); return { data: { id: 'C' }, error: null } }, + verify: async (...a) => { record('mfa.verify')(...a); current = stepped; return { data: stepped, error: null } }, + }, + }, + } + } +} + +function fetchStub({ salt, login = () => json({ accessToken: 'AAL1', refreshToken: 'R1' }) }) { + return async (url, options) => { + wire.push({ via: 'signIn fetch', url: String(url), body: String(options?.body ?? '') }) + if (String(url).endsWith('/api/auth/salt')) return salt() + if (String(url).endsWith('/api/mobile/auth/login')) return login() + throw new Error(`unexpected request to ${url}`) + } +} + +const run = (opts) => signIn({ + apiBaseUrl: API, supabaseUrl: 'https://s.test', anonKey: 'ANON', email: EMAIL, password: PASSWORD, + getTotpCode: async () => '123456', ...opts, +}) + +function leaks() { + return wire.filter((r) => r.url.includes(PASSWORD) || r.body.includes(PASSWORD) || + r.body.includes(JSON.stringify(PASSWORD).slice(1, -1))) +} + +test('version 2: success', async () => { + const out = await run({ fetchImpl: fetchStub({ salt: () => json({ authVersion: 2, authSalt: SALT }) }), createClientImpl: supabaseStub() }) + assert.equal(out.accessToken, 'AAL1') + assert.deepEqual(leaks(), []) + const login = wire.find((r) => r.url.endsWith('/api/mobile/auth/login')) + assert.deepEqual(Object.keys(JSON.parse(login.body)).sort(), ['email', 'loginSecret']) +}) + +test('version 2: MFA step-up', async () => { + const out = await run({ fetchImpl: fetchStub({ salt: () => json({ authVersion: 2, authSalt: SALT }) }), createClientImpl: supabaseStub({ mfa: true }) }) + assert.equal(out.accessToken, 'AAL2') + assert.equal(out.refreshToken, 'R2') + assert.ok(wire.some((r) => r.via === 'supabase.mfa.verify')) + assert.deepEqual(leaks(), []) +}) + +test('version 2: login refused (401) and throttled (429)', async () => { + for (const [status, message] of [[401, /Sign-in failed/], [429, /Too many/]]) { + wire.length = 0 + await assert.rejects(run({ + fetchImpl: fetchStub({ salt: () => json({ authVersion: 2, authSalt: SALT }), login: () => json({ error: 'x' }, status) }), + createClientImpl: supabaseStub(), + }), message) + assert.deepEqual(leaks(), []) + } +}) + +test('version 2: login route unreachable', async () => { + await assert.rejects(run({ + fetchImpl: fetchStub({ salt: () => json({ authVersion: 2, authSalt: SALT }), login: () => { throw new TypeError('fetch failed') } }), + createClientImpl: supabaseStub(), + }), /could not reach/) + assert.deepEqual(leaks(), []) +}) + +test('salt lookup failures abort before any credential is posted', async () => { + const failures = { + 'network error': () => { throw new TypeError('fetch failed') }, + 'HTTP 403': () => json({ error: 'forbidden' }, 403), + 'HTTP 429': () => json({ error: 'slow down' }, 429), + 'HTTP 500': () => json({ error: 'boom' }, 500), + 'HTTP 503 no body': () => new Response('upstream', { status: 503 }), + 'non-JSON 200': () => new Response('', { status: 200 }), + 'unknown version': () => json({ authVersion: 3, authSalt: SALT }), + 'version as string': () => json({ authVersion: '1', authSalt: SALT }), + 'missing version': () => json({ authSalt: SALT }), + 'missing salt': () => json({ authVersion: 2 }), + } + for (const [name, salt] of Object.entries(failures)) { + wire.length = 0 + await assert.rejects(run({ fetchImpl: fetchStub({ salt }), createClientImpl: supabaseStub() }), undefined, name) + assert.deepEqual(leaks(), [], name) + assert.ok(!wire.some((r) => r.url.endsWith('/api/mobile/auth/login')), `${name}: login route must not be called`) + assert.ok(!wire.some((r) => r.via.startsWith('supabase')), `${name}: no Supabase client`) + } +}) + +test('version 1: the password goes ONLY to the login route, never to Supabase or the salt lookup', async () => { + for (const migrationEnabled of [true, false]) { + wire.length = 0 + await run({ + fetchImpl: fetchStub({ salt: () => json({ authVersion: 1, authSalt: SALT, migrationEnabled }) }), + createClientImpl: supabaseStub({ mfa: true }), + }) + const leaked = leaks() + assert.equal(leaked.length, 1, `migrationEnabled=${migrationEnabled}`) + assert.equal(leaked[0].url, `${API}/api/mobile/auth/login`) + const body = JSON.parse(leaked[0].body) + assert.deepEqual(Object.keys(body).sort(), + migrationEnabled ? ['authSalt', 'email', 'loginSecret', 'password'] : ['email', 'password']) + } +}) + +test('Supabase signInWithPassword and the global fetch are never used', async () => { + await run({ fetchImpl: fetchStub({ salt: () => json({ authVersion: 2, authSalt: SALT }) }), createClientImpl: supabaseStub({ mfa: true }) }) + assert.ok(!wire.some((r) => r.via === 'supabase.signInWithPassword')) + assert.ok(!wire.some((r) => r.via === 'global fetch')) +}) diff --git a/test/auth.v2.test.mjs b/test/auth.v2.test.mjs new file mode 100644 index 0000000..df44d62 --- /dev/null +++ b/test/auth.v2.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { deriveLoginSecret, buildSignInCredentials } from '../src/loginSecret.mjs' +import { signIn } from '../src/auth.mjs' + +const password = 'correct horse battery staple 42' +const salt = Buffer.alloc(32, 7).toString('base64') +const secret = '-3MAPAD6FFMypDmTdLMY0ivfscMAlhyFK5aj5-1c8N8' +const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +test('login derivation matches the pinned web/mobile 32-byte-salt vector', async () => { + let ticks = 0 + const timer = setInterval(() => { ticks++ }, 10) + try { assert.equal(await deriveLoginSecret({ password, authSalt: salt }), secret) } + finally { clearInterval(timer) } + assert.ok(ticks > 0, 'Argon2id must leave the event loop responsive') + assert.equal(await deriveLoginSecret({ password, authSalt: Buffer.alloc(16, 7).toString('base64') }), + 'YRxqCqxxKe-kWjq0T9jwIe22GOLJlROTlKkedBS8qd4') +}) + +test('migrated sign-in never posts raw password; establishes session before checking MFA', async () => { + const requests = [] + const actions = [] + const session = { access_token: 'ACCESS', refresh_token: 'REFRESH', expires_at: 12345, user: { id: 'QA' } } + const result = await signIn({ apiBaseUrl: 'https://api.test', supabaseUrl: 'https://s.test', anonKey: 'PUBLIC', email: 'qa@example.test', password, + fetchImpl: async (url, options) => { + requests.push({ url: String(url), body: JSON.parse(options.body) }) + return String(url).endsWith('/salt') ? json({ authVersion: 2, authSalt: salt }) : json({ accessToken: 'ACCESS', refreshToken: 'REFRESH' }) + }, + createClientImpl: () => ({ auth: { + setSession: async (tokens) => { actions.push(tokens); return { data: { session }, error: null } }, + mfa: { getAuthenticatorAssuranceLevel: async () => { actions.push('MFA'); return { data: { currentLevel: 'aal1', nextLevel: 'aal1' }, error: null } } }, + } }), + }) + assert.deepEqual(requests, [ + { url: 'https://api.test/api/auth/salt', body: { email: 'qa@example.test' } }, + { url: 'https://api.test/api/mobile/auth/login', body: { email: 'qa@example.test', loginSecret: secret } }, + ]) + assert.deepEqual(actions, [{ access_token: 'ACCESS', refresh_token: 'REFRESH' }, 'MFA']) + assert.equal(result.accessToken, 'ACCESS') + assert.equal(result.refreshToken, 'REFRESH') + assert.equal(result.userId, 'QA') +}) + +test('failed or unknown auth-version lookup stops before credential submission', async () => { + for (const response of [json({}, 503), json({ authVersion: 3, authSalt: salt }), json({ authVersion: 2 })]) { + let calls = 0 + await assert.rejects(signIn({ apiBaseUrl: 'https://api.test', email: 'qa@example.test', password, + fetchImpl: async () => { calls++; return response }, createClientImpl: () => { throw new Error('Must not establish session') }, + }), /Unable to prepare secure sign-in/) + assert.equal(calls, 1) + } +}) + +test('legacy migration body matches the server contract; migration-off preserves legacy sign-in', async () => { + const input = { apiBaseUrl: 'https://api.test', email: 'qa@example.test', password } + assert.deepEqual(await buildSignInCredentials({ ...input, fetchImpl: async () => json({ authVersion: 1, authSalt: salt, migrationEnabled: true }) }), + { password, loginSecret: secret, authSalt: salt }) + assert.deepEqual(await buildSignInCredentials({ ...input, fetchImpl: async () => json({ authVersion: 1, authSalt: salt, migrationEnabled: false }) }), { password }) +}) + +test('invalid salt is refused before expensive derivation', async () => { + for (const authSalt of ['', '!!!', 'YQ==', Buffer.alloc(65).toString('base64')]) { + await assert.rejects(deriveLoginSecret({ password, authSalt }), /Invalid secure sign-in salt/) + } +}) diff --git a/test/unlock.roundtrip.test.mjs b/test/unlock.roundtrip.test.mjs index cc04a2b..2c80858 100644 --- a/test/unlock.roundtrip.test.mjs +++ b/test/unlock.roundtrip.test.mjs @@ -63,3 +63,18 @@ test('a wrong password fails to unlock', async () => { unlockRootKey({ password: 'the-wrong-password', vaultKey }), ) }) + +// The web keyring trims the vault password before wrapping, so a vault created +// on the web with " pw " typed is wrapped under "pw". The CLI must open it +// with the password as typed. +test('a vault wrapped under the trimmed password (the web keyring) unlocks with surrounding whitespace typed', async () => { + const rootKey = webcrypto.getRandomValues(new Uint8Array(32)) + const vaultKey = await wrapRootKeyUnderPassword({ password: 'spaced pw', preset: 'moderate', rootKey }) + assert.deepEqual(await unlockRootKey({ password: ' spaced pw \t', vaultKey }), rootKey) +}) + +test('a vault wrapped under the untrimmed password still unlocks (fallback)', async () => { + const rootKey = webcrypto.getRandomValues(new Uint8Array(32)) + const vaultKey = await wrapRootKeyUnderPassword({ password: ' spaced pw ', preset: 'moderate', rootKey }) + assert.deepEqual(await unlockRootKey({ password: ' spaced pw ', vaultKey }), rootKey) +})