diff --git a/CHANGELOG.md b/CHANGELOG.md index 5009a23..aaab6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to `@shieldfive/cli` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Security + +- **Downgrade pin for sign-in.** After a sign-in with a login secret (auth + version 2), the CLI records the account (a SHA-256 of the email) in + `~/.shieldfive/auth-pins.json` (mode 0600). A later server answer of + version 1 for that account is refused before the raw password is sent. + `--allow-legacy-login` on `sf login`, `sf push` or `sf sync` overrides it. + ## 0.3.1 - 2026-10-01 ### Security diff --git a/src/auth.mjs b/src/auth.mjs index b78977a..e9fcad4 100644 --- a/src/auth.mjs +++ b/src/auth.mjs @@ -87,6 +87,10 @@ export async function signIn({ getTotpCode, fetchImpl = fetch, createClientImpl = createClient, + // { isPinned(email), pin(email) } -- src/authPin.mjs fileAuthPin(). Null + // means no pin (tests, embedders); the sf commands always pass one. + authPin = null, + allowLegacyLogin = false, }) { // The raw password is never handed to Supabase directly. The salt lookup // decides what the version-aware login route receives: only the derived @@ -94,7 +98,10 @@ export async function signIn({ // 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 }) + const pinnedV2 = authPin ? await authPin.isPinned(email) : false + const credentials = await buildSignInCredentials({ + apiBaseUrl, email, password, fetchImpl, pinnedV2, allowLegacyLogin, + }) let response try { response = await fetchImpl(new URL('/api/mobile/auth/login', apiBaseUrl), { @@ -127,6 +134,13 @@ export async function signIn({ throw new Error('Sign-in returned no access token.') } + // Signed in with only the login secret: the account is version 2. Remember + // it so a later version-1 answer is refused (src/authPin.mjs). A failed + // write does not fail the sign-in; the next one retries it. + if (authPin && !('password' in credentials)) { + try { await authPin.pin(email) } catch { /* retried on the next sign-in */ } + } + const aal2Token = await stepUpMfaIfRequired({ supabase, getTotpCode }) // The refresh token has to come from the session that was stepped up. diff --git a/src/authPin.mjs b/src/authPin.mjs new file mode 100644 index 0000000..74f70f5 --- /dev/null +++ b/src/authPin.mjs @@ -0,0 +1,95 @@ +// "This account signs in with a login secret" -- remembered on this computer. +// +// /api/auth/salt is the server telling the CLI how to sign in: version 2 means +// send only the derived login secret, version 1 means send the raw password. +// A server that has been tampered with (or an account a stolen session +// downgraded) can answer version 1 for an account that is really version 2, +// and a CLI that obeys sends the raw password -- the one thing version 2 +// promises never leaves this machine. The mobile app and the web already pin; +// this is the CLI's pin. +// +// Once a sign-in has gone through with the server saying version 2, the +// account is recorded here. Afterwards a version-1 answer for it is refused +// unless the user passes --allow-legacy-login. +// +// File: ~/.shieldfive/auth-pins.json, mode 0600 in a 0700 directory. It holds +// SHA-256 hashes of lower-cased email addresses, not the addresses, so the file +// does not list which accounts this computer uses. + +import { createHash } from 'node:crypto' +import { chmod, readFile, rename, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { ensurePrivateDir, shieldfiveHome } from './agent/paths.mjs' + +export const AUTH_PIN_FILE = 'auth-pins.json' + +export class LegacyLoginRefusedError extends Error { + constructor() { + super( + 'Refusing to sign in: this computer has signed in to this account with a ' + + 'login secret before, but the server now asks for the raw password ' + + '(auth version 1). That should not happen to an account that has moved ' + + 'to version 2, so the password was not sent. If you know why (for ' + + 'example ShieldFive support reset the account), run the command again ' + + 'with --allow-legacy-login.', + ) + this.name = 'LegacyLoginRefusedError' + this.code = 'legacy_login_refused' + } +} + +function emailKey(email) { + return createHash('sha256') + .update(String(email ?? '').trim().toLowerCase()) + .digest('hex') +} + +/** + * The pin store backed by ~/.shieldfive/auth-pins.json. `home` is injectable + * for tests. A file that cannot be read as a pin list is treated as holding + * no pins for reads, and is replaced on the next write. + */ +export function fileAuthPin({ home = homedir() } = {}) { + const dir = shieldfiveHome(home) + const file = join(dir, AUTH_PIN_FILE) + + const readKeys = async () => { + let raw + try { + raw = await readFile(file, 'utf8') + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed?.v2) + ? parsed.v2.filter((k) => typeof k === 'string' && /^[0-9a-f]{64}$/.test(k)) + : [] + } catch { + return [] + } + } + + return { + file, + async isPinned(email) { + return (await readKeys()).includes(emailKey(email)) + }, + async pin(email) { + await ensurePrivateDir(dir) + const keys = await readKeys() + const key = emailKey(email) + if (keys.includes(key)) return + keys.push(key) + // Write-then-rename so a crash never leaves a half-written file, and + // chmod after rename in case an older file had looser bits. + const tmp = `${file}.${process.pid}.tmp` + await writeFile(tmp, JSON.stringify({ v2: keys }) + '\n', { mode: 0o600 }) + await rename(tmp, file) + await chmod(file, 0o600) + }, + } +} diff --git a/src/cli.mjs b/src/cli.mjs index e525b33..973365f 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -38,6 +38,10 @@ function usage() { ' inspect count a migration source recursively; upload nothing', ' --help', '', + 'login, push and sync accept --allow-legacy-login: sign in even though the', + 'server asks for the raw password (auth version 1) for an account this', + 'computer has signed in to with a login secret. Refused by default.', + '', 'After sf login, push / sync / verify go through the agent and need nothing', 'in the environment. Without an agent, or when SF_EMAIL and SF_PASSWORD are', 'set, push and sync sign in from those [SF_VAULT_PASSWORD if your vault', @@ -99,7 +103,7 @@ function envCredentialsSet() { return Boolean(process.env.SF_EMAIL && process.env.SF_PASSWORD) } -function readLiveConfig() { +function readLiveConfig(flags = {}) { const e = process.env const email = e.SF_EMAIL const password = e.SF_PASSWORD @@ -119,6 +123,7 @@ function readLiveConfig() { email, password, vaultPassword: e.SF_VAULT_PASSWORD || password, + allowLegacyLogin: flags['allow-legacy-login'] === true, } } @@ -141,6 +146,7 @@ async function readTotpCode() { // lazily so `sf encrypt` runs without @supabase/supabase-js. async function authAndUnlock(cfg) { const { signIn } = await import('./auth.mjs') + const { fileAuthPin } = await import('./authPin.mjs') const { fetchAndUnlockVault } = await import('./vault.mjs') process.stderr.write('signing in…\n') @@ -151,6 +157,8 @@ async function authAndUnlock(cfg) { email: cfg.email, password: cfg.password, getTotpCode: readTotpCode, + authPin: fileAuthPin(), + allowLegacyLogin: cfg.allowLegacyLogin === true, }) process.stderr.write('unlocking vault…\n') @@ -298,10 +306,15 @@ async function cmdLogin(flags) { (await promptHidden('Vault password (press Enter if it is the same): ')) || password const { signIn } = await import('./auth.mjs') + const { fileAuthPin } = await import('./authPin.mjs') const { fetchAndUnlockVault } = await import('./vault.mjs') process.stderr.write('signing in…\n') - const session = await signIn({ ...cfg, email, password, getTotpCode: readTotpCode }) + const session = await signIn({ + ...cfg, email, password, getTotpCode: readTotpCode, + authPin: fileAuthPin(), + allowLegacyLogin: flags['allow-legacy-login'] === true, + }) process.stderr.write('unlocking vault…\n') const rootKey = await fetchAndUnlockVault({ @@ -378,7 +391,7 @@ async function cmdLogout(flags) { } // login -> unlock -> encrypt -> upload every file in the folder, once. -async function cmdPush(folder) { +async function cmdPush(folder, flags) { const { agent } = await chooseAuth() if (agent) { const { request } = await import('./agent/client.mjs') @@ -402,7 +415,7 @@ async function cmdPush(folder) { return } - const cfg = readLiveConfig() + const cfg = readLiveConfig(flags) const { uploadFile } = await import('./upload.mjs') const { accessToken, rootKey } = await authAndUnlock(cfg) @@ -466,11 +479,11 @@ async function syncViaAgent(folder, { watch, intervalMs }, agent) { } // login -> unlock -> mirror new/changed files into the vault, once or on a loop. -async function cmdSync(folder, { watch, intervalMs }) { +async function cmdSync(folder, { watch, intervalMs }, flags) { const { agent } = await chooseAuth() if (agent) return syncViaAgent(resolve(folder), { watch, intervalMs }, agent) - const cfg = readLiveConfig() + const cfg = readLiveConfig(flags) const { uploadFile } = await import('./upload.mjs') const { runSync } = await import('./sync.mjs') @@ -556,7 +569,7 @@ async function main() { if (result.skipped) process.exitCode = 2 } else if (cmd === 'push') { if (!folder) throw new Error('push needs a ') - await cmdPush(folder) + await cmdPush(folder, flags) } else if (cmd === 'sync') { if (!folder) throw new Error('sync needs a ') // `--interval 10` (space-separated) would otherwise parse as a bare flag and @@ -569,7 +582,7 @@ async function main() { await cmdSync(folder, { watch: Boolean(flags.watch), intervalMs: Math.round(intervalSec * 1000), - }) + }, flags) } else if (cmd === 'verify') { await cmdVerify(positionals) } else { diff --git a/src/loginSecret.mjs b/src/loginSecret.mjs index 223ba20..8095051 100644 --- a/src/loginSecret.mjs +++ b/src/loginSecret.mjs @@ -10,6 +10,7 @@ // vault KDF is different: the web keyring trims the vault password. See // src/unlock.mjs.) import { deriveMasterSecret } from './argon2.mjs' +import { LegacyLoginRefusedError } from './authPin.mjs' const DOMAIN = 'shieldfive/v1/auth/login-secret' @@ -40,8 +41,15 @@ export async function deriveLoginSecret({ password, authSalt }) { * 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. + * + * `pinnedV2`: this computer has already signed in to the account as version 2 + * (src/authPin.mjs). A version-1 answer for it is then refused with + * LegacyLoginRefusedError, before the password goes anywhere, unless + * `allowLegacyLogin` (the --allow-legacy-login flag) says the user expects it. */ -export async function buildSignInCredentials({ apiBaseUrl, email, password, fetchImpl = fetch }) { +export async function buildSignInCredentials({ + apiBaseUrl, email, password, fetchImpl = fetch, pinnedV2 = false, allowLegacyLogin = false, +}) { let response try { response = await fetchImpl(new URL('/api/auth/salt', apiBaseUrl), { @@ -57,6 +65,7 @@ export async function buildSignInCredentials({ apiBaseUrl, email, password, fetc typeof body.authSalt !== 'string' || !body.authSalt) { throw new Error('Unable to prepare secure sign-in. Please try again.') } + if (body.authVersion === 1 && pinnedV2 && !allowLegacyLogin) throw new LegacyLoginRefusedError() 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/test/auth.pin.test.mjs b/test/auth.pin.test.mjs new file mode 100644 index 0000000..3627454 --- /dev/null +++ b/test/auth.pin.test.mjs @@ -0,0 +1,134 @@ +// Downgrade pin (audit 2026-10-02, Batch R): once this computer has signed in +// to an account with a login secret, a server "version 1" answer for it must +// not make the CLI send the raw password, unless --allow-legacy-login. +import assert from 'node:assert/strict' +import { mkdtemp, readFile, stat, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { signIn } from '../src/auth.mjs' +import { AUTH_PIN_FILE, fileAuthPin, LegacyLoginRefusedError } from '../src/authPin.mjs' +import { buildSignInCredentials } from '../src/loginSecret.mjs' + +const password = 'correct horse battery staple 42' +const salt = Buffer.alloc(32, 7).toString('base64') +const email = 'qa@example.test' +const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +const tempHome = () => mkdtemp(join(tmpdir(), 'sf-pin-')) + +function fakeServer(saltAnswer) { + const requests = [] + const fetchImpl = async (url, options) => { + requests.push({ url: String(url), body: JSON.parse(options.body) }) + return String(url).endsWith('/salt') + ? json(saltAnswer()) + : json({ accessToken: 'ACCESS', refreshToken: 'REFRESH' }) + } + return { requests, fetchImpl } +} + +const createClientImpl = () => ({ + auth: { + setSession: async () => ({ + data: { session: { access_token: 'ACCESS', refresh_token: 'REFRESH', user: { id: 'U' } } }, + error: null, + }), + mfa: { + getAuthenticatorAssuranceLevel: async () => ({ + data: { currentLevel: 'aal1', nextLevel: 'aal1' }, error: null, + }), + }, + }, +}) + +const base = { apiBaseUrl: 'https://api.test', supabaseUrl: 'https://s.test', anonKey: 'PUBLIC', email, password, createClientImpl } + +test('pin file is 0600 in a 0700 directory and holds no plaintext email', async () => { + const home = await tempHome() + const pin = fileAuthPin({ home }) + assert.equal(await pin.isPinned(email), false) + await pin.pin(email) + await pin.pin(email) // idempotent + assert.equal(await pin.isPinned(' QA@Example.TEST '), true) + assert.equal(await pin.isPinned('other@example.test'), false) + assert.equal((await stat(pin.file)).mode & 0o777, 0o600) + assert.equal((await stat(join(home, '.shieldfive'))).mode & 0o777, 0o700) + const raw = await readFile(pin.file, 'utf8') + assert.ok(!raw.includes('example'), 'email must not be stored in plaintext') + assert.equal(JSON.parse(raw).v2.length, 1) +}) + +test('a corrupt pin file reads as empty and is replaced on the next pin', async () => { + const home = await tempHome() + await mkdir(join(home, '.shieldfive'), { mode: 0o700 }) + await writeFile(join(home, '.shieldfive', AUTH_PIN_FILE), 'not json', { mode: 0o644 }) + const pin = fileAuthPin({ home }) + assert.equal(await pin.isPinned(email), false) + await pin.pin(email) + assert.equal(await pin.isPinned(email), true) + assert.equal((await stat(pin.file)).mode & 0o777, 0o600) +}) + +test('a version-2 sign-in pins the account', async () => { + const home = await tempHome() + const authPin = fileAuthPin({ home }) + const { fetchImpl } = fakeServer(() => ({ authVersion: 2, authSalt: salt })) + await signIn({ ...base, fetchImpl, authPin }) + assert.equal(await authPin.isPinned(email), true) +}) + +test('a version-1 sign-in does not pin', async () => { + const home = await tempHome() + const authPin = fileAuthPin({ home }) + const { fetchImpl } = fakeServer(() => ({ authVersion: 1, authSalt: salt, migrationEnabled: false })) + await signIn({ ...base, fetchImpl, authPin }) + assert.equal(await authPin.isPinned(email), false) +}) + +test('pinned account: a version-1 answer is refused before the password is posted', async () => { + const home = await tempHome() + const authPin = fileAuthPin({ home }) + await authPin.pin(email) + for (const migrationEnabled of [false, true]) { + const { requests, fetchImpl } = fakeServer(() => ({ authVersion: 1, authSalt: salt, migrationEnabled })) + await assert.rejects(signIn({ ...base, fetchImpl, authPin }), LegacyLoginRefusedError) + assert.equal(requests.length, 1, 'only the salt lookup may go out') + assert.ok(!JSON.stringify(requests).includes(password)) + } +}) + +test('pinned account: --allow-legacy-login lets a version-1 sign-in through', async () => { + const home = await tempHome() + const authPin = fileAuthPin({ home }) + await authPin.pin(email) + const { requests, fetchImpl } = fakeServer(() => ({ authVersion: 1, authSalt: salt, migrationEnabled: false })) + await signIn({ ...base, fetchImpl, authPin, allowLegacyLogin: true }) + assert.deepEqual(requests[1].body, { email, password }) + // The pin stays: one consented downgrade does not disarm the next refusal. + assert.equal(await authPin.isPinned(email), true) +}) + +test('pinned account: a version-2 answer still signs in normally', async () => { + const home = await tempHome() + const authPin = fileAuthPin({ home }) + await authPin.pin(email) + const { requests, fetchImpl } = fakeServer(() => ({ authVersion: 2, authSalt: salt })) + await signIn({ ...base, fetchImpl, authPin }) + assert.equal(requests.length, 2) + assert.ok(!('password' in requests[1].body)) +}) + +test('buildSignInCredentials refuses version 1 for a pinned account', async () => { + const fetchImpl = async () => json({ authVersion: 1, authSalt: salt, migrationEnabled: false }) + await assert.rejects( + buildSignInCredentials({ apiBaseUrl: 'https://api.test', email, password, fetchImpl, pinnedV2: true }), + { code: 'legacy_login_refused' }, + ) + assert.deepEqual( + await buildSignInCredentials({ apiBaseUrl: 'https://api.test', email, password, fetchImpl, pinnedV2: true, allowLegacyLogin: true }), + { password }, + ) +})