Security white paperand threat model

The keys, what each one protects, what the server receives and stores, and which attacks the design does and does not address. Written to be checked against the open-source library and its specifications.

Version 1 · 2026-10-24 · @shieldfive/crypto 1.0.1

Version 1, 2026-10-24. Describes the web client and server at shieldfive/web main on that date, using @shieldfive/crypto 1.0.1.

This document states how ShieldFive encrypts data, which keys exist and where they live, what the server receives and stores, and which attacks the design does and does not address. It is written so that each statement can be checked against the cryptography library, its specifications and test vectors, or against the behaviour of the running web app. The security overview is the shorter summary; where the two disagree, report it to [email protected].

Contents:

  1. Architecture
  2. Keys and encryption
  3. What the server receives and stores
  4. Threat model
  5. Audit status
  6. Retention, deletion, export and continuity
  7. Reporting a vulnerability
  8. Sources and how to check this document

Architecture

Clients

  • Web app (shieldfive.com): available. Uses @shieldfive/crypto (Apache-2.0), WebCrypto, libsodium (WASM) and @noble/post-quantum.
  • Android app: closed testing on Google Play. A separate TypeScript implementation of the same wire formats, tested against fixtures produced by the library. The app's JavaScript ships inside the signed package; there is no over-the-air code update channel.
  • iOS app: in testing, not available. Same code base as Android.
  • Desktop app: in testing, not available. Uses @shieldfive/crypto.
  • CLI (@shieldfive/cli, Apache-2.0): available on npm. Uses @shieldfive/crypto.
  • MCP server for AI assistants (@shieldfive/mcp, MIT): available on npm. Uses @shieldfive/crypto and opens the vault with an agent grant (see Agent grants).

The cryptography library, CLI and MCP server are open source. The web, Android, iOS and desktop applications are not.

Server side

  • Cloudflare is the reverse proxy and DNS for shieldfive.com. TLS from the browser terminates there. A Cloudflare Worker (dl.shieldfive.com) relays presigned ciphertext downloads from object storage; it holds no credentials.
  • Vercel runs the Next.js application: page rendering, API routes, authorisation decisions and presigned storage URLs.
  • Supabase provides Postgres (row-level security on user tables; writes to key and file columns go through server routes) and the authentication service (GoTrue). The database is in Frankfurt.
  • Backblaze B2 stores encrypted file objects (Amsterdam). Object names are random UUIDs, not file names. Uploads go from the browser straight to B2 through presigned URLs.

The full sub-processor list, with locations and transfer bases, is Annex III of the DPA.

Keys and encryption

All randomness below comes from crypto.getRandomValues (WebCrypto) or libsodium's generator. Argon2id runs in libsodium (crypto_pwhash); the moderate preset is libsodium's OPSLIMIT_MODERATE / MEMLIMIT_MODERATE: 3 passes, 256 MiB. The library does not expose libsodium's interactive preset for password derivation (name envelopes are the one exception, below).

One password, two derivations

A ShieldFive account has one password. The browser derives two unrelated values from it, with different salts and different inputs:

login_secret   = base64url(Argon2id(
                   "shieldfive/v1/auth/login-secret\n" || password,
                   auth_salt,   // 32 random bytes, HKDF-compressed to 16
                   moderate))

vault_wrap_key = Argon2id(password,
                          uk_salt,   // 16 random bytes
                          moderate)  // 32 bytes, an AES-256-GCM key
  • Sign-in sends login_secret, not the password. The browser fetches auth_salt from POST /api/auth/salt, derives the value and posts it. The authentication service stores a bcrypt hash of login_secret. Source: utils/auth/loginSecret.ts, utils/auth/loginSecretClient.ts.
  • vault_wrap_key never leaves the browser. It only unwraps the root key (next section).
  • The domain prefix and the independent salts mean that knowing login_secret (or its bcrypt hash) does not give vault_wrap_key. Both are still functions of the same password, so a stolen login_secret or hash is an offline guessing target for the password at the cost of one moderate Argon2id per guess.
  • Downgrade refusal. Once a browser has seen an address on this scheme it pins that fact locally, and refuses to send anything if the server later asks for the old scheme. If the salt lookup fails, nothing credential-bearing is sent.
  • Accounts created before 2026-09-20 sent the password over TLS at sign-in. Such an account migrates at its next sign-in: the password reaches the server once, at that sign-in, so it can be checked before the derived value replaces it.
  • To let the vault derivation start in parallel with sign-in, /api/auth/salt also returns the vault salt and preset before authentication. A salt is not secret; the wrapped root key it applies to is only returned after sign-in. For an address with no account the server returns a deterministic pseudo-salt so the response does not reveal whether an account exists.
  • Optional TOTP two-factor authentication is available.

Root key and recovery key

RK             = 32 random bytes            // vault root key
rk_wrapped_uk  = AES-256-GCM(vault_wrap_key, RK)
recovery_key   = 32 random bytes
rk_wrapped_rec = AES-256-GCM(recovery_key, RK)
                 // each wrap: fresh 12-byte random IV

Both wraps are stored in vault_keys; the server never holds RK, vault_wrap_key or recovery_key. Source: utils/vaultKeyClient.ts, utils/keyring.ts.

  • The recovery key is shown once at sign-up as 24 BIP39 English words: the 32 bytes plus an 8-bit SHA-256 checksum. The words are an encoding of the key bytes; no BIP39 seed derivation (PBKDF2) is applied. Keys issued before the word format were shown as base64, which is still accepted. Source: utils/recoveryKeyWords.ts.
  • A password reset by email alone gives back the account, not the files. To keep existing files readable the reset needs the recovery key, which unwraps RK so it can be re-wrapped under the new password.
  • Without either the password or the recovery key, the remaining option is "start a new empty vault": a new RK and recovery key are generated, the old key material is retired, and old items are moved into an archive folder. Nothing is deleted, and nothing in the archive can be decrypted.
  • Vault wraps made before Argon2id became the default used PBKDF2-SHA-256 (310,000 to 600,000 iterations). They remain readable and are re-wrapped with Argon2id on the next password change.
  • RK is also the account's master secret for deterministic key derivation: the ML-KEM-1024, X25519 and Ed25519 keypairs below are derived from it with HKDF-SHA-256, so they need no separate backup.

Unlocked session

While the vault is unlocked, RK and the derived ML-KEM secret key are held in page memory. To survive a reload, RK is also stored in sessionStorage encrypted under a non-extractable AES key kept in IndexedDB, for at most 12 hours. Where IndexedDB cannot hold a key (for example some private-browsing modes), the fallback record embeds the wrapping key, which makes it equivalent to plaintext for anything that can read that tab's storage. JavaScript cannot guarantee that key bytes are wiped from memory. Signing out clears both records.

Folder keys and file keys

FK(folder)    = 32 random bytes
fk_wrapped    = AES-256-GCM(parent key, iv = 12 random bytes, FK)
                // parent key: RK for a top-level folder,
                // otherwise the parent folder's FK
CSK(file)     = 32 random bytes                       // file content key
csk_wrapped   = AES-256-GCM(containing folder's FK, or RK at the root, CSK)

Specified in spec/vault-formats.md section 1. Moving a file re-wraps its CSK under the destination folder key; the file ciphertext is not touched.

File content formats

New uploads from the web app use the v1 wire format, suite 0x03. The format is specified in spec/format-v1.md: a header (magic, suite byte, 16-byte random file_id, chunk size, chunk count, plaintext size, suite payload) authenticated by HMAC-SHA-256, then length-prefixed chunks of 5 MiB plaintext. Each chunk's AAD binds its index, the total chunk count and a final-chunk flag, so truncation, reordering and splicing between files are detected. Chunk keys and nonces are derived per file from the content key and file_id with HKDF-SHA-256.

SuiteConstructionWritten by
0x03ML-KEM-1024 + XChaCha20-Poly1305 hybridWeb (default since 2026-05-17), Android build 38+, iOS and desktop builds in testing
0x01AES-256-GCM, 4-byte nonce prefixWeb fallback (see below); Android before build 38
0x02XChaCha20-Poly1305Send transfers and one-time secrets
0x04AES-256-GCM with an 8-byte nonce prefixLibrary only; no production writer
v0Original chunked AES-256-GCM formatNo longer written; read-only. No AEAD-bound chunk order; integrity rests on per-chunk SHA-1 hashes checked on download

Fallback. After every unlock the browser re-derives the account's ML-KEM public key and compares it with the copy the server holds. If they differ, the web app writes suite 0x01 instead of 0x03 until the mismatch is resolved, and says so on screen. Source: utils/cryptoV1Flag.ts, utils/keyring.ts.

The post-quantum hybrid, and what it protects

For suite 0x03 (spec section):

S_c        = 32 random bytes                  // classical share
mlkem_ct, S_pq = ML-KEM-1024.Encaps(pk_pq)     // 1568-byte ct, 32-byte secret
wrapped_Sc = secretbox(K_c, S_c)              // XSalsa20-Poly1305
K          = HKDF-SHA-256(ikm  = S_c || S_pq,
                          salt = file_id,
                          info = "shieldfive/v1/pq-hybrid/combine",
                          L    = 32)
chunks     = XChaCha20-Poly1305, keys and nonces derived from K
header_mac = HMAC-SHA-256 under HKDF(K, file_id, "shieldfive/v1/header-mac")

seed       = HKDF-SHA-256(ikm  = RK,
                          info = "shieldfive/v1/pq-hybrid/ml-kem-1024-seed",
                          L    = 64)
pk_pq, sk_pq = ML-KEM-1024.KeyGen(d = seed[0..32], z = seed[32..64])

ML-KEM is @noble/post-quantum 0.6.1 (FIPS 203). The keypair derivation is pinned by vector 10_ml_kem_keypair_derivation in tests/vectors/vectors.json. Recovering K needs both S_pq (from sk_pq) and S_c (from K_c).

Where the classical share comes from depends on who wrote the file, and this decides what the ML-KEM layer adds:

  • Files you upload to your own vault. K_c is the file's CSK, wrapped under the folder-key chain that ends at RK, which is wrapped under a key derived from your password. Every step of that chain is symmetric (AES-256-GCM, XSalsa20-Poly1305, HKDF, Argon2id), and sk_pq is itself derived from RK. These files do not depend on public-key cryptography for confidentiality at rest, so harvest-now-decrypt-later against them rests on the symmetric primitives with or without ML-KEM. ML-KEM adds an independent second share.
  • Files a client uploads through a request link. The sender has no account and no shared secret, so K_c comes from an ephemeral-static X25519 exchange with the firm's published key. Here X25519 alone would fall to a quantum computer; ML-KEM-1024 is what keeps a recorded upload confidential in that case. An attacker has to break both X25519 and ML-KEM-1024.

Not covered by the post-quantum layer: password share links and folder links (the shared key is wrapped under a password-derived key, which is symmetric); Send transfers and one-time secrets (suite 0x02, symmetric key in the link); TLS between your device and Cloudflare, which is negotiated by your browser and Cloudflare. Signatures are Ed25519; ML-DSA is reserved in the format and not implemented.

Names, thumbnails and dates

File, folder and album names are encrypted in the browser as version 6 name envelopes (spec/vault-formats.md section 2):

name_key = Argon2id(base64(parent key), salt = 16 random bytes,
                    interactive preset, 32 bytes)
envelope = AES-256-GCM(name_key, iv = 12 random bytes,
                       aad = row UUID, UTF-8 name)

The parent key is the folder key (or RK), which has 256 bits of entropy; the Argon2id step exists for compatibility with older envelopes, not for strength. The row UUID as AAD stops the server moving a name onto another row. Older v4 envelopes (no AAD) and v3 envelopes are still read. A file's name is re-encrypted under the destination folder key when it moves.

Thumbnails (small previews made on your device) are encrypted and stored as separate objects:

thumb_key = HKDF-SHA-256(ikm = CSK, salt = empty,
                        info = "shieldfive/v1/thumbnail")
thumbnail = AES-256-GCM(thumb_key, iv = 12 random bytes,
                        image bytes)

Thumbnails made by Android and by web builds before 2026-10-12 use the CSK directly with a 4-byte random nonce prefix; they are still read. Source: utils/thumbnail.ts.

Capture date (enc_meta). When the web app knows when a photo or video was taken (a Google Takeout sidecar, or the file's last-modified time), it stores it encrypted, so the media view can sort by it:

meta_key = HKDF-SHA-256(ikm = CSK, salt = empty,
                       info = "shieldfive/v1/file-meta")
ct       = AES-256-GCM(meta_key, iv = 12 random bytes, aad = row UUID,
                       {"v":1,"takenAt":"<ISO 8601>"})
enc_meta = "m1:" || base64(iv) || ":" || base64(ct)

A database constraint limits the column to that shape and 1,024 characters, so a faulty client cannot park cleartext there. Source: utils/fileMeta.ts, migration 20261024120000_files_enc_meta.sql.

Search hashes

To find a name without decrypting every row, each file, folder and album row carries a deterministic hash:

name_hash = "v4:" || hex(HMAC-SHA-256(key = SHA-256(base64(RK)),
                                      msg = lowercase(name)))

The server cannot invert it without RK, but it can see that two of your rows have the same name (case-insensitively), because they have the same hash.

File links (/s/<slug>) and folder links (/f/<slug>). The owner's browser wraps the file's key (the CSK, or K for a suite 0x03 file; for a folder link, the folder's FK) under a share password:

share_key    = Argon2id(password, 16 random bytes, moderate)
share_wrap   = AES-256-GCM(share_key, shared key)
verifier     = Argon2id(password, separate 16 random bytes, moderate)
stored       = share_wrap, salts, SHA-256(verifier)

The recipient derives verifier locally and sends it; the server compares SHA-256(verifier) in constant time, under per-link and per-IP rate limits, and only then returns the wrap and ciphertext. The password itself is never sent. Shares created before the verifier scheme sent the password for a bcrypt comparison; new shares always use the verifier.

  • If the owner does not choose a password, the browser generates one (sf- + 24 random bytes) and puts it in the link's fragment (#p=...). Browsers do not send the fragment to the server. Anyone holding the whole link can open the file. An owner-chosen password is kept out of the link and is a second factor.
  • A file link reaches one file. A folder link reaches the folder subtree: the recipient re-derives each child folder key from the shared FK. A suite 0x03 file inside a shared folder is readable only if the owner's browser has written an extra wrap of K under the parent folder key (pqk_fk_wrapped); otherwise it shows as unavailable.
  • Expiry, download limits and country restrictions are enforced by the server, not cryptographically.

Send (/t/<id>) and one-time secrets (/secret/s/<id>). No account is involved. The browser generates a 32-byte key, encrypts the files (or the secret) with suite 0x02, and puts the key in the link fragment. File names and the optional message are encrypted under the same key. The link is the credential.

A firm (or any account) can collect files from people without accounts at /r/<slug>, /u/<handle> and /form/<slug>. The uploader's browser encrypts each file to the request owner's published keys with suite 0x03: K_c from ephemeral X25519 against the owner's static X25519 key, S_pq from ML-KEM-1024 against the owner's ML-KEM key. Names, titles and labels are AES-GCM-encrypted under a 32-byte key in the link fragment (k=), or under a per-submission key for permanent links; the page refuses to upload if that key is missing. Source: utils/inboundIntake.ts, @shieldfive/crypto inbound.

  • Key authenticity. The owner signs its encryption keys with an Ed25519 identity key. Links minted since the pinning change carry f=SHA-256("shieldfive/inbound/signing-key-pin/v1" || signing key) in the fragment, computed from the key the owner's browser holds, not from the database. The uploader's page refuses to encrypt if the served signing key does not match the pin. Specified in spec/intake-link-pin.md. Older links without f= are marked unconfirmed to the uploader.
  • Firm identities. For a firm with several seats, received files are encrypted to a firm identity generated from a random seed, not to anyone's personal vault key. Each member receives a copy of that identity sealed to their own keys with the same hybrid construction. Members never receive the owner's vault root key.
  • PINs. An optional link PIN is checked against an Argon2id verifier, with attempt limits. It gates access to the upload page; the encryption does not depend on it.

Agent grants for AI assistants

The MCP server opens a part of the vault without RK. A grant has a bearer token (the server stores SHA-256(token)) and a 32-byte secret that never leaves a client. Folder keys, file keys and single-name keys for the granted subtree are wrapped under HKDF(secret, grant_id, "shieldfive/v1/agent-grant/wrap") with AAD naming the grant, object kind and object id. A leaked secret without the token gives nothing; a leaked token and secret give that subtree only, never RK or the ML-KEM secret key. Revocation is enforced by the server and does not re-key: material fetched before revocation stays decryptable by whoever holds the secret. Specified in spec/vault-formats.md section 3.

Upload proof

For v1 uploads the server reads the stored object's header and first chunk frame and checks them against a proof the client sends. This catches a client that, through a bug, uploads something that is not in the declared format. It does not and cannot prove that a client which has been modified to cheat encrypted the data.

What the server receives and stores

Encrypted on your device; the server holds ciphertext only: file contents; file, folder and album names; thumbnails; the capture date in enc_meta; request titles, labels and uploaded file names; form questions and answers; uploader email addresses typed into a request; Send messages; one-time secrets; all private keys and the vault root key (stored only wrapped).

Not encrypted; visible to ShieldFive and to anyone with database access:

DataWhy it is there
Account email address, and the firm name and logo for firmsSign-in, billing, email
Client email address on a request, when the firm enters oneReminder emails
Each file's size, and the encrypted sizeQuotas, upload verification
Each file's MIME type (for example image/jpeg) as reported by the browserPreviews and downloads
Created, updated and deleted timestamps; download countsSorting, Bin, limits
Folder structure: which folder each file and folder sits in, item counts, the Media and Bin folder flagsListing without decrypting the whole vault
Name equality within your vault, through name_hashSearch
Cipher suite of each file, the file's public headerDecryption dispatch, upload proof
Public keys: ML-KEM-1024, X25519, Ed25519Encrypting to you; key-change detection
Share settings: which files are shared, expiry, download limits, allowed countries, per-share access events with viewer country and an HMAC of the IP address (kept 30 days)Enforcing the link's limits
Agent grant labels, scope and usage countersShowing and revoking access
Request and form structure: number of items, form field types and order, limits, expiry, upload counts and sizesEnforcing the request's limits
Billing records (held by the payment processor and in the database)Payments, tax law
IP address, user agent and request metadataSeen by Cloudflare and Vercel on every request. The app reads the client IP for rate limiting and download tokens, and records IP and user agent in an audit log for sign-up and privileged operations

Object storage holds ciphertext under random object names; an object's size reveals the file's approximate size (no padding). Exact retention for each of these is in the privacy policy.

Threat model

The library's spec/threat-model.md defines the cryptographic adversaries. This section applies them to the whole service.

What the design protects against

  • A compromised or curious server. Someone with full access to the database, object storage, logs and the application servers, who does not change the code sent to your browser, obtains ciphertext, wrapped keys and the metadata listed above. They do not obtain file contents, names, or keys. Their remaining option against the password is offline guessing against login_secret's bcrypt hash or rk_wrapped_uk, at one moderate Argon2id (256 MiB) per guess.
  • The storage provider and backups. B2, the disaster-recovery copy and the nightly database dumps contain the same ciphertext and wrapped keys.
  • Network attackers. TLS protects transport. Independently, an attacker between the browser and storage who can modify bytes cannot produce ciphertext that decrypts, or truncate, reorder or splice chunks without detection (v1 formats). A presigned URL alone gives ciphertext.
  • Harvest now, decrypt later. Recorded ciphertext of vault files depends on symmetric primitives and the password-derived key. Recorded request uploads are protected by ML-KEM-1024 in addition to X25519.
  • Key substitution on request links. A server that swaps a firm's encryption keys is detected on pinned links (see Request links and forms).
  • A server swapping objects or names between rows. Name envelopes and enc_meta are bound to the row UUID; file headers are bound to file_id and checked against the row when sharing.

What it does not protect against

  • Modified web client code. The web app's JavaScript is served by ShieldFive on each visit. Whoever controls the deployment (ShieldFive, an attacker who compromises the build or hosting, or anyone able to compel ShieldFive) could serve code that captures the password or keys after they are typed. This applies to every browser-delivered encrypted product. There is no reproducible build or code-signing check for the web app today.
  • A compromised device. Malware, a malicious browser extension or someone with access to the unlocked session sees what you see. See Unlocked session for what the browser stores.
  • Weak passwords. Argon2id at 256 MiB makes each guess expensive; it does not make a guessable password safe against someone who has the hash or the wrapped root key.
  • Losing both the password and the recovery key. The files cannot be recovered by anyone. There is no key escrow.
  • Anyone holding a complete share link. A link with the key in its fragment is a credential. Links pasted into chats, emails or logs carry it.
  • Metadata analysis. Sizes, MIME types, timestamps, folder structure, sharing activity, name equality and IP addresses are visible as listed above. The access pattern (what is uploaded or downloaded when) is visible to the server and the storage provider.
  • Legal compulsion. ShieldFive can be compelled to produce what it holds: account data, the metadata above, ciphertext and wrapped keys. Encryption protects contents, not the fact that an account exists or what its metadata shows. A compulsion to modify the served client is covered by the first item.
  • Revoked access that was already used. Revoking a share or an agent grant stops the server returning data; it does not re-encrypt data the recipient already downloaded, or keys they already obtained.
  • Side channels in the browser's or runtime's implementation of the primitives.

Mitigations for the limits above

  • Open cryptography. @shieldfive/crypto is Apache-2.0, with a written wire-format spec, key-derivation spec, threat model and deterministic test vectors, so the formats can be implemented and checked independently.
  • A client that is not served per visit. The Android app's code is fixed in the signed package from Google Play and changes only through an app update. It is not open source.
  • Content Security Policy. HTML responses carry an enforced CSP: default-src 'none', scripts only from the same origin with a per-request nonce, plus Cloudflare Turnstile (and Stripe on checkout pages); object-src, base-uri and frame-ancestors are 'none'. This limits injected and third-party script. It does not protect against changes to the first-party code itself. Subresource Integrity is not used.
  • Downgrade refusal for sign-in and for request-link keys, described above.
  • Export. Ciphertext and the metadata needed to decrypt it can be exported and decrypted offline with the open library (see below), so access does not depend on the service continuing to run.

Audit status

No independent security audit has been carried out on the cryptography library, the web application, the mobile apps or the infrastructure. An external audit is planned and has not been commissioned.

What exists:

  • An internal review of @shieldfive/crypto by the team that wrote it: six task documents and a triage table, published at /security/internal-review. It reports 0 Critical, 0 High, 7 Medium, 17 Low and 7 Informational findings, with status and remediation per row. Internal reviews of the server and web surfaces exist and are not published.
  • Reports from external researchers sent to [email protected], fixed and credited in the security acknowledgements.

An external audit would cover, at minimum: the wire formats and their implementations in the library and the Android app; key derivation and wrapping; the web app's key handling (keyring, session storage, workers); sign-in and recovery flows; share-link and request-link verification and rate limiting; database row-level security, server routes that use the service role, and the storage authorisation path. When an audit is commissioned the firm and scope will be named on /security, and the report will be published there.

Retention, deletion, export and continuity

  • Deleting a file moves it to the Bin. Nothing is removed from the Bin on a timer. Emptying the Bin, or deleting a file permanently, removes the row and queues the object for deletion.
  • Deleting the account removes account, file and share records in the same request and queues every object for deletion; an hourly job removes them from storage, normally within about two hours.
  • After deletion, the storage provider keeps the deleted version of each object for up to 30 days, and the disaster-recovery copy in a separate storage account erases it within 60 days. Both are the same ciphertext. Database backups (the database provider's own point-in-time backups, and nightly passphrase-encrypted dumps kept 30 days on GitHub with a copy in a separate storage account) keep a deleted account's rows until they expire.
  • Kept after account deletion: the append-only audit logs (account identifier, and for privileged operations the IP address and user agent; never file contents, names or keys) and billing records for the period tax law requires. Details per category are in the privacy policy.
  • Export. You can download your ciphertext with the metadata needed to decrypt it, and decrypt offline with the open library. The procedure is at /export.
  • Continuity. For Lifetime plans the Terms commit to at least 12 months' notice before the service closes, with export available throughout. See /lifetime/continuity.

Reporting a vulnerability

Email [email protected]; the contact is also in /.well-known/security.txt. Reports are triaged, fixed and, if the reporter wants, credited by name or handle in the acknowledgements on /security. There is no bounty program. The library's SECURITY.md describes the encrypted channel and safe-harbour terms for library reports.

Sources and how to check this document

  • Library and specs: github.com/shieldfive/crypto (spec/format-v1.md, spec/key-derivation.md, spec/vault-formats.md, spec/intake-link-pin.md, spec/threat-model.md, tests/vectors/).
  • Package: @shieldfive/crypto on npm, published with provenance. The web app pins 1.0.1.
  • Web app behaviour you can observe without our source: the request bodies of POST /api/auth/salt and sign-in in your browser's developer tools (no password), the Content-Security-Policy response header, and the bytes of any object you download (a v1 file starts with SF5\x01\x00).
  • File paths cited above (utils/..., supabase/migrations/...) refer to the private shieldfive/web repository and are given so that an auditor with access can find the code.

This document is revised when any statement in it stops being true. Changes are listed below.

  • 2026-10-24: first version.