Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,21 @@ 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
// 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 })
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), {
Expand Down Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions src/authPin.mjs
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
29 changes: 21 additions & 8 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ function usage() {
' inspect <folder> 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',
Expand Down Expand Up @@ -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
Expand All @@ -119,6 +123,7 @@ function readLiveConfig() {
email,
password,
vaultPassword: e.SF_VAULT_PASSWORD || password,
allowLegacyLogin: flags['allow-legacy-login'] === true,
}
}

Expand All @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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')
Expand All @@ -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)

Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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 <folder>')
await cmdPush(folder)
await cmdPush(folder, flags)
} else if (cmd === 'sync') {
if (!folder) throw new Error('sync needs a <folder>')
// `--interval 10` (space-separated) would otherwise parse as a bare flag and
Expand All @@ -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 {
Expand Down
11 changes: 10 additions & 1 deletion src/loginSecret.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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), {
Expand All @@ -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 }
Expand Down
Loading
Loading