Command-line client for ShieldFive. Encrypt a folder on your machine, then push or continuously sync it to your EU-hosted vault. File contents and names are encrypted locally before they leave your machine (contents with a post-quantum hybrid key). Read the code and verify that for yourself.
The point of this tool being open source is that you do not have to trust a marketing claim. The plaintext of your files and their names is never sent to a ShieldFive server:
- File contents — AES-256-GCM chunks under a per-file key that combines a random envelope key (wrapped by your vault root key) with an ML-KEM-1024 + X25519 hybrid encapsulation to your vault identity.
- File names — AES-256-GCM under a key derived from your vault root key (not the post-quantum layer), plus a keyed HMAC of the lowercased name so the server can spot duplicates without reading it.
What the server does see, by design: the plaintext size of each file, part hashes (SHA-1 of ciphertext), the wrapped keys, an upload proof, timestamps and your account. Sign-in sends a secret derived from your password, never the password itself. Two files carry the whole story:
src/uploadCrypto.mjs— the encryption applied to every chunk and to the filename.src/upload.mjs— the only place file bytes are sent over the network. Every file body it sends is ciphertext.
If you want to see encryption happen without an account at all, run
sf encrypt <folder> — it encrypts each file locally and prints the ciphertext
size, uploading nothing.
- The underlying cryptography (
@shieldfive/crypto) has not undergone an external audit yet. Treat this as early software. - Neither upload path has been exercised against production since 2026-09-03.
The direct (≤ 5 MiB) path was validated end to end against the backend as it
stood at 0.1.0, but the server then moved that path to a presigned S3 PUT and
stopped issuing an upload token. 0.2.0 hard-required that token, so it could
not upload any file of 5 MiB or less at all. This release rewrites the direct
path to the presigned PUT. It is covered by the offline mock test in
test/upload.flow.test.mjsand sends byte-for-byte the request the web app sends, but it has not been run live. - The multipart path (files larger than 5 MiB) is covered by a byte-for-byte review against the server contract and a mock end-to-end test that decrypts every part, but has not yet been exercised against production with a real large file.
sf syncis append-only: it uploads new and changed files; it does not yet mirror local deletions or renames into the vault.sf loginand the agent have not been run against production. They are covered by offline tests, including one that runs the agent as its own process and reaches it from separatesfprocesses against a local stub of the auth and verify endpoints. Sign-in, unlock and session refresh against the live backend are not covered.
- Node.js >= 20
- A ShieldFive account with a vault (create one in the web app first)
npm install -g @shieldfive/cli # provides the `sf` commandOr run it without installing:
npx @shieldfive/cli --helpTo read and build from source (the whole point — verify what it does before you trust it):
git clone https://github.com/shieldfive/cli.git
cd cli
npm install # @shieldfive/crypto (+ libsodium), @supabase/supabase-js, @noble/*
npm test # 107 testssf login [--idle=<hours>] sign in once; an agent holds the session in memory (locks after 8 h idle)
sf status is an agent running, and for which account
sf logout [--everywhere] revoke the session and stop the agent; --everywhere signs out every device
sf push <folder> [--include-dotfiles] encrypt and upload every file once (skips dotfiles unless asked; never follows symlinks)
sf sync <folder> [--watch] [--interval=N] upload new/changed files; --watch keeps a poll loop (N seconds, default 5)
sf verify <file>... report whether each file is safely stored in your vault
sf encrypt <folder> encrypt each file locally, upload nothing (no account needed)
sf inspect <folder> read-only recursive migration inventory; upload nothing
sf push and sf sync pick the direct or multipart upload path automatically
from the file size. Both read only regular files directly inside the folder:
symlinks are skipped (and files are opened with O_NOFOLLOW), and names
starting with . are skipped unless you pass sf push --include-dotfiles.
An empty (0-byte) file is uploaded only when the server says it accepts empty
files; otherwise it is reported as skipped and the exit code is 2. If an upload
fails after its session was created, the pending session is cancelled so it
does not hold quota. sf sync writes its manifest (.shieldfive-sync.json)
with mode 0600.
sf inspect reports JSON counts of files, directories, bytes and skipped entries.
It includes nested/hidden files and empty folders, reads metadata only, and
does not follow symbolic links. Exit code 2 means entries were skipped. It does
not upload anything or prove that a migration has completed. Current push/sync
remain top-level only; do not use their completion as proof of a whole-tree copy.
With an agent running, sf sync records uploads in the agent's ledger (see
What the agent can and cannot do) and
sf verify works. Without one, sf sync writes a .shieldfive-sync.json
manifest in the target folder that records what has already been uploaded (by
size and mtime), so unchanged files are skipped and an interrupted run does not
re-upload everything; sf verify needs the agent.
sf login
sf push ./my-folder
sf sync ./my-folder --watch
sf verify ./my-folder/tax-return-2025.pdf
sf logoutsf login prompts for your email, your password and, if it differs, your vault
password. It never reads them from the environment and there is no flag for
them. It then starts an agent in the background and exits; every later command
talks to that agent and needs nothing exported.
If SF_EMAIL and SF_PASSWORD are also set, sf push and sf sync sign in
with those instead and tell you an agent is running, so a script written for one
account is never sent to another. sf push exits 2 if a file changed while it
was being uploaded: the vault then holds the version that was read, not the file
as it is now.
sf push and sf sync still work from environment variables, exactly as
before. Your password is typed, never passed on the command line:
export SF_EMAIL=you@example.com
read -rs SF_PASSWORD; echo; export SF_PASSWORD
export SF_VAULT_PASSWORD="$SF_PASSWORD" # only if your vault password differs
sf push ./my-folder
sf sync ./my-folder --watchThe backend endpoints default to ShieldFive, so there is nothing else to
configure. SF_API_BASE_URL, SF_SUPABASE_URL, and SF_SUPABASE_ANON_KEY are
baked in with ShieldFive's public values (the Supabase anon key is a role=anon
JWT gated by row-level security, the same value any browser receives) and only
need overriding if you point the CLI at a development backend.
If your account has an authenticator app (TOTP) enabled, the CLI performs the AAL2 step-up automatically: after your password it prompts
Two-factor code (6 digits):
Enter the current code from your authenticator. In a non-interactive context
(scripts, CI) set SF_TOTP_CODE to the current code instead of being prompted.
Only TOTP is supported; SMS and other factors are not.
With sf login you are asked once. The agent refreshes the stepped-up session
on its own, so sf sync --watch keeps working on a two-factor account until the
agent locks or you log out.
The agent keeps two things in memory: your signed-in session and your unlocked
vault key. Neither is written to disk. It listens on a Unix socket in
~/.shieldfive/run, a directory only your user can enter, and refuses to start
if that directory is a symlink, owned by someone else or open to other users.
Only one agent runs at a time.
It accepts six requests: status, upload, sync, verify, lock and
logout. It cannot list what is in your vault, download a file, decrypt
anything, or hand out the key, because there is no request for any of those. A
process that reaches the socket can put files into your vault and ask whether
files are backed up. It cannot read your vault through the agent.
Uploads are recorded in ~/.shieldfive/ledger/<account>.jsonl: the path, size,
time, vault file id, and an HMAC of the bytes that were actually encrypted, under
a key derived from your vault key. A plain hash would let anyone holding the
ledger confirm whether you have a particular known document; the HMAC does not,
without your vault key. Each record also carries a tag under that key, so a line
added to the ledger by anything other than the agent is ignored, and records
made on another machine do not count on this one.
sf verify reports a file as backed up only when the bytes on disk now
match an upload in the ledger and the server confirms, at that moment, that the
upload is stored, complete, intact and not in your Bin. It never decides by name
or size. The other answers:
| Answer | Meaning |
|---|---|
NOT confirmed |
these bytes were uploaded, but the server cannot confirm they are still safely stored |
changed since upload |
this path was uploaded, but the file has changed since |
no upload record |
this agent never uploaded these bytes; they may still be in your vault from another device |
unreadable |
the local file could not be read |
no upload record does not mean the file is missing from your vault. Do not
delete anything on the strength of that answer.
The agent locks itself after 8 hours without an upload, sync or verify
(sf login --idle=<hours> to change it; sf status does not count). Locking and
sf logout both overwrite the key in memory first and then sign the session out
on the server. Sessions on ShieldFive do not expire on their own, so a session
that is forgotten rather than signed out would stay valid. If the server cannot
be reached when you log out, sf logout says the session is still valid and
exits 1; run sf login and then sf logout --everywhere once you are online.
Limits:
- Windows is not supported. Use
SF_EMAILandSF_PASSWORDthere. - Node cannot see which process connected to the socket, so directory
permissions are the only gate. This is also how
ssh-agentworks on macOS. - Overwriting the key in memory is best-effort. A garbage-collected runtime can copy key material during crypto operations.
- The ledger is per device. A file uploaded from another machine shows as
no upload recordhere.
The full design, including the threat model, is in
docs/agent-design.md.
Uploads are written in the format the web app writes by default:
cipher_version 3, suite 0x03 (post-quantum hybrid) in the v1 wire format
(@shieldfive/crypto spec/format-v1.md), using the library's own stream
encoder.
- Filename — encrypted with AES-GCM under a key derived from your vault root
key via Argon2id (
encryptMetadataV4), then, at finalize, re-sealed with the file's row id as additional data (v6,encryptNameV6), so the server cannot move one file's name onto another row. A keyed HMAC of the lowercased name (hashMetadataV4) lets the server deduplicate without learning the name. - File content — a MAC'd header carries the file's
file_id(the row id the server assigned, so the stored object is bound to its row), the plaintext size and the chunk count. The content key combines a per-file envelope key (wrapped by your root key incsk_wrapped) with an ML-KEM-1024 + X25519 encapsulation to your vault identity, which is derived on this machine from your root key, as the web app derives it; the server cannot substitute a recipient. Each 5 MiB chunk is AES-256-GCM with its index, the total and an is-final flag in the additional data, so a dropped, reordered or truncated chunk fails to decrypt. - Upload proof — base64 of
[3][3] || HMAC-SHA256(proofKey, [3][3] || header || frame_0)(buildUploadProofV3in@shieldfive/crypto, the same function the web and the MCP server use). The server issuesproofKeywhen it creates the session and verifies the proof against the stored object when finalizing. - Parts — each storage part is exactly one encrypted chunk frame; part 1 also carries the header.
Versions up to 0.3.0 wrote the legacy v0 format (AES-GCM chunks under a random nonce prefix, no header): no truncation detection, no row binding and no post-quantum layer. Files already uploaded that way still open in the apps; this client no longer writes it.
- Direct (≤ 5 MiB) — the single encrypted chunk is
PUTat a presigned S3 URL the server issues with the session. The URL's SigV4 signature is the only credential and it authorises exactly one object key, so the request carries noAuthorizationheader and noX-Bz-*headers; the client sends no storage id at finalize, because the server reads the real one off the stored object. Because the signature covershostonly, nothing on this path binds the body: the server compares the client'sciphertextHashagainst the client's ownpartSha1Array[0], so they always agree. Integrity here rests on the upload proof and on the header MAC and chunk tags at download, not on a wire checksum. - Multipart (> 5 MiB) — the file is streamed one 5 MiB chunk at a time (never loaded whole into memory), each chunk uploaded as a Backblaze part. The ciphertext hash the server checks is SHA-1 over the concatenated raw part digests; the server finishes the large file with the ordered part list.
The mock end-to-end test in test/upload.flow.test.mjs
captures every byte this client would send, opens the stored object with
@shieldfive/crypto's reference reader from nothing but the session fields and
the root key, and asserts the result equals the original file and the header's
file_id equals the row id. test/v1Format.test.mjs
checks the shared vectors in test/vectors/v3-upload.json
(regenerate with node scripts/generate-vectors.mjs) against the library's
blob and streaming readers.
src/cli.mjs— thesfentry point (argument parsing, command dispatch)src/auth.mjs— sign-in to a Bearer token through ShieldFive's sign-in route, with two-factor step-upsrc/loginSecret.mjs— the login secret derived from your password; for accounts on the current scheme the password itself is never sentsrc/argon2.mjs— Argon2id, native when available, WebAssembly otherwisesrc/vault.mjs/src/unlock.mjs— fetch the wrapped vault key, unwrap the root key from your password (Argon2id / PBKDF2)src/uploadCrypto.mjs— filename encryption, the suite-0x03 part writer, upload proof, multipart ciphertext hashsrc/upload.mjs— create session, encrypt, upload (direct + multipart), finalize; the streaming chunk readersrc/sync.mjs—sf sync: manifest, change detection, reconcile pass, watch loop (reusesupload.mjs)src/prompt.mjs— terminal prompts; the password prompt echoes nothingsrc/agent/—sf login's agent:server.mjs(the process and its six requests),client.mjs,protocol.mjs(the request allowlist),ledger.mjs(HMAC upload ledger andverifyclassification),paths.mjs(socket directory checks),main.mjs(entry point and session handoff)docs/agent-design.md— design record and threat model for the agentsrc/sfCrypto.mjs— the post-quantum hybrid encrypt/decrypt used by thesf encryptdemotest/*.test.mjs— round-trip guarantees plus the mock end-to-end upload and sync tests
Please report vulnerabilities privately — see SECURITY.md. Do not open a public issue for security reports.