Security model
The full threat model with every invariant cross-referenced to source is
in SECURITY.md.
This page is the operator-facing companion: the shape of the system, the
parts you can rely on, and what an attacker with X capability can or
can’t do.
The trust boundary
Section titled “The trust boundary”┌────────────────────────┐ ┌──────────────────────────┐│ Browser SPA + WW │ │ Storage backend ││ (sees plaintext) │ │ (sees V7 ciphertext) ││ │ │ ││ - vault key │ │ - encrypted file blobs ││ - file content keys │ │ - encrypted filenames ││ - filenames, content │ │ - encrypted manifest │└──────────┬─────────────┘ └──────────────────────────┘ │ V7 ciphertext over HTTPS / SSH-via-relay │┌──────────┴────────────────────────────────────────────────────┐│ byo-relay (sees ciphertext + per-device JWT) ││ - device enrolment + cookies ││ - SFTP transport (proxies SSH for the SPA) ││ - share blob storage (V7 only, sweeper-purged) ││ - never plaintext, never key material, never filenames │└────────────────────────────────────────────────────────────────┘The plaintext trust boundary is your browser. Nothing outside that boundary holds plaintext or key material — this is enforced by code, not by policy.
The seven zero-knowledge invariants
Section titled “The seven zero-knowledge invariants”Every API call in the system carries either encrypted blobs, one-way auth
tokens, or public keys. The seven invariants enumerated as ZK-1 … ZK-7
in SECURITY.md:
| ID | The relay never sees | Enforced by |
|---|---|---|
| ZK-1 | Plaintext passwords | Argon2id runs in the Web Worker; only the derived auth_hash is sent. |
| ZK-2 | The client_kek_half | Derived client-side; never serialised or transmitted. |
| ZK-3 | The full KEK | Two-factor split across client_kek_half + a server shard; the relay sees only its half. |
| ZK-4 | Plaintext private keys | KEK-wrapped before transmission. |
| ZK-5 | Plaintext file content | AES-256-GCM encrypted before upload. |
| ZK-6 | Plaintext filenames | AES-GCM-SIV (deterministic nonce) encrypted before upload. |
| ZK-7 | Plaintext recovery key | Displayed once at vault creation; never stored, never sent. |
Reviewers can grep for each ZK-N tag in source. New API endpoints land
with a “verified ZK-1…ZK-7” check before merge.
Key hierarchy
Section titled “Key hierarchy”Conceptually, every key in the system descends from one of three roots: the user’s passphrase, the recovery key, or a passkey-derived secret.
passphrase ──Argon2id──▶ kek_half_client ──┐ ├──▶ KEK ──unwrap──▶ vault_key ──▶ content_keys kek_half_server ──┘ │ │recovery_key ──HKDF──▶ rec_wrapping_key ──unwrap──▶ vault_key ──────┘
passkey (PRF mode) ──HKDF──▶ wrapping_key_vk ──unwrap──▶ vault_key (opt-in)vault_keyis the per-vault root. Any of the three paths (passphrase, recovery key, opt-in passkey) unwrap it independently. Compromise of one wrap does not compromise the others.content_keysare per-file random keys derived inside WASM, wrapped under the vault key, and stored in the encrypted manifest. File-level keys never cross the WASM ↔ JS boundary in plaintext.device_keyis a per-device-per-vault wrapping key for IndexedDB state. When the WebAuthn/PRF gate is on, the device key is itself wrapped by an HKDF output from the authenticator’s PRF result.
The HKDF info strings ("SecureCloud BYO …", "Wattcloud device key v1",
"Wattcloud vault_key wrap v1", etc.) are frozen protocol constants
— renaming any of them would invalidate every existing wrap. They live
in sdk-core and are append-only.
Cryptographic primitives
Section titled “Cryptographic primitives”| Function | Algorithm | Notes |
|---|---|---|
| Passphrase KDF | Argon2id | 64 MiB / 3 iter / 4-way parallel — heavy enough to make GPU/ASIC search expensive. |
| Key agreement | Hybrid X25519 + ML-KEM-1024 | Both must fail for a break. No classical-only fallback path. |
| File encryption | AES-256-GCM, chunked | Each chunk independently authenticated; chunk HMAC keyed by HKDF(content_key, "chunk-hmac-v1"). |
| Key commitment | BLAKE2b-256(content_key ‖ file_iv) | Prevents partitioning-oracle attacks. |
| Filename encryption | AES-GCM-SIV (deterministic nonce) | Same filename + key → same ciphertext, so dedup and rename detection work without leaking plaintext. |
| Key derivation | HKDF-SHA256 | Frozen info strings ("SecureCloud v6", "Wattcloud device key v1", …). |
Post-quantum strategy
Section titled “Post-quantum strategy”The relevant attacker for post-quantum is harvest-now-decrypt-later: record encrypted traffic today, store it, decrypt when quantum hardware matures. Defending against this requires the post-quantum primitives in the current handshake, not “we’ll add them later”.
Wattcloud’s hybrid construction:
- X25519 — classical elliptic-curve Diffie–Hellman. Today’s strongest classical defence; broken by a sufficiently large quantum computer.
- ML-KEM-1024 — NIST-standardised post-quantum KEM (formerly Kyber). Believed secure against quantum adversaries; new enough that classical cryptanalysis is still maturing.
Session keys are derived from a hybrid of both. An attacker has to break
both to break the session — not either-or. There is no “downgrade
to classical-only” path on the wire; the protocol does not negotiate it.
Full discussion in SECURITY.md §5.
What an attacker with X capability can do
Section titled “What an attacker with X capability can do”A capability-based way to read the threat model.
| Attacker capability | What they get | What they don’t |
|---|---|---|
| Reads relay disk | Encrypted enrolment DB rows, share-blob V7 ciphertext + share index columns (share_id, expires_at, kind, total_bytes). | Plaintext file content, filenames, vault keys, recovery key, device keys, share fragment keys, client IPs. |
| Reads relay memory at runtime | Per-device JWT signing key, in-memory rate-limiter counters (per IP), live SFTP session credentials (only while a session is active). | Vault keys, content keys, recovery key — none of these ever reach the relay. |
| MITM on the WebSocket | Ciphertext traffic, public keys, auth tokens. | Anything plaintext. The handshake binds session keys to the (X25519, ML-KEM) hybrid. |
| Steals an admin device cookie | Can mint invites, revoke devices, fill disk with shares (subject to the per-IP byte budget). | Cannot decrypt another member’s files. The zero-knowledge guarantees are math, not admin policy. |
| Same-origin DOM XSS in the SPA | Live access to the unlocked vault session for that device. | If the WebAuthn gate is in PRF mode, no access to the at-rest device-key wrapping — re-locking forces a new authenticator touch to re-derive. |
| Steals the storage backend | Encrypted vault directory tree (V7 ciphertext + AES-GCM-SIV filenames). | Vault keys, recovery key — same as relay-reads-disk. |
| Captures a session cookie post-sign-out | Nothing. Sign-out is server-side: the cookie row is revoked_at, every subsequent request 401s. | — |
The full row-by-row capability matrix lives in SECURITY.md. The
above is the operator’s pocket version.
Sandboxed runtime
Section titled “Sandboxed runtime”The relay is small and runs under a locked-down systemd unit. The relevant sandbox directives:
| Directive | Effect |
|---|---|
DynamicUser=yes | Per-service UID minted on each start. No persistent home. |
ProtectSystem=strict | /usr, /boot, /etc read-only. |
ProtectHome=yes | /root, /home invisible to the service. |
MemoryDenyWriteExecute=yes | No W+X memory pages. Blocks most shellcode + JIT-spray. |
CapabilityBoundingSet= empty | No Linux capabilities. |
SystemCallFilter=@system-service ~@privileged @resources | Strict seccomp allowlist. |
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX | No raw sockets; no routing-table sockets. |
RestrictNamespaces=yes | No namespace creation. |
PrivateTmp=yes | Per-service /tmp. |
PrivateDevices=yes | No /dev/mem, /dev/kmem, raw block devices. |
For a zero-state relay, this is roughly equivalent to a minimal
container — without the runtime trust boundary or container-daemon
attack surface. Full table in
SECURITY.md §13.1.
Release integrity
Section titled “Release integrity”Every tarball, install.sh, and CHECKSUMS.txt is keyless cosign-signed
via the GitHub Actions workflow’s OIDC identity. There is no long-lived
signing key. Verification:
- Pinned identity regex: the
release.ymlworkflow path on the upstream repo, with issuerhttps://token.actions.githubusercontent.com. install.shandwattcloud-updateruncosign verify-blobagainst this regex before extraction. A swapped release asset fails verification and the script aborts.- Forks override
TRUSTED_SIGNER_IDENTITYin/etc/wattcloud/wattcloud.env. No script patching. - Every signature is logged in the Sigstore Rekor transparency log; you can verify any release independently.
There is no “skip verification” flag and there will not be one.
Adjacent docs
Section titled “Adjacent docs”- Sharing — how the share relay extends the trust boundary without breaking ZK.
- Multi-device — pairing, SAS verification, owner / member roles.
- Identity & passkeys — passphrase, recovery key, presence vs PRF passkey.