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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
69 changes: 69 additions & 0 deletions src/argon2.mjs
Original file line number Diff line number Diff line change
@@ -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) }
}
38 changes: 32 additions & 6 deletions src/auth.mjs
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading