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:
- Architecture
- Keys and encryption
- What the server receives and stores
- Threat model
- Audit status
- Retention, deletion, export and continuity
- Reporting a vulnerability
- 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/cryptoand 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 fetchesauth_saltfromPOST /api/auth/salt, derives the value and posts it. The authentication service stores a bcrypt hash oflogin_secret. Source:utils/auth/loginSecret.ts,utils/auth/loginSecretClient.ts. vault_wrap_keynever 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 givevault_wrap_key. Both are still functions of the same password, so a stolenlogin_secretor 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/saltalso 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
RKso 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
RKand 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.
RKis 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.
| Suite | Construction | Written by |
|---|---|---|
0x03 | ML-KEM-1024 + XChaCha20-Poly1305 hybrid | Web (default since 2026-05-17), Android build 38+, iOS and desktop builds in testing |
0x01 | AES-256-GCM, 4-byte nonce prefix | Web fallback (see below); Android before build 38 |
0x02 | XChaCha20-Poly1305 | Send transfers and one-time secrets |
0x04 | AES-256-GCM with an 8-byte nonce prefix | Library only; no production writer |
| v0 | Original chunked AES-256-GCM format | No 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_cis the file'sCSK, wrapped under the folder-key chain that ends atRK, which is wrapped under a key derived from your password. Every step of that chain is symmetric (AES-256-GCM, XSalsa20-Poly1305, HKDF, Argon2id), andsk_pqis itself derived fromRK. 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_ccomes 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.
Share links
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 suite0x03file inside a shared folder is readable only if the owner's browser has written an extra wrap ofKunder 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.
Request links and forms
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 inspec/intake-link-pin.md. Older links withoutf=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:
| Data | Why it is there |
|---|---|
| Account email address, and the firm name and logo for firms | Sign-in, billing, email |
| Client email address on a request, when the firm enters one | Reminder emails |
| Each file's size, and the encrypted size | Quotas, upload verification |
Each file's MIME type (for example image/jpeg) as reported by the browser | Previews and downloads |
| Created, updated and deleted timestamps; download counts | Sorting, Bin, limits |
| Folder structure: which folder each file and folder sits in, item counts, the Media and Bin folder flags | Listing without decrypting the whole vault |
Name equality within your vault, through name_hash | Search |
| Cipher suite of each file, the file's public header | Decryption dispatch, upload proof |
| Public keys: ML-KEM-1024, X25519, Ed25519 | Encrypting 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 counters | Showing and revoking access |
| Request and form structure: number of items, form field types and order, limits, expiry, upload counts and sizes | Enforcing the request's limits |
| Billing records (held by the payment processor and in the database) | Payments, tax law |
| IP address, user agent and request metadata | Seen 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 orrk_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_metaare bound to the row UUID; file headers are bound tofile_idand 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/cryptois 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-uriandframe-ancestorsare'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/cryptoby 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/saltand sign-in in your browser's developer tools (no password), theContent-Security-Policyresponse header, and the bytes of any object you download (a v1 file starts withSF5\x01\x00). - File paths cited above (
utils/...,supabase/migrations/...) refer to the privateshieldfive/webrepository 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.