diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d4b302f4..bd54bb27 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -69,7 +69,15 @@ jobs: - name: Build run: cargo build --workspace --all-targets --locked + # `c0mpute storage mount` has a real FUSE round-trip test; it needs + # fusermount3. C0MPUTE_REQUIRE_FUSE turns its "no FUSE here" skip into a + # failure, so CI cannot pass by silently not testing the mount. + - name: Install FUSE + run: sudo apt-get update && sudo apt-get install -y fuse3 + - name: Test + env: + C0MPUTE_REQUIRE_FUSE: "1" run: cargo test --workspace --locked # Blocking, unlike the lint job: this invariant has no legacy debt to diff --git a/Cargo.lock b/Cargo.lock index 730b71ff..6f234381 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -523,21 +523,25 @@ dependencies = [ "c0mpute-gateway", "c0mpute-placement", "c0mpute-proto", + "c0mpute-sealed", "c0mpute-secure-chat", "c0mpute-store", "c0mpute-update", "clap", "daemonize", "dialoguer", + "fuser", "hex", "libc", "reqwest", "rpassword", "serde", "serde_json", + "tempfile", "tokio", "toml", "tracing", + "zeroize", ] [[package]] @@ -556,6 +560,7 @@ dependencies = [ "c0mpute-gateway", "c0mpute-net", "c0mpute-proto", + "c0mpute-sealed", "c0mpute-secure-chat", "c0mpute-store", "c0mpute-transcode", @@ -585,6 +590,7 @@ dependencies = [ "url", "uuid", "x25519-dalek", + "zeroize", "zip", ] @@ -1461,6 +1467,26 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fuser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82b6597d216503555ead6b358f341ef748869bf5c6fbae6a0cb9dd231baecfd" +dependencies = [ + "bitflags 2.13.2", + "libc", + "log", + "memchr", + "nix 0.31.3", + "num_enum", + "page_size", + "parking_lot 0.12.5", + "pkg-config", + "ref-cast", + "smallvec", + "zerocopy", +] + [[package]] name = "futures" version = "0.3.34" @@ -2705,6 +2731,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2939,6 +2974,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -3009,6 +3045,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -3046,6 +3104,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -3179,6 +3247,12 @@ dependencies = [ "spki 0.8.1", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "polling" version = "3.11.0" @@ -3246,6 +3320,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.19+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3571,6 +3654,26 @@ dependencies = [ "spin", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "regex" version = "1.13.1" @@ -4361,8 +4464,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -4374,6 +4477,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -4383,9 +4495,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.19+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" +dependencies = [ + "indexmap", + "toml_datetime 1.1.3+spec-1.1.0", + "toml_parser", + "winnow 1.0.5", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.5", ] [[package]] @@ -5128,6 +5261,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/apps/web/src/app/getting-started/page.tsx b/apps/web/src/app/getting-started/page.tsx index 47291e30..8bdeda43 100644 --- a/apps/web/src/app/getting-started/page.tsx +++ b/apps/web/src/app/getting-started/page.tsx @@ -11,7 +11,7 @@ export default function GettingStartedPage() {

getting-started

-

// install → identity → register → run

+

// install → identity → register → run → host → store privately

@@ -149,6 +149,46 @@ announce = "https://gw.example.org" # TLS in front of :7777`} .

+ +
+

+ Anything you store that is not deliberately public is encrypted on + your machine first, with keys only you hold. Node operators store, + replicate and serve ciphertext and never get a key. Public sites + (step 8) stay plaintext; everything else is private by default. +

+ {`$ c0mpute storage key init --backup-to /media/usb/c0mpute-identity.json +$ c0mpute storage put taxes-2026.pdf # encrypted here, 3 network copies +c0p1:3f0c9a1e5b7d2c4a8e6f +$ c0mpute storage get c0p1:3f0c9a1e5b7d2c4a8e6f -o taxes-2026.pdf + +$ c0mpute storage key show # your c0ps1... identity, safe to share +$ c0mpute storage share c0p1:3f0c... --with c0ps1.Zb3... +$ c0mpute storage revoke c0p1:3f0c... --from c0ps1.Zb3... +$ c0mpute storage link c0p1:3f0c... # https://gateway.c0mpute.com/p//#k=... + +$ c0mpute hosting publish ./photos --name family --private +$ c0mpute storage mount ~/Private # encrypted FUSE volume (Linux) +$ c0mpute storage put press-kit.zip --public # plaintext, on purpose`} +

+ Lost keys mean lost data. First use + makes you save an encrypted backup of your identity; nobody can + recover it for you. A link's key sits after #, + which browsers never send to the gateway: the page decrypts in your + browser. +

+

+ What an operator can see: ciphertext, its size rounded up to a + coarse bucket (at most about 12% over), and when it is written and + read. What it cannot see: the content, file names, the exact size, + or who it is shared with, and the object alone does not say who + owns it. Full threat model in{" "} + + docs/private-storage.md + + . +

+
); } @@ -156,14 +196,16 @@ announce = "https://gw.example.org" # TLS in front of :7777`} function Section({ number, label, + id, children, }: { number: string; label: string; + id?: string; children: React.ReactNode; }) { return ( -
+

[{number}]{" "} {label} diff --git a/dips/0012-storage-plugin.md b/dips/0012-storage-plugin.md index 60166e2f..5a8b99a4 100644 --- a/dips/0012-storage-plugin.md +++ b/dips/0012-storage-plugin.md @@ -5,9 +5,9 @@ status: Accepted authors: - anthony@profullstack.com created: 2026-05-03 -updated: 2026-05-03 +updated: 2026-11-09 discussion: -implementation: c0mpute-store::erasure (shipped) + storage role on workers + HTTP shard endpoints (in progress) +implementation: c0mpute-store::erasure (shipped) + storage role on workers + HTTP shard endpoints (shipped) + `private` tier (implemented: c0mpute-sealed, c0mpute-core::sealed_store, docs/private-storage.md) supersedes: superseded-by: --- @@ -110,7 +110,7 @@ Standard parameters; same Storj uses, well-trodden territory. |---|---|---|---| | `cheap` | 3-copy replication | optional | Low-stakes / replaceable / dev | | `verified` | RS 10/14 | server-side at-rest (worker-local key) | Default production | -| `private` | RS 10/14 | customer-encrypted before PUT (E2E) | Sovereignty / regulated content | +| `private` | 3 network copies (hosting layer) | customer-encrypted before upload (E2E), **implemented, and the default** | Everything not deliberately public | `private` is the genuine differentiator — workers process / serve opaque ciphertext. If the workload is "store and serve later" without @@ -118,6 +118,33 @@ any worker-side processing of plaintext, E2E is straightforward. If the workload is "transcode this video", `private` doesn't apply because the worker needs plaintext. +### `private` tier: implemented (2026-11-09) + +Status: **Implemented.** The `private` tier is the default for +`c0mpute storage put`; `--public` opts out. Node operators only ever +hold ciphertext for anything that is not deliberately public. Full +description and threat model: [docs/private-storage.md](../docs/private-storage.md). + +- **Format** `c0mpute-private-v1` (crate `c0mpute-sealed`): random + per-object data key; AES-256-GCM over 64 KiB chunks in the STREAM + construction (chunk index and last flag in nonce and AAD); HKDF key + commitment; metadata (names, types, exact size) encrypted inside the + stream; Padmé size padding; X25519 key wrap in 16 fixed anonymous + slots; per-object ed25519 owner signature. No convergent encryption. +- **Keys** derive from the user's c0mpute identity seed (`c0mpute dm` + keyfile, Argon2id, mandatory encrypted backup on first use). Sharing + rewraps the key (header only); revocation re-encrypts under a new key. +- **On the network** a sealed object is a hosting site with one opaque + file, published under a per-object key and random name, so + replication, healing, gateway serving and proof-of-storage challenges + run unchanged over ciphertext and objects cannot be grouped by owner. +- **Surfaces:** `c0mpute storage put|get|share|revoke|link|rm|ls --private|key`, + `c0mpute hosting publish --private` (browser loader at `/p//#k=...`, + key never sent to the gateway), `c0mpute storage mount` (FUSE volume, + Linux). OpenDisk descriptors advertise `accepts.encrypted: true`. +- **Deviation from CIP-011:** no convergent encryption, by decision: + cross-user equality leaks are worse than losing dedup. + ### HTTP wire format Object PUT (multipart not required; raw body works): @@ -165,8 +192,8 @@ GET /storage/v1/shards/ ``` Auth on PUTs: signed-request envelope keyed by CoinPay DID -(DIP-0007). Reads of public objects don't require auth; reads of -`private` tier do. +(DIP-0007). Reads need no auth for either tier: a `private` object is +ciphertext, so confidentiality comes from the owner's key, not an ACL. ### Phase plan diff --git a/docs/hosting.md b/docs/hosting.md index 3938aae4..997a4e1d 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -211,3 +211,11 @@ Payments move only between the payer and the hosts it chose; c0mpute holds nothi Escrow (money held until a window is proven) and proof-of-serve challenges from DIP-0015 are not implemented: payers pay from their own wallet after the fact. Free hosting stays free. + +## Private sites + +`c0mpute hosting publish ./dir --name mysite --private` seals the whole site on +this machine and prints a link like `https://gateway.c0mpute.com/p//#k=`. +Gateways serve a fixed loader page and the ciphertext; the browser decrypts it +with the key after `#`, which is never sent to the gateway. Republishing keeps +the link; `--rekey` invalidates it. See [private storage](private-storage.md). diff --git a/docs/prds/011-encryption-keys.md b/docs/prds/011-encryption-keys.md index da5690b0..c3a58406 100644 --- a/docs/prds/011-encryption-keys.md +++ b/docs/prds/011-encryption-keys.md @@ -1,7 +1,7 @@ --- cip: 011 title: "Client-side encryption and key management" -status: Draft +status: Implemented (differently; see note) authors: - anthony@profullstack.com created: 2026-08-29 @@ -13,6 +13,14 @@ implementation: estimate: "2–3 weeks" --- +> **Implemented 2026-11-09** as the DIP-0012 `private` tier, see +> [docs/private-storage.md](../private-storage.md). Differences from this +> draft: no convergent encryption (no cross-user or cross-object equality +> leak, at the cost of dedup); sizes are padded (Padmé); per-object random +> data keys wrapped to X25519 storage keys rather than a volume key +> hierarchy; volumes (`c0mpute storage mount`) seal each file as its own +> object. + ## Summary Encrypt file content and metadata on the client so storage providers hold only diff --git a/docs/private-storage.md b/docs/private-storage.md new file mode 100644 index 00000000..9a2fef75 --- /dev/null +++ b/docs/private-storage.md @@ -0,0 +1,157 @@ +# Private storage: node operators only ever hold ciphertext + +Anything you store on the c0mpute network that is not deliberately public is +encrypted on your own machine before it leaves, with keys only you hold. The +nodes that store, replicate, serve and prove your data hold opaque bytes and +no key. This is the `private` tier of [DIP-0012](../dips/0012-storage-plugin.md), +and it is the default. + +Public hosted websites (`c0mpute hosting publish`) stay plaintext: they are +public by design. Everything else is private unless you say `--public`. + +## Use it + +```sh +# First use creates your c0mpute identity and makes you save its encrypted +# backup (a file on another disk, or confirm you copied the printed text). +c0mpute storage key init --backup-to /media/usb/c0mpute-identity.json + +# Store a file: encrypted here, published as ciphertext, 3 self-healing copies. +c0mpute storage put taxes-2026.pdf +# c0p1:3f0c9a1e5b7d2c4a8e6f + +# Read it back on any machine with your identity (dm restore puts it there). +c0mpute storage get c0p1:3f0c9a1e5b7d2c4a8e6f -o taxes-2026.pdf + +# Share with someone: they run `c0mpute storage key show` and send you their +# c0ps1... identity. Only the key header changes; nothing is re-uploaded. +c0mpute storage share c0p1:3f0c9a1e5b7d2c4a8e6f --with c0ps1.Zb3... + +# Stop sharing: re-encrypts under a new key and publishes the new version. +c0mpute storage revoke c0p1:3f0c9a1e5b7d2c4a8e6f --from c0ps1.Zb3... + +# A link anyone can open (the key is after #, which browsers never send): +c0mpute storage link c0p1:3f0c9a1e5b7d2c4a8e6f +# https://gateway.c0mpute.com/p/3f0c9a1e5b7d2c4a8e6f/#k=... + +# Your private objects (from an encrypted index on this machine): +c0mpute storage ls --private + +# Delete: publishes a tombstone, holders drop the ciphertext. +c0mpute storage rm c0p1:3f0c9a1e5b7d2c4a8e6f --yes + +# Plaintext, on purpose (readable by anyone with the hash): +c0mpute storage put press-kit.zip --public +``` + +### Private sites + +```sh +c0mpute hosting publish ./family-photos --name family-photos --private +# open: https://gateway.c0mpute.com/p//#k= +``` + +The gateway serves a fixed loader page and the site's ciphertext; the browser +decrypts it with the key from the link, checks the owner's signature (it needs +a browser with Ed25519 in WebCrypto: Chrome 137, Firefox 129, Safari 17 or +later), and renders it in a sandboxed frame with an opaque origin: it cannot +read the gateway's origin, open popups, or load anything from other hosts +(it can still make requests to the gateway itself). Republishing the +same `--name` keeps the link working; `--rekey` kills every link given out. +Relative links, images, stylesheets and scripts inside the site work; code +that `fetch()`es other files of the site does not. + +### Encrypted volume (Linux) + +```sh +c0mpute storage mount ~/Private --volume backups +``` + +A FUSE filesystem: apps and backup tools read and write ordinary files. +Each file is sealed when it is closed and stored as its own private object; +the directory tree is one more private object. Whole-file semantics (a file +is fetched into memory when opened), so it suits documents, photos and backup +archives, not databases. Needs `fusermount3` (package `fuse3`). + +## What an operator can and cannot see + +| An operator holding your data | can see | cannot see | +|---|---|---| +| Content | ciphertext | any plaintext byte | +| File names, types, folder structure | nothing (encrypted inside the object) | | +| Size | a padded size: lengths round up to Padmé buckets (at most about 12% over, a few bits of the true length); everything under 4 KiB looks the same | the exact size | +| Owner | a per-object signing key and a random 26-character name, different for every object, announced by a throwaway peer id per session | who owns it from the object alone. Objects published in one session share a source address and arrive close together in time, and a volume rewrites its directory object after each file, so a node watching the gossip can guess that some objects belong together | +| Sharing | 16 fixed key slots per object, all shaped alike | who it is shared with, or with how many | +| Time | when an object is written, updated and read, and from where | why | +| That it is private | yes: a private object is a hosting site with one opaque file | | + +Gateways never decrypt. A private link's key lives after `#`, which browsers do +not send to servers, and the CLI never sends a key or plaintext to any node, +gateway, buyer bridge or log. + +What protects you is the key, not an access list: reads of the ciphertext are +open (any gateway serves `/s//o`), exactly like public objects. + +### What we do not protect against + +- **Lost keys mean lost data.** There is no recovery, by design. Keep the + encrypted identity backup safe and remember its password. +- **A compromised machine.** Malware on the computer that holds your identity + can read your data, as with any end-to-end scheme. +- **Revocation is forward-only.** Anyone you shared with may have kept a copy + of what they could read. `revoke` re-encrypts, so old keys and links open + nothing current. +- **Traffic analysis.** Access timing and padded sizes are visible. +- **Replay of an older version.** Every object carries a signed version + number, and the CLI remembers the newest version it has seen of each object + and refuses older ones, so a node cannot quietly undo a revocation or a + volume write on a machine that saw the newer version. A machine that never + saw it cannot tell, and the browser loader does not keep this memory. +- **The browser loader shares an origin** with the public sites that gateway + serves. The key stays in the fragment and the decrypted page is sandboxed, + but a gateway you do not trust can serve a different loader page; for + anything sensitive, `c0mpute storage get` is the stronger path. +- **Post-quantum.** Key wrapping is X25519. Data with a decades-long secrecy + horizon should wait for a hybrid ML-KEM wrap (the primitives are already in + the tree for `c0mpute dm`). + +## How it works + +The format is `c0mpute-private-v1`, implemented in the `c0mpute-sealed` crate. + +- **Data key.** Every object gets a random 256-bit data key (DEK). It is never + used directly: the body key and a key commitment are derived from it with + HKDF-SHA256 and a random 128-bit stream id, so every encryption has its own + body key and nonce space. +- **Content and metadata.** The plaintext stream is + `metadata JSON | owner signature | content | zero padding`, cut into 64 KiB + chunks and sealed with AES-256-GCM in the STREAM construction: the chunk + index and a last-chunk flag are bound into both the nonce and the associated + data, so reordering, dropping or appending chunks fails authentication. +- **Key commitment.** AES-GCM is not key-committing, so the header carries + `HKDF(DEK, stream id, "commit")`. A reader checks it before decrypting, so one + object cannot open to different plaintexts under different keys. +- **Key wrapping.** The DEK is wrapped to each reader's X25519 storage key + (ephemeral X25519, HKDF, AES-256-GCM) in one of 16 fixed slots; unused slots + hold a fresh public key and random bytes, so they look like real ones. Slots + carry no recipient id: readers find theirs by trying them. + The owner also keeps an encrypted roster of who the object is wrapped to, + which other readers cannot open. +- **Authenticity.** The owner signs the content hash, metadata hash (which + includes a monotonic version), stream id and commitment with a per-object + ed25519 key. Readers (the CLI and the browser loader) verify it and check + that the site id is derived from that key, so a node, a gateway or another + key holder cannot substitute or forge an object, even one encrypted to the + reader's key. +- **No convergent encryption.** The same file stored twice produces unrelated + ciphertext, so nobody can confirm you hold a known file. +- **Keys.** Storage keys are derived from your c0mpute identity seed (the one + `c0mpute dm` uses; Argon2id-protected keyfile, encrypted backup) under their + own HKDF labels. Restoring that backup restores access to everything. +- **On the network.** The sealed blob is published as a hosting site with one + file, so replication (3 copies), healing, gateway serving and + proof-of-storage challenges all run unchanged over ciphertext. Files over + 192 MiB are split into separately sealed parts whose keys live only inside + the sealed root object. + +All key types zeroize on drop. diff --git a/node/crates/c0mpute-cli/Cargo.toml b/node/crates/c0mpute-cli/Cargo.toml index 75010512..de667f17 100644 --- a/node/crates/c0mpute-cli/Cargo.toml +++ b/node/crates/c0mpute-cli/Cargo.toml @@ -16,6 +16,8 @@ c0mpute-bench = { workspace = true } c0mpute-proto = { workspace = true } c0mpute-update = { workspace = true } c0mpute-secure-chat = { workspace = true } +c0mpute-sealed = { workspace = true } +zeroize = { workspace = true } c0mpute-store = { workspace = true } c0mpute-placement = { workspace = true } reqwest = { workspace = true } @@ -36,3 +38,16 @@ dialoguer = "0.11" [target.'cfg(unix)'.dependencies] daemonize = "0.5" libc = "0.2" + +# `c0mpute storage mount` (encrypted FUSE volume), Linux only. No libfuse: +# fuser mounts through the `fusermount3` binary, so the static musl release +# build has nothing extra to link. Build without it: --no-default-features. +[target.'cfg(target_os = "linux")'.dependencies] +fuser = { version = "0.18.0", default-features = false, optional = true } + +[features] +default = ["fuse"] +fuse = ["dep:fuser"] + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/node/crates/c0mpute-cli/src/main.rs b/node/crates/c0mpute-cli/src/main.rs index 4530486a..c7ae5146 100644 --- a/node/crates/c0mpute-cli/src/main.rs +++ b/node/crates/c0mpute-cli/src/main.rs @@ -28,8 +28,11 @@ mod dm_cli; use dm_cli::run_chat; mod gameserver; mod peers; +mod private_storage; mod promo; mod storage; +#[cfg(all(target_os = "linux", feature = "fuse"))] +mod mount; mod temporal; mod text; mod train; @@ -131,10 +134,11 @@ enum Cmd { #[command(subcommand)] cmd: JobCmd, }, - /// Erasure-coded object storage on this node (DIP-0012). + /// Object storage (DIP-0012), private by default. /// - /// Objects are content-addressed and split into Reed-Solomon shards. Run - /// `c0mpute storage tiers` for the redundancy and price table. + /// `put` encrypts on this machine and the network only ever holds + /// ciphertext; `--public` stores plaintext. Run `c0mpute storage tiers` + /// for the redundancy and price table. #[command(after_long_help = storage::unimplemented_note())] Storage { #[command(subcommand)] @@ -849,6 +853,17 @@ enum HostingCmd { /// The OpenDisk marketplace to pick hosts from (discovery only). #[arg(long, default_value = c0mpute_core::opendisk::DEFAULT_D1SKS_URL)] d1sks: String, + /// Publish a private site: encrypted on this machine, served only to + /// people with its link (the browser decrypts; gateways see only + /// ciphertext). Sites are public by default. + #[arg(long)] + private: bool, + /// With --private: draw a new key, killing every link given out so far. + #[arg(long, requires = "private")] + rekey: bool, + /// With --private: the gateway the printed link points at. + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, }, /// Show the c0mpute hosting nodes d1sks lists, ranked the way /// `publish --disks` would pick them. @@ -4017,7 +4032,16 @@ async fn run_hosting(cmd: HostingCmd) -> Result<()> { country, copies, d1sks, + private, + rekey, + gateway, } => { + if private { + if !domains.is_empty() || disks.is_some() { + anyhow::bail!("--private sites are reached by their link only: drop --domain and --disks"); + } + return private_storage::publish_site(&dir, &name, rekey, &gateway, std::time::Duration::from_secs(wait_secs)).await; + } let domains: Vec = domains.into_iter().map(|d| d.to_ascii_lowercase()).collect(); let market = match disks { Some(spec) => Some(c0mpute_core::hosting::MarketOptions { diff --git a/node/crates/c0mpute-cli/src/mount.rs b/node/crates/c0mpute-cli/src/mount.rs new file mode 100644 index 00000000..ce7fc0cc --- /dev/null +++ b/node/crates/c0mpute-cli/src/mount.rs @@ -0,0 +1,584 @@ +//! `c0mpute storage mount DIR`: an encrypted volume as a FUSE filesystem +//! (Linux). Apps and backup tools read and write plain files in DIR; each +//! file is stored on the network as a private object, sealed on this +//! machine when it is closed, and the directory tree is one more private +//! object. Nodes hold only ciphertext and cannot tell the objects belong +//! together. +//! +//! Whole-file semantics: a file is fetched and decrypted into memory when +//! opened and sealed and published when closed (or fsynced). Good for +//! documents, photos and backup archives; not a database volume. + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use c0mpute_core::sealed_store::{BlobStore, NetworkStore, Volume}; +use c0mpute_sealed::StorageSecret; +use fuser::{ + BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation, INodeNo, LockOwner, + MountOption, OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, + ReplyOpen, ReplyWrite, Request, TimeOrNow, WriteFlags, +}; +use tokio::runtime::Handle; +use zeroize::Zeroizing; + +const TTL: Duration = Duration::from_secs(1); +const ROOT: u64 = 1; + +struct Open { + path: String, + data: Zeroizing>, + dirty: bool, +} + +/// State behind the FUSE callbacks (fuser 0.18 calls `&self`). +struct Inner { + rt: Handle, + vol: Volume, + inodes: HashMap, + paths: HashMap, + next_ino: u64, + handles: HashMap, + next_fh: u64, + uid: u32, + gid: u32, +} + +fn join(parent: &str, name: &OsStr) -> Option { + let name = name.to_str()?; + if name.is_empty() || name == "." || name == ".." || name.contains('/') { + return None; + } + Some(if parent.is_empty() { name.to_string() } else { format!("{parent}/{name}") }) +} + +fn secs(t: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(t) +} + +impl Inner { + fn new(rt: Handle, vol: Volume) -> Self { + let mut fs = Self { + rt, + vol, + inodes: HashMap::new(), + paths: HashMap::new(), + next_ino: ROOT + 1, + handles: HashMap::new(), + next_fh: 1, + uid: unsafe { libc::getuid() }, + gid: unsafe { libc::getgid() }, + }; + fs.inodes.insert(ROOT, String::new()); + fs.paths.insert(String::new(), ROOT); + fs + } + + fn ino(&mut self, path: &str) -> u64 { + if let Some(i) = self.paths.get(path) { + return *i; + } + let i = self.next_ino; + self.next_ino += 1; + self.inodes.insert(i, path.to_string()); + self.paths.insert(path.to_string(), i); + i + } + + fn path(&self, ino: u64) -> Option { + self.inodes.get(&ino).cloned() + } + + /// Size of a file, counting unsaved writes in open handles. + fn pending(&self, path: &str) -> Option<&Open> { + self.handles.values().find(|h| h.path == path) + } + + fn attr(&mut self, path: &str) -> Option { + let ino = self.ino(path); + let (kind, size, mtime, perm) = if let Some(h) = self.pending(path) { + (FileType::RegularFile, h.data.len() as u64, SystemTime::now(), 0o600) + } else if let Some(f) = self.vol.index.files.get(path) { + (FileType::RegularFile, f.size, secs(f.mtime), 0o600) + } else if self.vol.is_dir(path) { + (FileType::Directory, 0, SystemTime::now(), 0o700) + } else { + return None; + }; + Some(FileAttr { + ino: INodeNo(ino), + size, + blocks: size.div_ceil(512), + atime: mtime, + mtime, + ctime: mtime, + crtime: mtime, + kind, + perm, + nlink: if kind == FileType::Directory { 2 } else { 1 }, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 4096, + flags: 0, + }) + } + + fn save(&mut self, fh: u64) -> Result<(), Errno> { + let Some(h) = self.handles.get_mut(&fh) else { return Ok(()) }; + if !h.dirty { + return Ok(()); + } + let path = h.path.clone(); + let data = h.data.clone(); + let res = self.rt.block_on(self.vol.write(&path, &data)); + match res { + Ok(()) => { + if let Some(h) = self.handles.get_mut(&fh) { + h.dirty = false; + } + Ok(()) + } + Err(e) => { + eprintln!("c0mpute mount: could not store {path}: {e:#}"); + Err(Errno::EIO) + } + } + } + + fn open_handle(&mut self, path: String, data: Zeroizing>, dirty: bool) -> u64 { + let fh = self.next_fh; + self.next_fh += 1; + self.handles.insert(fh, Open { path, data, dirty }); + fh + } +} + +/// The FUSE side of a [`Volume`]. +pub struct VolumeFs(Mutex); + +impl VolumeFs { + pub fn new(rt: Handle, vol: Volume) -> Self { + Self(Mutex::new(Inner::new(rt, vol))) + } +} + +impl Filesystem for VolumeFs { + fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let parent = parent.0; + let Some(p) = s.path(parent) else { return reply.error(Errno::ENOENT) }; + let Some(path) = join(&p, name) else { return reply.error(Errno::ENOENT) }; + match s.attr(&path) { + Some(a) => reply.entry(&TTL, &a, Generation(0)), + None => reply.error(Errno::ENOENT), + } + } + + fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let ino = ino.0; + let Some(p) = s.path(ino) else { return reply.error(Errno::ENOENT) }; + match s.attr(&p) { + Some(a) => reply.attr(&TTL, &a), + None => reply.error(Errno::ENOENT), + } + } + + fn setattr( + &self, + _req: &Request, + ino: INodeNo, + _mode: Option, + _uid: Option, + _gid: Option, + size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + reply: ReplyAttr, + ) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let ino = ino.0; + let fh = fh.map(|f| f.0); + let Some(p) = s.path(ino) else { return reply.error(Errno::ENOENT) }; + if let Some(size) = size { + let handle = fh.filter(|f| s.handles.contains_key(f)).or_else(|| s.handles.iter().find(|(_, h)| h.path == p).map(|(k, _)| *k)); + match handle { + Some(f) => { + let h = s.handles.get_mut(&f).expect("present"); + h.data.resize(size as usize, 0); + h.dirty = true; + } + None => { + // Truncate a closed file: load, resize, store. + let mut data = if s.vol.index.files.contains_key(&p) { + match s.rt.block_on(s.vol.read(&p)) { + Ok(d) => d, + Err(_) => return reply.error(Errno::EIO), + } + } else { + Zeroizing::new(Vec::new()) + }; + data.resize(size as usize, 0); + if s.rt.block_on(s.vol.write(&p, &data)).is_err() { + return reply.error(Errno::EIO); + } + } + } + } + match s.attr(&p) { + Some(a) => reply.attr(&TTL, &a), + None => reply.error(Errno::ENOENT), + } + } + + fn mkdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, _mode: u32, _umask: u32, reply: ReplyEntry) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let parent = parent.0; + let Some(p) = s.path(parent) else { return reply.error(Errno::ENOENT) }; + let Some(path) = join(&p, name) else { return reply.error(Errno::EINVAL) }; + if s.attr(&path).is_some() { + return reply.error(Errno::EEXIST); + } + if s.rt.block_on(s.vol.mkdir(&path)).is_err() { + return reply.error(Errno::EIO); + } + match s.attr(&path) { + Some(a) => reply.entry(&TTL, &a, Generation(0)), + None => reply.error(Errno::EIO), + } + } + + fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let parent = parent.0; + let Some(p) = s.path(parent) else { return reply.error(Errno::ENOENT) }; + let Some(path) = join(&p, name) else { return reply.error(Errno::ENOENT) }; + if !s.vol.index.files.contains_key(&path) { + // Created but never stored (still open): just forget it. + let pending: Vec = s.handles.iter().filter(|(_, h)| h.path == path).map(|(k, _)| *k).collect(); + if pending.is_empty() { + return reply.error(Errno::ENOENT); + } + for fh in pending { + if let Some(h) = s.handles.get_mut(&fh) { + h.dirty = false; + h.path = String::new(); + } + } + return reply.ok(); + } + match s.rt.block_on(s.vol.remove(&path)) { + Ok(()) => reply.ok(), + Err(_) => reply.error(Errno::EIO), + } + } + + fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let parent = parent.0; + let Some(p) = s.path(parent) else { return reply.error(Errno::ENOENT) }; + let Some(path) = join(&p, name) else { return reply.error(Errno::ENOENT) }; + if !s.vol.is_dir(&path) { + return reply.error(Errno::ENOTDIR); + } + if s.vol.children(&path).next().is_some() { + return reply.error(Errno::ENOTEMPTY); + } + match s.rt.block_on(s.vol.rmdir(&path)) { + Ok(()) => reply.ok(), + Err(_) => reply.error(Errno::EIO), + } + } + + fn rename(&self, _req: &Request, parent: INodeNo, name: &OsStr, newparent: INodeNo, newname: &OsStr, _flags: RenameFlags, reply: ReplyEmpty) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let (parent, newparent) = (parent.0, newparent.0); + let (Some(p), Some(np)) = (s.path(parent), s.path(newparent)) else { return reply.error(Errno::ENOENT) }; + let (Some(from), Some(to)) = (join(&p, name), join(&np, newname)) else { return reply.error(Errno::EINVAL) }; + // Flush unsaved writes first so the data moves with the name. + let open: Vec = s.handles.iter().filter(|(_, h)| h.path == from).map(|(k, _)| *k).collect(); + for fh in &open { + if s.save(*fh).is_err() { + return reply.error(Errno::EIO); + } + } + match s.rt.block_on(s.vol.rename(&from, &to)) { + Ok(()) => { + for fh in open { + if let Some(h) = s.handles.get_mut(&fh) { + h.path = to.clone(); + } + } + if let Some(ino) = s.paths.remove(&from) { + s.inodes.insert(ino, to.clone()); + s.paths.insert(to, ino); + } + reply.ok() + } + Err(_) => reply.error(Errno::EIO), + } + } + + fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let ino = ino.0; + let flags = flags.0; + let Some(p) = s.path(ino) else { return reply.error(Errno::ENOENT) }; + if s.vol.is_dir(&p) && !s.vol.index.files.contains_key(&p) { + return reply.error(Errno::EISDIR); + } + let truncate = flags & libc::O_TRUNC != 0; + let data = if truncate || !s.vol.index.files.contains_key(&p) { + s.pending(&p).map(|h| h.data.clone()).unwrap_or_default() + } else { + match s.rt.block_on(s.vol.read(&p)) { + Ok(d) => d, + Err(e) => { + eprintln!("c0mpute mount: could not fetch {p}: {e:#}"); + return reply.error(Errno::EIO); + } + } + }; + let fh = s.open_handle(p, data, truncate); + reply.opened(FileHandle(fh), FopenFlags::empty()); + } + + fn create(&self, _req: &Request, parent: INodeNo, name: &OsStr, _mode: u32, _umask: u32, _flags: i32, reply: ReplyCreate) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let parent = parent.0; + let Some(p) = s.path(parent) else { return reply.error(Errno::ENOENT) }; + let Some(path) = join(&p, name) else { return reply.error(Errno::EINVAL) }; + let fh = s.open_handle(path.clone(), Zeroizing::new(Vec::new()), true); + match s.attr(&path) { + Some(a) => reply.created(&TTL, &a, Generation(0), FileHandle(fh), FopenFlags::empty()), + None => reply.error(Errno::EIO), + } + } + + fn read(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, offset: u64, size: u32, _flags: OpenFlags, _lock: Option, reply: ReplyData) { + let g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &*g; + let fh = fh.0; + let Some(h) = s.handles.get(&fh) else { return reply.error(Errno::EIO) }; + let start = (offset as usize).min(h.data.len()); + let end = (start + size as usize).min(h.data.len()); + reply.data(&h.data[start..end]); + } + + fn write(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, offset: u64, data: &[u8], _wf: WriteFlags, _flags: OpenFlags, _lock: Option, reply: ReplyWrite) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let fh = fh.0; + let Some(h) = s.handles.get_mut(&fh) else { return reply.error(Errno::EIO) }; + let start = offset as usize; + if h.data.len() < start + data.len() { + h.data.resize(start + data.len(), 0); + } + h.data[start..start + data.len()].copy_from_slice(data); + h.dirty = true; + reply.written(data.len() as u32); + } + + fn flush(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _lock: LockOwner, reply: ReplyEmpty) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let fh = fh.0; + match s.save(fh) { + Ok(()) => reply.ok(), + Err(e) => reply.error(e), + } + } + + fn fsync(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _datasync: bool, reply: ReplyEmpty) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let fh = fh.0; + match s.save(fh) { + Ok(()) => reply.ok(), + Err(e) => reply.error(e), + } + } + + fn release(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _flags: OpenFlags, _lock: Option, _flush: bool, reply: ReplyEmpty) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let fh = fh.0; + let res = s.save(fh); + s.handles.remove(&fh); + match res { + Ok(()) => reply.ok(), + Err(e) => reply.error(e), + } + } + + fn opendir(&self, _req: &Request, ino: INodeNo, _flags: OpenFlags, reply: ReplyOpen) { + let g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &*g; + let ino = ino.0; + match s.path(ino) { + Some(p) if s.vol.is_dir(&p) => reply.opened(FileHandle(0), FopenFlags::empty()), + Some(_) => reply.error(Errno::ENOTDIR), + None => reply.error(Errno::ENOENT), + } + } + + fn readdir(&self, _req: &Request, ino: INodeNo, _fh: FileHandle, offset: u64, mut reply: ReplyDirectory) { + let mut g = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let s = &mut *g; + let ino = ino.0; + let Some(p) = s.path(ino) else { return reply.error(Errno::ENOENT) }; + let mut entries: Vec<(u64, FileType, String)> = vec![(ino, FileType::Directory, ".".into()), (ROOT, FileType::Directory, "..".into())]; + let mut kids: Vec<(String, bool)> = s.vol.children(&p).collect(); + // Files created but not yet stored show up too. + let prefix = if p.is_empty() { String::new() } else { format!("{p}/") }; + for h in s.handles.values() { + if let Some(rest) = h.path.strip_prefix(&prefix) + && !rest.is_empty() + && !rest.contains('/') + && !kids.iter().any(|(n, _)| n == rest) + { + kids.push((rest.to_string(), false)); + } + } + for (name, is_dir) in kids { + let child = if p.is_empty() { name.clone() } else { format!("{p}/{name}") }; + let ino = s.ino(&child); + entries.push((ino, if is_dir { FileType::Directory } else { FileType::RegularFile }, name)); + } + for (i, (ino, kind, name)) in entries.into_iter().enumerate().skip(offset as usize) { + if reply.add(INodeNo(ino), (i + 1) as u64, kind, name) { + break; + } + } + reply.ok(); + } +} + +fn options() -> Vec { + vec![ + MountOption::FSName("c0mpute".into()), + MountOption::Subtype("c0mpute-private".into()), + MountOption::NoAtime, + MountOption::DefaultPermissions, + ] +} + +/// Mount `vol` at `dir` until the returned session is dropped. +pub fn mount_volume(rt: Handle, vol: Volume, dir: &Path) -> Result { + let mut config = Config::default(); + config.mount_options = options(); + fuser::spawn_mount(VolumeFs::new(rt, vol), dir, &config) + .with_context(|| format!("mount at {} (is FUSE installed? `fusermount3` must be on PATH)", dir.display())) +} + +/// `c0mpute storage mount DIR`. +pub async fn run(dir: &Path, label: &str, gateway: &str) -> Result<()> { + let secret = Arc::new(crate::private_storage::load_identity(true, None)?); + let net = Arc::new(NetworkStore::start(gateway).await?); + let store: Arc = net.clone(); + let vol = Volume::open(store, secret.clone(), label).await?; + eprintln!( + "volume {label}: {} file(s); mounting at {} (Ctrl-C to unmount)", + vol.index.files.len(), + dir.display() + ); + eprintln!(" files are encrypted on this machine when closed; the network holds only ciphertext"); + let session = mount_volume(Handle::current(), vol, dir)?; + tokio::signal::ctrl_c().await?; + if let Err(e) = session.umount_and_join() { + eprintln!("unmount: {e}"); + } + eprintln!("unmounted; making sure the network has copies of what you wrote..."); + let counts = net + .wait_for_copies(Duration::from_secs(300), |_, _| {}) + .await; + let short = counts.values().filter(|n| **n < c0mpute_core::hosting::TARGET_REPLICAS).count(); + if short > 0 { + eprintln!("note: {short} object(s) have fewer than 3 copies yet; the network keeps replicating"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use c0mpute_core::sealed_store::DirStore; + + fn fuse_available() -> bool { + std::path::Path::new("/dev/fuse").exists() + && ["fusermount3", "fusermount"].iter().any(|b| { + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).any(|d| d.join(b).is_file())) + .unwrap_or(false) + }) + } + + /// Write and read files through a real FUSE mount, unmount, mount again + /// from the store alone, and check the store holds only ciphertext. + #[test] + fn fuse_write_read_round_trip() { + if !fuse_available() { + if std::env::var_os("C0MPUTE_REQUIRE_FUSE").is_some() { + panic!("C0MPUTE_REQUIRE_FUSE is set but /dev/fuse or fusermount is missing"); + } + eprintln!("skipping: no /dev/fuse or fusermount on this machine"); + return; + } + let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let blobs = tmp.path().join("blobs"); + let mnt = tmp.path().join("mnt"); + std::fs::create_dir_all(&mnt).unwrap(); + let secret = Arc::new(StorageSecret::generate()); + let store: Arc = Arc::new(DirStore(blobs.clone())); + + let vol = rt.block_on(Volume::open(store.clone(), secret.clone(), "test")).unwrap(); + let session = mount_volume(rt.handle().clone(), vol, &mnt).unwrap(); + std::fs::create_dir(mnt.join("taxes")).unwrap(); + std::fs::write(mnt.join("taxes/return-2026.txt"), b"SECRET-INCOME-4242").unwrap(); + let big: Vec = (0..300_000u32).map(|i| (i % 241) as u8).collect(); + std::fs::write(mnt.join("archive.bin"), &big).unwrap(); + assert_eq!(std::fs::read(mnt.join("taxes/return-2026.txt")).unwrap(), b"SECRET-INCOME-4242"); + std::fs::rename(mnt.join("archive.bin"), mnt.join("taxes/archive.bin")).unwrap(); + let mut names: Vec = std::fs::read_dir(mnt.join("taxes")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + names.sort(); + assert_eq!(names, vec!["archive.bin", "return-2026.txt"]); + session.umount_and_join().unwrap(); + + // Fresh mount, state comes only from the (ciphertext) store. + let vol = rt.block_on(Volume::open(store.clone(), secret.clone(), "test")).unwrap(); + let session = mount_volume(rt.handle().clone(), vol, &mnt).unwrap(); + assert_eq!(std::fs::read(mnt.join("taxes/return-2026.txt")).unwrap(), b"SECRET-INCOME-4242"); + assert_eq!(std::fs::read(mnt.join("taxes/archive.bin")).unwrap(), big); + std::fs::remove_file(mnt.join("taxes/archive.bin")).unwrap(); + assert!(!mnt.join("taxes/archive.bin").exists()); + session.umount_and_join().unwrap(); + + for e in std::fs::read_dir(&blobs).unwrap() { + let b = std::fs::read(e.unwrap().path()).unwrap(); + for needle in [&b"SECRET-INCOME"[..], b"return-2026", b"taxes"] { + assert!(!b.windows(needle.len()).any(|w| w == needle), "plaintext in the store"); + } + } + } +} diff --git a/node/crates/c0mpute-cli/src/private_storage.rs b/node/crates/c0mpute-cli/src/private_storage.rs new file mode 100644 index 00000000..b2dc23c1 --- /dev/null +++ b/node/crates/c0mpute-cli/src/private_storage.rs @@ -0,0 +1,480 @@ +//! The private (end-to-end encrypted) side of `c0mpute storage` and +//! `c0mpute hosting publish --private` (DIP-0012 `private` tier). +//! +//! Everything here encrypts or decrypts on this machine. What goes to the +//! network, a gateway or a log is ciphertext, object ids and copy counts: +//! never a key, never plaintext, never a file name. + +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use c0mpute_core::sealed_store::{self, BlobStore, GatewayStore, IndexEntry, LocalIndex, NetworkStore, ObjectRef, PutOptions}; +use c0mpute_sealed::{Kind, StoragePublic, StorageSecret}; +use c0mpute_secure_chat::{self as chat, ChatKey}; + +/// Password for the c0mpute identity keyfile: `C0MPUTE_DM_PASSWORD` for +/// scripts, otherwise a prompt. +fn password(prompt: &str) -> Result { + if let Ok(p) = std::env::var("C0MPUTE_DM_PASSWORD") + && !p.is_empty() + { + return Ok(p); + } + Ok(rpassword::prompt_password(prompt)?) +} + +const LOST_KEY_WARNING: &str = "\ + Your private objects are encrypted on this machine with keys only you hold. + Nobody (not c0mpute, not node operators, not support) can recover them. + LOSE THIS IDENTITY AND ITS PASSWORD AND YOUR PRIVATE DATA IS GONE FOR GOOD."; + +/// Load this machine's c0mpute identity (the same one `c0mpute dm` uses) and +/// derive the storage secret from it. On first use, with `create`, make one, +/// and refuse to continue until its encrypted backup has been saved. +pub fn load_identity(create: bool, backup_to: Option<&Path>) -> Result { + let path = chat::key_file_path()?; + if path.exists() { + let kf = chat::load_keyfile(&path)?; + let pw = zeroize::Zeroizing::new(password("c0mpute identity password: ")?); + let key = chat::decrypt_keyfile(&kf, &pw)?; + let secret = sealed_store::secret_for(&key); + sealed_store::Freshness::install_for(&secret); + return Ok(secret); + } + if !create { + bail!("no c0mpute identity on this machine yet: run `c0mpute storage key init --backup-to `"); + } + init_identity(backup_to) +} + +fn init_identity(backup_to: Option<&Path>) -> Result { + let path = chat::key_file_path()?; + if path.exists() { + bail!("an identity already exists at {}", path.display()); + } + eprintln!("Creating your c0mpute identity (first use of private storage).\n"); + eprintln!("{LOST_KEY_WARNING}\n"); + let interactive = std::io::stdin().is_terminal(); + if backup_to.is_none() && !interactive { + bail!( + "first use needs somewhere to put the encrypted key backup: pass\n\ + --backup-to (ideally on another disk or a USB stick)" + ); + } + let pw = zeroize::Zeroizing::new(match std::env::var("C0MPUTE_DM_PASSWORD") { + Ok(p) if p.len() >= 10 => p, + Ok(p) if !p.is_empty() => bail!("C0MPUTE_DM_PASSWORD must be at least 10 characters"), + _ => crate::prompt_new_password()?, + }); + let key = ChatKey::generate(); + let kf = key.encrypt_to_keyfile(&pw, None)?; + let backup = serde_json::to_string_pretty(&kf)?; + + // The backup is mandatory: written and read back, or confirmed by hand, + // before the identity is saved. No identity exists without one. + match backup_to { + Some(dest) => { + write_private_file(dest, backup.as_bytes())?; + let back: chat::KeyFile = serde_json::from_slice(&std::fs::read(dest)?)?; + chat::decrypt_keyfile(&back, &pw).context("backup did not read back correctly")?; + eprintln!("encrypted backup written to {} (and verified)", dest.display()); + } + None => { + eprintln!("── encrypted backup: save this text somewhere safe, off this machine ──"); + eprintln!("{backup}"); + eprintln!("────────────────────────────────────────────────────────────────────────"); + eprintln!("Restore it on any machine with: c0mpute dm restore --from-backup "); + loop { + eprint!("Type SAVED once the backup is stored somewhere safe: "); + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + if line.trim() == "SAVED" { + break; + } + } + } + } + chat::save_keyfile(&path, &kf)?; + let secret = sealed_store::secret_for(&key); + sealed_store::Freshness::install_for(&secret); + eprintln!("identity saved to {}", path.display()); + eprintln!("storage identity: {}", secret.public().to_share_string()); + eprintln!(" (give this to people who want to share private objects with you)\n"); + Ok(secret) +} + +fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(dir) = path.parent() + && !dir.as_os_str().is_empty() + { + std::fs::create_dir_all(dir)?; + } + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("create {} (it must not exist yet)", path.display()))?; + f.write_all(bytes)?; + } + #[cfg(not(unix))] + { + if path.exists() { + bail!("{} already exists", path.display()); + } + std::fs::write(path, bytes)?; + } + Ok(()) +} + +fn parse_recipients(raw: &[String]) -> Result> { + raw.iter() + .map(|r| StoragePublic::from_share_string(r).with_context(|| format!("`{r}` is not a storage identity (c0ps1...)"))) + .collect() +} + +fn human(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut v = bytes as f64; + let mut i = 0; + while v >= 1024.0 && i < UNITS.len() - 1 { + v /= 1024.0; + i += 1; + } + if i == 0 { format!("{bytes} B") } else { format!("{v:.1} {}", UNITS[i]) } +} + +fn remember(secret: &StorageSecret, site_id: &str, entry: IndexEntry) { + let path = LocalIndex::default_path(); + let mut idx = LocalIndex::load(&path, secret).unwrap_or_default(); + idx.objects.insert(site_id.to_string(), entry); + if let Err(e) = idx.save(&path, secret) { + eprintln!("warning: could not update the local object list: {e:#}"); + } +} + +fn forget(secret: &StorageSecret, site_id: &str) { + let path = LocalIndex::default_path(); + if let Ok(mut idx) = LocalIndex::load(&path, secret) { + idx.objects.remove(site_id); + let _ = idx.save(&path, secret); + } +} + +async fn wait_copies(store: &NetworkStore, wait: Duration) -> usize { + let counts = store + .wait_for_copies(wait, |id, n| eprintln!(" {id}: {n}/{} network copies", c0mpute_core::hosting::TARGET_REPLICAS)) + .await; + counts.values().copied().min().unwrap_or(0) +} + +fn copies_note(copies: usize) { + if copies < c0mpute_core::hosting::TARGET_REPLICAS { + eprintln!( + "note: fewer than {} hosting nodes took a copy before the wait ran out; the\n\ + network keeps replicating, but re-run the command if no copies appear", + c0mpute_core::hosting::TARGET_REPLICAS + ); + } +} + +/// `c0mpute storage put FILE` (the default, private). +pub async fn put(file: &Path, share_with: &[String], gateway: &str, wait: Duration, backup_to: Option<&Path>) -> Result<()> { + let rcpts = parse_recipients(share_with)?; + let secret = load_identity(true, backup_to)?; + let bytes = tokio::fs::read(file).await.with_context(|| format!("read {}", file.display()))?; + let file_name = file.file_name().map(|n| n.to_string_lossy().to_string()); + let content_type = file_name.as_deref().map(|n| c0mpute_core::hosting::site::content_type_for(n).to_string()); + eprintln!(" encrypting {} on this machine...", human(bytes.len() as u64)); + let store = NetworkStore::start(gateway).await?; + let res = sealed_store::put_bytes( + &store, + &secret, + &bytes, + PutOptions { + file_name: file_name.clone(), + content_type, + share_with: rcpts.clone(), + }, + ) + .await?; + remember( + &secret, + &res.site_id, + IndexEntry { + file_name, + size: bytes.len() as u64, + kind: if res.published.len() > 1 { Kind::Multipart } else { Kind::File }, + created: c0mpute_core::payments::now_secs(), + shared_with: rcpts.len(), + }, + ); + eprintln!( + " published {} object(s), {} of ciphertext; waiting for network copies...", + res.published.len(), + human(res.stored_bytes) + ); + let copies = wait_copies(&store, wait).await; + println!("{}", sealed_store::plain_ref(&res.site_id)); + eprintln!(" private: only you{} can read it", if rcpts.is_empty() { String::new() } else { format!(" and {} other identit(ies)", rcpts.len()) }); + eprintln!(" get it back: c0mpute storage get {} -o ", sealed_store::plain_ref(&res.site_id)); + eprintln!(" share by link: c0mpute storage link {}", sealed_store::plain_ref(&res.site_id)); + copies_note(copies); + Ok(()) +} + +/// Seal a file for `storage put --local`: the ciphertext goes into this +/// node's own store. Returns the blob. +pub fn seal_local(bytes: &[u8], file: &Path, backup_to: Option<&Path>) -> Result> { + if bytes.len() > sealed_store::PART_BYTES { + bail!("--local private objects are limited to {} MiB; drop --local to store bigger files on the network", sealed_store::PART_BYTES >> 20); + } + let secret = load_identity(true, backup_to)?; + let name = c0mpute_sealed::random_name(); + let file_name = file.file_name().map(|n| n.to_string_lossy().to_string()); + let sealed = c0mpute_sealed::seal( + bytes, + &c0mpute_sealed::SealParams { + kind: Kind::File, + name: &name, + owner: &secret, + content_type: file_name.as_deref().map(|n| c0mpute_core::hosting::site::content_type_for(n).to_string()), + file_name, + files: Default::default(), + parts: Vec::new(), + recipients: &[secret.public()], + created: c0mpute_core::payments::now_secs(), + version: 0, + }, + )?; + Ok(sealed.blob) +} + +/// True when `bytes` is a sealed object (so `get` decrypts it). +pub fn is_sealed(bytes: &[u8]) -> bool { + bytes.len() >= 8 && bytes[..8] == c0mpute_sealed::format::MAGIC +} + +/// Decrypt a sealed object read from this node's own store. +pub fn open_local(bytes: &[u8]) -> Result>> { + let secret = load_identity(false, None)?; + let (opened, _) = c0mpute_sealed::open_with_secret(bytes, &secret, None)?; + Ok(opened.content) +} + +/// `c0mpute storage get c0p1:...`. +pub async fn get(reference: &str, out: Option<&Path>, raw: bool, gateway: &str) -> Result<()> { + let r = ObjectRef::parse(reference)?; + let store = GatewayStore::new(gateway); + if raw { + // The ciphertext exactly as operators hold it: for audits. + let blob = store.get(&r.site_id).await?; + return write_out(out, &blob); + } + let secret = if r.key.is_none() { Some(load_identity(false, None)?) } else { None }; + let fetched = sealed_store::get(&store, &r, secret.as_ref()).await?; + let meta = &fetched.opened.meta; + if meta.kind == Kind::Site { + let Some(dir) = out else { + bail!("this is a private site ({} files): pass -o to unpack it", meta.files.len()); + }; + let n = sealed_store::unpack_site(&fetched.opened, dir)?; + eprintln!("unpacked {n} file(s) into {}", dir.display()); + return Ok(()); + } + if let Some(name) = &meta.file_name { + eprintln!(" {name}, {}", human(meta.size)); + } + write_out(out, &fetched.opened.content) +} + +fn write_out(out: Option<&Path>, bytes: &[u8]) -> Result<()> { + match out { + Some(p) => { + std::fs::write(p, bytes).with_context(|| format!("write {}", p.display()))?; + eprintln!("wrote {} to {}", human(bytes.len() as u64), p.display()); + } + None => { + use std::io::Write; + std::io::stdout().write_all(bytes)?; + } + } + Ok(()) +} + +/// `c0mpute storage share REF --with c0ps1...`. +pub async fn share(reference: &str, with: &[String], gateway: &str, wait: Duration) -> Result<()> { + let add = parse_recipients(with)?; + if add.is_empty() { + bail!("say who to share with: --with c0ps1... (they get it from `c0mpute storage key show`)"); + } + let r = ObjectRef::parse(reference)?; + let secret = load_identity(false, None)?; + let store = NetworkStore::start(gateway).await?; + let roster = sealed_store::share(&store, &secret, &r.site_id, &add).await?; + let copies = wait_copies(&store, wait).await; + println!("{} is now readable by {} identit(ies) (you included)", sealed_store::plain_ref(&r.site_id), roster.len()); + eprintln!(" they read it with: c0mpute storage get {} -o ", sealed_store::plain_ref(&r.site_id)); + copies_note(copies); + Ok(()) +} + +/// `c0mpute storage revoke REF --from c0ps1...`. +pub async fn revoke(reference: &str, from: &[String], gateway: &str, wait: Duration) -> Result<()> { + let remove = parse_recipients(from)?; + let r = ObjectRef::parse(reference)?; + let secret = load_identity(false, None)?; + let store = NetworkStore::start(gateway).await?; + let (roster, res) = sealed_store::revoke(&store, &secret, &r.site_id, &remove).await?; + let copies = wait_copies(&store, wait).await; + println!( + "{} re-encrypted under a new key; {} identit(ies) can read the new version", + sealed_store::plain_ref(&res.site_id), + roster.len() + ); + eprintln!( + " every earlier link and key is dead. Anyone you removed may still have a copy of\n \ + what they could read before; revocation only protects the data from now on." + ); + copies_note(copies); + Ok(()) +} + +/// `c0mpute storage link REF`: print a capability link. +pub async fn link(reference: &str, gateway: &str) -> Result<()> { + let r = ObjectRef::parse(reference)?; + let secret = if r.key.is_none() { Some(load_identity(false, None)?) } else { None }; + let fetched = sealed_store::get(&GatewayStore::new(gateway), &r, secret.as_ref()).await?; + println!("{}", sealed_store::browser_link(gateway, &r.site_id, &fetched.dek).as_str()); + println!("{}", sealed_store::capability_ref(&r.site_id, &fetched.dek).as_str()); + eprintln!( + " anyone with either link can read this object. The key is after #, which browsers\n \ + never send to the gateway. To kill every link: c0mpute storage revoke {}", + sealed_store::plain_ref(&r.site_id) + ); + Ok(()) +} + +/// `c0mpute storage rm c0p1:...`: replace with a tombstone. +pub async fn rm(reference: &str, yes: bool, gateway: &str) -> Result<()> { + let r = ObjectRef::parse(reference)?; + if !yes { + eprintln!("about to delete {} from the network; re-run with --yes to confirm", sealed_store::plain_ref(&r.site_id)); + return Ok(()); + } + let secret = load_identity(false, None)?; + let store = NetworkStore::start(gateway).await?; + let gone = sealed_store::delete(&store, &secret, &r.site_id).await?; + wait_copies(&store, Duration::from_secs(60)).await; + forget(&secret, &r.site_id); + println!("deleted {} ({} object(s) tombstoned; holders drop the ciphertext)", sealed_store::plain_ref(&r.site_id), gone.len()); + Ok(()) +} + +/// `c0mpute storage ls --private`. +pub fn ls() -> Result<()> { + let secret = load_identity(false, None)?; + let idx = LocalIndex::load(&LocalIndex::default_path(), &secret)?; + if idx.objects.is_empty() { + println!("no private objects recorded on this machine"); + return Ok(()); + } + println!("{:<28} {:>10} {:<9} NAME", "OBJECT", "SIZE", "KIND"); + for (id, e) in &idx.objects { + println!( + "{:<28} {:>10} {:<9} {}", + sealed_store::plain_ref(id), + human(e.size), + format!("{:?}", e.kind).to_lowercase(), + e.file_name.as_deref().unwrap_or("-") + ); + } + Ok(()) +} + +/// `c0mpute storage key ...`. +pub fn key(cmd: KeyCmd) -> Result<()> { + match cmd { + KeyCmd::Init { backup_to } => { + init_identity(backup_to.as_deref())?; + Ok(()) + } + KeyCmd::Show => { + let secret = load_identity(false, None)?; + let p = secret.public(); + println!("storage identity: {}", p.to_share_string()); + println!("fingerprint: {}", p.fingerprint()); + Ok(()) + } + KeyCmd::Backup { out } => { + let kf = chat::load_keyfile(&chat::key_file_path()?)?; + let json = serde_json::to_string_pretty(&kf)?; + match out { + Some(p) => { + write_private_file(&p, json.as_bytes())?; + eprintln!("encrypted backup written to {}", p.display()); + } + None => println!("{json}"), + } + eprintln!("{LOST_KEY_WARNING}"); + Ok(()) + } + } +} + +#[derive(clap::Subcommand, Debug)] +pub enum KeyCmd { + /// Create your c0mpute identity and its mandatory encrypted backup. + Init { + /// Write the encrypted backup here (it must not exist yet). Without + /// it the backup is printed and you confirm you saved it. + #[arg(long)] + backup_to: Option, + }, + /// Print your storage identity (`c0ps1...`), which others use to share + /// private objects with you. Public; safe to give out. + Show, + /// Print (or write) the password-encrypted identity backup again. + Backup { + #[arg(long)] + out: Option, + }, +} + +/// `c0mpute hosting publish DIR --name N --private`. +pub async fn publish_site(dir: &Path, name: &str, rekey: bool, gateway: &str, wait: Duration) -> Result<()> { + if !c0mpute_core::hosting::site::valid_name(name) { + bail!("site name must be 1-63 of a-z, 0-9 and '-'"); + } + let secret = load_identity(true, None)?; + let store = NetworkStore::start(gateway).await?; + let res = sealed_store::put_site(&store, &secret, dir, name, rekey).await?; + remember( + &secret, + &res.site_id, + IndexEntry { + file_name: Some(format!("{name} (private site)")), + size: res.stored_bytes, + kind: Kind::Site, + created: c0mpute_core::payments::now_secs(), + shared_with: 0, + }, + ); + eprintln!(" published private site {name} ({} of ciphertext); waiting for network copies...", human(res.stored_bytes)); + let copies = wait_copies(&store, wait).await; + println!("site: {}", sealed_store::plain_ref(&res.site_id)); + println!("open: {}", sealed_store::browser_link(gateway, &res.site_id, &res.dek).as_str()); + println!("copies: {copies}/{}", c0mpute_core::hosting::TARGET_REPLICAS); + eprintln!( + " only people with the link can see the site; gateways serve ciphertext and the\n \ + browser decrypts it. Republishing keeps the link; --rekey kills every old link." + ); + copies_note(copies); + Ok(()) +} diff --git a/node/crates/c0mpute-cli/src/storage.rs b/node/crates/c0mpute-cli/src/storage.rs index 5ab0449e..a95ce447 100644 --- a/node/crates/c0mpute-cli/src/storage.rs +++ b/node/crates/c0mpute-cli/src/storage.rs @@ -4,10 +4,13 @@ //! specified in `docs/prds/009-mount-cli.md`. This is the CIP-002 slice of //! that surface: objects, shards and the local node's storage service. //! -//! Volume, mount and provider commands arrive with their CIPs — see -//! [`unimplemented_note`] for what maps to what. They are deliberately absent -//! rather than stubbed, so `--help` never advertises something that does not -//! work. +//! Objects are private by default: `put` encrypts on this machine before +//! anything leaves it and publishes only ciphertext (DIP-0012 `private` +//! tier, see `private_storage.rs`). `--public` stores plaintext, as before. +//! +//! Provider commands arrive with their CIPs; see [`unimplemented_note`]. +//! They are deliberately absent rather than stubbed, so `--help` never +//! advertises something that does not work. use std::path::PathBuf; use std::sync::Arc; @@ -23,19 +26,41 @@ use clap::Subcommand; use tokio::sync::RwLock; use crate::peers; +use crate::private_storage; +use std::time::Duration; #[derive(Subcommand, Debug)] pub enum StorageCmd { - /// Store a file and print its object hash. + /// Store a file. Private by default: encrypted on this machine, then + /// published to the network as ciphertext (three self-healing copies). + /// Prints a `c0p1:` reference that only you can open. /// - /// Placed across the network when storage peers are configured - /// (`c0mpute storage peer add`), otherwise stored on this node alone. + /// --public stores plaintext instead (content-addressed, readable by + /// anyone with the hash): placed across storage peers when configured + /// (`c0mpute storage peer add`), otherwise on this node alone. Put { file: PathBuf, + /// Store plaintext: anyone with the hash can read it. Only for data + /// that is meant to be public. + #[arg(long)] + public: bool, + /// Also let these storage identities (`c0ps1...`) open it. Repeatable. + #[arg(long = "share-with")] + share_with: Vec, + /// First use only: where to write your encrypted key backup. + #[arg(long)] + backup_to: Option, + /// Gateway used to read objects back. + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, + /// Give up waiting for network copies after this many seconds. + #[arg(long, default_value_t = 600)] + wait_secs: u64, /// Redundancy tier: hot, standard (default) or critical. #[arg(long, default_value = "standard")] tier: String, - /// Keep every shard on this node even when peers are configured. + /// Keep it on this node: a private object's ciphertext, or with + /// --public every shard, even when peers are configured. #[arg(long)] local: bool, /// Place without requiring failure-domain diversity. @@ -52,21 +77,80 @@ pub enum StorageCmd { #[command(subcommand)] cmd: PeerCmd, }, - /// Fetch an object by hash. + /// Fetch an object: `c0p1:...` (private, decrypted here), a private + /// link (`...#k=...`), or `blake3:...` (on this node or its peers). Get { + #[arg(value_name = "REF")] hash: String, - /// Write to this path instead of stdout. + /// Write to this path (a directory for a private site) instead of stdout. #[arg(short = 'o', long)] out: Option, - /// Read only this byte range, e.g. `1000-2047`. + /// Read only this byte range, e.g. `1000-2047` (blake3 objects). #[arg(long)] range: Option, + /// Write the ciphertext exactly as operators hold it; do not decrypt. + #[arg(long)] + raw: bool, + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, + }, + /// Let more storage identities open a private object (you must own it). + Share { + #[arg(value_name = "REF")] + reference: String, + /// Their storage identity (`c0ps1...`, from `c0mpute storage key show`). Repeatable. + #[arg(long = "with", required = true)] + with: Vec, + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, + #[arg(long, default_value_t = 180)] + wait_secs: u64, + }, + /// Stop identities reading a private object: re-encrypts it under a new + /// key, which also kills every link given out so far. + Revoke { + #[arg(value_name = "REF")] + reference: String, + /// Storage identities to remove (`c0ps1...`). Omit to just rotate the key. + #[arg(long = "from")] + from: Vec, + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, + #[arg(long, default_value_t = 600)] + wait_secs: u64, + }, + /// Print a link that opens a private object for anyone who has it (in a + /// browser, or with `storage get`). The key rides in the # fragment. + Link { + #[arg(value_name = "REF")] + reference: String, + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, + }, + /// Your c0mpute identity: create it, show your storage identity, back it up. + Key { + #[command(subcommand)] + cmd: crate::private_storage::KeyCmd, + }, + /// Mount an encrypted volume: files written here are stored as private + /// objects (Linux, FUSE). + #[cfg(all(target_os = "linux", feature = "fuse"))] + Mount { + dir: PathBuf, + /// Volume name (each name is a separate volume). + #[arg(long, default_value = "default")] + volume: String, + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, }, /// List objects held on this node. Ls { /// Print one hash per line with no header. #[arg(long)] quiet: bool, + /// List your private objects (from this machine's encrypted index). + #[arg(long)] + private: bool, }, /// Show an object's block and shard layout. Info { hash: String }, @@ -85,11 +169,15 @@ pub enum StorageCmd { #[arg(long)] now: bool, }, - /// Delete an object and its shards. + /// Delete an object: `blake3:...` from this node, or `c0p1:...` from + /// the network (its holders drop the ciphertext). Rm { + #[arg(value_name = "REF")] hash: String, #[arg(long)] yes: bool, + #[arg(long, default_value = c0mpute_core::sealed_store::DEFAULT_GATEWAY)] + gateway: String, }, /// Disk usage, budget, and per-tier redundancy. Status, @@ -167,19 +255,66 @@ pub async fn run(cmd: StorageCmd, config_path: &std::path::Path) -> Result<()> { match cmd { StorageCmd::Put { file, + public, + share_with, + backup_to, + gateway, + wait_secs, tier, local, insecure_ignore_diversity, - } => put(config_path, file, &tier, local, insecure_ignore_diversity).await, + } => { + if public { + if !share_with.is_empty() { + bail!("--share-with is for private objects; a --public object is readable by anyone"); + } + put(config_path, file, &tier, local, insecure_ignore_diversity, None).await + } else if local { + put(config_path, file, &tier, true, insecure_ignore_diversity, Some(backup_to)).await + } else { + private_storage::put(&file, &share_with, &gateway, Duration::from_secs(wait_secs), backup_to.as_deref()).await + } + } StorageCmd::Peer { cmd } => peer(config_path, cmd).await, - StorageCmd::Get { hash, out, range } => get(config_path, &hash, out, range).await, - StorageCmd::Ls { quiet } => ls(config_path, quiet).await, + StorageCmd::Get { hash, out, range, raw, gateway } => { + if is_private_ref(&hash) { + if range.is_some() { + bail!("--range reads plaintext objects; private objects are fetched whole and decrypted"); + } + private_storage::get(&hash, out.as_deref(), raw, &gateway).await + } else { + get(config_path, &hash, out, range, raw).await + } + } + StorageCmd::Share { reference, with, gateway, wait_secs } => { + private_storage::share(&reference, &with, &gateway, Duration::from_secs(wait_secs)).await + } + StorageCmd::Revoke { reference, from, gateway, wait_secs } => { + private_storage::revoke(&reference, &from, &gateway, Duration::from_secs(wait_secs)).await + } + StorageCmd::Link { reference, gateway } => private_storage::link(&reference, &gateway).await, + StorageCmd::Key { cmd } => private_storage::key(cmd), + #[cfg(all(target_os = "linux", feature = "fuse"))] + StorageCmd::Mount { dir, volume, gateway } => crate::mount::run(&dir, &volume, &gateway).await, + StorageCmd::Ls { quiet, private } => { + if private { + private_storage::ls() + } else { + ls(config_path, quiet).await + } + } StorageCmd::Info { hash } => info(config_path, &hash).await, StorageCmd::Verify { hash } => verify(config_path, &hash).await, StorageCmd::Repair { hash, dry_run, now } => { repair(config_path, hash.as_deref(), dry_run, now).await } - StorageCmd::Rm { hash, yes } => rm(config_path, &hash, yes).await, + StorageCmd::Rm { hash, yes, gateway } => { + if is_private_ref(&hash) { + private_storage::rm(&hash, yes, &gateway).await + } else { + rm(config_path, &hash, yes).await + } + } StorageCmd::Status => status(config_path).await, StorageCmd::Tiers => { tiers(); @@ -223,17 +358,33 @@ async fn open_distributed( Ok(Some((storage, peer_count, domains))) } +/// `c0p1:...`, a private link, or a bare 20-hex site id (a blake3 hash is +/// 64 hex, so the two never collide). +fn is_private_ref(raw: &str) -> bool { + let raw = raw.trim(); + raw.starts_with(c0mpute_core::sealed_store::REF_PREFIX) + || raw.contains("/p/") + || (raw.len() == 20 && raw.bytes().all(|b| b.is_ascii_hexdigit())) +} + +/// Store a file in this node's own engine. `seal` is `Some(backup_to)` for a +/// private `--local` object: the file is encrypted first and only the +/// ciphertext is stored. async fn put( config_path: &std::path::Path, file: PathBuf, tier: &str, force_local: bool, ignore_diversity: bool, + seal: Option>, ) -> Result<()> { let tier: Tier = tier.parse()?; - let bytes = tokio::fs::read(&file) + let mut bytes = tokio::fs::read(&file) .await .with_context(|| format!("read {}", file.display()))?; + if let Some(backup_to) = &seal { + bytes = private_storage::seal_local(&bytes, &file, backup_to.as_deref())?; + } let len = bytes.len() as u64; if ignore_diversity { @@ -258,6 +409,9 @@ async fn put( }; println!("blake3:{}", manifest.object_hash.to_hex()); + if seal.is_some() { + eprintln!(" private: this node holds only ciphertext; `storage get` decrypts it with your identity"); + } eprintln!( " {} in {} block(s), {} shards, tier {} ({:.1}x expansion, {} raw)", human(len), @@ -387,6 +541,7 @@ async fn get( hash: &str, out: Option, range: Option, + raw: bool, ) -> Result<()> { let hash = parse_hash(hash)?; let storage = open(config_path).await?; @@ -394,6 +549,7 @@ async fn get( bail!("no object {hash} on this node"); } + let ranged = range.is_some(); let bytes = match range { Some(spec) => { let (start, end) = spec @@ -422,6 +578,14 @@ async fn get( } }; + // A private object stored with `put --local` decrypts here, on this + // machine, unless the raw ciphertext was asked for. + let bytes = if !raw && !ranged && private_storage::is_sealed(&bytes) { + private_storage::open_local(&bytes)?.to_vec() + } else { + bytes + }; + match out { Some(path) => { tokio::fs::write(&path, &bytes).await?; @@ -715,11 +879,12 @@ async fn serve(config_path: &std::path::Path, bind: &str, allow_anon: bool) -> R /// Which CIP delivers each command that is not here yet. Referenced from the /// long help so the gap is discoverable without reading `docs/prds/`. pub fn unimplemented_note() -> &'static str { - "Not yet available:\n \ + "Private by default: `put` encrypts on this machine and publishes only\n\ + ciphertext; `--public` stores plaintext. See docs/private-storage.md.\n\ + \nNot yet available:\n \ volume create|list|destroy CIP-004\n \ - mount | umount CIP-009 (needs CIP-007 and CIP-008)\n \ provide | earnings | retire CIP-006\n \ - repair | recover CIP-005\n\ + recover CIP-005\n\ \nSee docs/prds/README.md for the delivery order." } @@ -747,7 +912,7 @@ mod tests { #[test] fn unimplemented_note_points_at_real_cips() { let note = unimplemented_note(); - for cip in ["CIP-004", "CIP-005", "CIP-006", "CIP-007", "CIP-009"] { + for cip in ["CIP-004", "CIP-005", "CIP-006"] { assert!(note.contains(cip), "note should mention {cip}"); } } diff --git a/node/crates/c0mpute-core/Cargo.toml b/node/crates/c0mpute-core/Cargo.toml index b6ccc371..d9f44541 100644 --- a/node/crates/c0mpute-core/Cargo.toml +++ b/node/crates/c0mpute-core/Cargo.toml @@ -17,6 +17,8 @@ c0mpute-doctor = { workspace = true } c0mpute-bench = { workspace = true } c0mpute-api = { workspace = true } c0mpute-secure-chat = { workspace = true } +c0mpute-sealed = { workspace = true } +zeroize = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } rand = { workspace = true } diff --git a/node/crates/c0mpute-core/src/hosting/mod.rs b/node/crates/c0mpute-core/src/hosting/mod.rs index b65a9cc3..41043048 100644 --- a/node/crates/c0mpute-core/src/hosting/mod.rs +++ b/node/crates/c0mpute-core/src/hosting/mod.rs @@ -1348,11 +1348,60 @@ impl Hosting { .route("/s/{site}", get(redirect_site_root)) .route("/s/{site}/", get(serve_root)) .route("/s/{site}/{*path}", get(serve_path)) + .route("/p/{site}", get(redirect_private_root)) + .route("/p/{site}/", get(private_loader)) .fallback(serve_by_host) .with_state(self.clone()) } } +/// The browser decrypt loader for private objects (`c0mpute storage link`, +/// `c0mpute hosting publish --private`). A fixed page: it fetches the +/// object's ciphertext from `/s//o` and decrypts it in the browser with +/// the key from the URL fragment, which browsers never send to the server. +/// This gateway only ever handles ciphertext. +pub const PRIVATE_LOADER_HTML: &str = include_str!("private_loader.html"); + +/// The loader's CSP. Decrypted pages render in a sandboxed (opaque-origin) +/// blob: frame that inherits this policy, so they can run their own scripts +/// but cannot reach this gateway's origin or any other host: no fetches out, +/// no external images, no form posts. +pub const PRIVATE_LOADER_CSP: &str = "default-src 'none'; script-src 'unsafe-inline' blob: data:; \ +style-src 'unsafe-inline' blob: data:; img-src blob: data:; media-src blob: data:; font-src blob: data:; \ +connect-src 'self'; frame-src blob:; child-src blob:; object-src 'none'; base-uri 'none'; \ +form-action 'none'; frame-ancestors 'none'"; + +fn valid_site_id(s: &str) -> bool { + s.len() == 20 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +async fn private_loader(AxPath(site): AxPath) -> Response { + if !valid_site_id(&site) { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + let mut resp = (StatusCode::OK, PRIVATE_LOADER_HTML).into_response(); + let h = resp.headers_mut(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); + h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(PRIVATE_LOADER_CSP)); + h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")); + h.insert("x-content-type-options", HeaderValue::from_static("nosniff")); + h.insert("cross-origin-opener-policy", HeaderValue::from_static("same-origin")); + h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300")); + h.insert("x-robots-tag", HeaderValue::from_static("noindex, nofollow")); + resp +} + +async fn redirect_private_root(AxPath(site): AxPath) -> Response { + if !valid_site_id(&site) { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + let mut resp = StatusCode::PERMANENT_REDIRECT.into_response(); + if let Ok(v) = HeaderValue::from_str(&format!("/p/{site}/")) { + resp.headers_mut().insert(header::LOCATION, v); + } + resp +} + /// `/s/` -> `/s//` (query kept). Serving the page at the slashless /// path made every relative asset (`img/a.png`, `og.png`) resolve to `/s/img/...`. async fn redirect_site_root(AxPath(site): AxPath, uri: axum::http::Uri) -> Response { diff --git a/node/crates/c0mpute-core/src/hosting/private_loader.html b/node/crates/c0mpute-core/src/hosting/private_loader.html new file mode 100644 index 00000000..beef2b4e --- /dev/null +++ b/node/crates/c0mpute-core/src/hosting/private_loader.html @@ -0,0 +1,270 @@ + + + + + + + +Private object · c0mpute + + + +
+ + Private object + Decrypted in this browser. The key after # in the link is never sent to the gateway. +
+
Fetching ciphertext...
+ + + diff --git a/node/crates/c0mpute-core/src/hosting/site.rs b/node/crates/c0mpute-core/src/hosting/site.rs index 449a6bc0..81b85b0e 100644 --- a/node/crates/c0mpute-core/src/hosting/site.rs +++ b/node/crates/c0mpute-core/src/hosting/site.rs @@ -693,6 +693,15 @@ pub fn build_from_dir(dir: &Path, name: &str) -> Result { }) } +/// The files `build_from_dir` would publish from `dir`, as sorted +/// (manifest path, absolute path) pairs. Private sites use the same walk. +pub fn list_dir(dir: &Path) -> Result> { + let mut paths = Vec::new(); + walk(dir, dir, &mut paths)?; + paths.sort(); + Ok(paths) +} + fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<()> { for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { let entry = entry?; diff --git a/node/crates/c0mpute-core/src/lib.rs b/node/crates/c0mpute-core/src/lib.rs index fbf7fd71..f829e09d 100644 --- a/node/crates/c0mpute-core/src/lib.rs +++ b/node/crates/c0mpute-core/src/lib.rs @@ -48,6 +48,7 @@ pub mod register; pub mod resources; pub mod routing; pub mod runner; +pub mod sealed_store; pub mod speech; pub mod speech_gateway; pub mod speech_profiles; diff --git a/node/crates/c0mpute-core/src/opendisk.rs b/node/crates/c0mpute-core/src/opendisk.rs index a4c120e2..0a7c2dd6 100644 --- a/node/crates/c0mpute-core/src/opendisk.rs +++ b/node/crates/c0mpute-core/src/opendisk.rs @@ -153,7 +153,12 @@ pub fn descriptor( }, "accepts": { "visibility": ["public", "private"], + // Private objects arrive already encrypted by their owner + // (DIP-0012 `private` tier): this node stores and serves + // ciphertext it has no key for. Public sites stay plaintext. + "encrypted": true, "encrypted_only": false, + "client_side_encryption": "c0mpute-private-v1", // A c0mpute hosting node holds static sites for the network and // can be picked as a paid (preferred) host by // `c0mpute hosting publish --disks auto`. @@ -167,6 +172,12 @@ pub fn descriptor( "version": env!("CARGO_PKG_VERSION"), "storage_api": origin.map(|o| format!("{}/storage/v1", o.trim_end_matches('/'))), "redundancy": "reed-solomon 10/14", + "private_storage": { + "format": "c0mpute-private-v1", + "cipher": "AES-256-GCM, X25519 key wrap, client-held keys", + "operator_sees": "ciphertext, padded size, access timing", + "docs": "https://c0mpute.com/getting-started#private-storage", + }, "roles": roles, }, }); @@ -451,6 +462,8 @@ mod tests { assert!(d["updated"].as_str().unwrap().ends_with('Z')); // Default roles include hosting: the disk says so, for d1sks to filter on. assert_eq!(d["accepts"]["hosting"], true); + assert_eq!(d["accepts"]["encrypted"], true); + assert_eq!(d["c0mpute"]["private_storage"]["format"], "c0mpute-private-v1"); assert!(d["c0mpute"]["roles"].as_array().unwrap().iter().any(|r| r == "hosting")); assert_eq!(d["c0mpute"]["hosting"]["price_per_gib_month"], 0.004); assert_eq!(d["c0mpute"]["hosting"]["max_gib"], 5.0); diff --git a/node/crates/c0mpute-core/src/sealed_store.rs b/node/crates/c0mpute-core/src/sealed_store.rs new file mode 100644 index 00000000..ed3f8416 --- /dev/null +++ b/node/crates/c0mpute-core/src/sealed_store.rs @@ -0,0 +1,1346 @@ +//! Private storage on the network: the DIP-0012 `private` tier. +//! +//! An object is sealed on the client ([`c0mpute_sealed`]) and the resulting +//! ciphertext blob is published as a hosting site with exactly one file, +//! [`OBJECT_PATH`]. From there the hosting layer does what it does for every +//! site, unchanged: three self-healing copies, any gateway serves it, holders +//! are challenged with random chunk fetches. All of that runs over +//! ciphertext. No node, gateway or log ever sees a key or a plaintext byte: +//! encryption and decryption happen only in this process, on the user's +//! machine. +//! +//! The site record is signed by a per-object key derived from the owner's +//! secret and a random object name, so the network sees an unrelated owner +//! key and a random name per object: it cannot group objects by owner. +//! +//! References: +//! +//! * `c0p1:`: readable by the owner and anyone it was shared with +//! (their storage key unwraps a slot). +//! * `c0p1:#k=`: a capability link carrying the object's data +//! key. Anyone holding it can read. The fragment is never sent anywhere. +//! * `https:///p//#k=`: the same, opened in a browser; +//! the gateway serves a static loader page and the ciphertext, and the +//! browser decrypts. The fragment never leaves the browser. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use c0mpute_proto::Hash; +use c0mpute_sealed::{DataKey, Kind, Opened, PartRef, SealParams, SiteFile, StoragePublic, StorageSecret}; +use tokio::sync::Mutex; +use zeroize::Zeroizing; + +use crate::hosting::site::{self, BuiltSite, FILE_CHUNK_BYTES, FileEntry, SiteManifest, SiteRecord}; +use crate::hosting::{self, Hosting}; + +/// Reference scheme for private objects. +pub const REF_PREFIX: &str = "c0p1:"; +/// The single file a sealed object's hosting site carries. +pub const OBJECT_PATH: &str = "o"; +/// Content larger than this is split into separately sealed parts, so every +/// part stays well under the hosting layer's per-site limit after padding. +pub const PART_BYTES: usize = 192 * 1024 * 1024; +/// Where `get` fetches ciphertext from when this machine holds no copy. +pub const DEFAULT_GATEWAY: &str = "https://gateway.c0mpute.com"; +/// HKDF label that turns the c0mpute identity seed into the storage root. +pub const STORAGE_LABEL: &[u8] = b"c0mpute-private-storage-root-v1"; + +/// The storage secret for a c0mpute identity (`c0mpute dm keygen`). +pub fn secret_for(key: &c0mpute_secure_chat::ChatKey) -> StorageSecret { + StorageSecret::from_seed(&key.derive_subkey(STORAGE_LABEL)) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn valid_site_id(s: &str) -> bool { + s.len() == 20 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +// ───────────────────────────────────────────────────────────── references + +/// A parsed private-object reference. +#[derive(Debug)] +pub struct ObjectRef { + pub site_id: String, + /// The data key, when the reference was a capability link. + pub key: Option, + /// A path inside a private site (`#path=` on browser links). + pub path: Option, +} + +impl ObjectRef { + /// Accepts `c0p1:`, `c0p1:#k=`, a bare 20-hex site id, or a + /// browser link `http(s)://host/p//...#k=`. + pub fn parse(raw: &str) -> Result { + let raw = raw.trim(); + let (body, frag) = match raw.split_once('#') { + Some((b, f)) => (b, Some(f)), + None => (raw, None), + }; + let site_id = if let Some(id) = body.strip_prefix(REF_PREFIX) { + id.to_string() + } else if body.starts_with("http://") || body.starts_with("https://") { + let after = body.split_once("/p/").map(|(_, a)| a).context("a private link looks like https:///p//#k=...")?; + after.split('/').next().unwrap_or("").to_string() + } else { + body.to_string() + }; + if !valid_site_id(&site_id) { + // Do not echo the input: it may carry a #k= key. + bail!("not a private object reference (expected c0p1:<20 hex>, or a /p//#k=... link)"); + } + let mut key = None; + let mut path = None; + if let Some(frag) = frag { + for kv in frag.split('&') { + match kv.split_once('=') { + Some(("k", v)) => key = Some(DataKey::from_link_secret(v).map_err(|_| anyhow::anyhow!("the #k= key in the link is malformed"))?), + Some(("path", v)) if !v.is_empty() => path = Some(v.to_string()), + _ => {} + } + } + } + Ok(Self { site_id, key, path }) + } +} + +/// `c0p1:`. +pub fn plain_ref(site_id: &str) -> String { + format!("{REF_PREFIX}{site_id}") +} + +/// `c0p1:#k=`: a capability. Treat it like a password. +pub fn capability_ref(site_id: &str, dek: &DataKey) -> Zeroizing { + Zeroizing::new(format!("{REF_PREFIX}{site_id}#k={}", dek.to_link_secret().as_str())) +} + +/// `https:///p//#k=`: opens in a browser, decrypted there. +pub fn browser_link(gateway: &str, site_id: &str, dek: &DataKey) -> Zeroizing { + Zeroizing::new(format!( + "{}/p/{site_id}/#k={}", + gateway.trim_end_matches('/'), + dek.to_link_secret().as_str() + )) +} + +// ──────────────────────────────────────────────────── hosting adaptation + +/// The hosting site that carries a sealed blob: one opaque file. +pub fn built_site(name: &str, blob: &[u8]) -> BuiltSite { + let mut chunks = Vec::new(); + let mut hashes = Vec::new(); + for piece in blob.chunks(FILE_CHUNK_BYTES) { + hashes.push(Hash::of(piece).to_hex()); + chunks.push(piece.to_vec()); + } + let mut files = BTreeMap::new(); + files.insert( + OBJECT_PATH.to_string(), + FileEntry { + size: blob.len() as u64, + content_type: "application/octet-stream".to_string(), + chunks: hashes, + }, + ); + BuiltSite { + manifest: SiteManifest { + v: 1, + name: name.to_string(), + files, + }, + chunks, + } +} + +/// Sign the hosting record for object `name` with its per-object key. +pub fn sign_record(owner: &StorageSecret, name: &str, built: &BuiltSite, seq: u64) -> Result { + let mut seed = owner.object_signing_seed(name); + // ed25519_from_bytes zeroizes the buffer it is given. + let keypair = c0mpute_net::libp2p::identity::Keypair::ed25519_from_bytes(seed.as_mut()) + .map_err(|e| anyhow::anyhow!("object key: {e}"))?; + SiteRecord::sign( + &keypair, + name, + &Hash::of(&built.manifest.to_bytes()), + seq, + built.manifest.total_bytes(), + vec![], + ) +} + +// ──────────────────────────────────────────────────────────── blob stores + +/// Where sealed blobs live. Everything passed in is already ciphertext. +#[async_trait::async_trait] +pub trait BlobStore: Send + Sync { + /// Publish `blob` as object `name` (a new version if it exists). + async fn put(&self, owner: &StorageSecret, name: &str, blob: Vec) -> Result; + /// Fetch the current blob of a site; `Ok(None)` when it does not exist + /// (as opposed to an error reaching it). + async fn try_get(&self, site_id: &str) -> Result>>; + + /// Fetch the current blob of a site. + async fn get(&self, site_id: &str) -> Result> { + self.try_get(site_id).await?.with_context(|| format!("no object {site_id} on the network")) + } +} + +/// Blobs as files in a directory. Offline use and tests. +pub struct DirStore(pub PathBuf); + +#[async_trait::async_trait] +impl BlobStore for DirStore { + async fn put(&self, owner: &StorageSecret, name: &str, blob: Vec) -> Result { + let owner_hex = hex::encode(owner.object_signing_key(name).verifying_key().as_bytes()); + let site_id = site::site_id_for(&owner_hex, name); + tokio::fs::create_dir_all(&self.0).await?; + let tmp = self.0.join(format!("{site_id}.tmp")); + tokio::fs::write(&tmp, &blob).await?; + tokio::fs::rename(&tmp, self.0.join(&site_id)).await?; + Ok(site_id) + } + + async fn try_get(&self, site_id: &str) -> Result>> { + if !valid_site_id(site_id) { + bail!("bad site id"); + } + match tokio::fs::read(self.0.join(site_id)).await { + Ok(b) => Ok(Some(b)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } +} + +/// Blobs on the c0mpute network, through the hosting layer. +pub struct NetworkStore { + hosting: Hosting, + gateway: String, + http: reqwest::Client, + published: Mutex>, + // Keeps the libp2p node alive when this store booted its own. + _supervisor: Option, + // Throwaway identity directory, removed on drop. + ephemeral_dir: Option, +} + +impl Drop for NetworkStore { + fn drop(&mut self) { + if let Some(d) = &self.ephemeral_dir { + let _ = std::fs::remove_dir_all(d); + } + } +} + +impl NetworkStore { + /// Join the network as a short-lived client with a fresh, throwaway + /// libp2p identity, so the peer that announces these objects is not this + /// machine's usual one (records are signed per object anyway). The + /// session's objects still share one source address and arrive together. + pub async fn start(gateway: &str) -> Result { + let mut rnd = [0u8; 8]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut rnd); + let dir = crate::config::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("private-client") + .join(hex::encode(rnd)); + std::fs::create_dir_all(&dir)?; + let supervisor = match crate::Supervisor::boot_with_identity(crate::Config::default(), dir.clone()).await { + Ok(s) => s, + Err(e) => { + let _ = std::fs::remove_dir_all(&dir); + return Err(e); + } + }; + let hosting = Hosting::start(supervisor.libp2p.clone(), supervisor.store.clone(), hosting::Options::default()).await?; + // Let the swarm reach peers before the first gossip. + tokio::time::sleep(Duration::from_secs(5)).await; + let mut s = Self::with_hosting(hosting, gateway); + s._supervisor = Some(supervisor); + s.ephemeral_dir = Some(dir); + Ok(s) + } + + /// Use an existing hosting service (tests, a running node). + pub fn with_hosting(hosting: Hosting, gateway: &str) -> Self { + Self { + hosting, + gateway: gateway.trim_end_matches('/').to_string(), + http: reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .expect("http client"), + published: Mutex::new(BTreeMap::new()), + _supervisor: None, + ephemeral_dir: None, + } + } + + pub fn hosting(&self) -> &Hosting { + &self.hosting + } + + /// Re-gossip everything published in this session until each object has + /// [`hosting::TARGET_REPLICAS`] copies or `wait` runs out. Returns the + /// copy count per site id. `progress` sees each change. + pub async fn wait_for_copies(&self, wait: Duration, mut progress: impl FnMut(&str, usize)) -> BTreeMap { + let started = Instant::now(); + let mut counts: BTreeMap = BTreeMap::new(); + loop { + let records: Vec = self.published.lock().await.values().cloned().collect(); + let mut done = true; + for rec in &records { + self.hosting.regossip(rec).await; + let n = self.hosting.holders(&rec.site_id).await.len(); + if counts.get(&rec.site_id) != Some(&n) { + progress(&rec.site_id, n); + counts.insert(rec.site_id.clone(), n); + } + if n < hosting::TARGET_REPLICAS { + done = false; + } + } + if done || started.elapsed() >= wait { + return counts; + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + } +} + +#[async_trait::async_trait] +impl BlobStore for NetworkStore { + async fn put(&self, owner: &StorageSecret, name: &str, blob: Vec) -> Result { + let built = built_site(name, &blob); + let site_id = { + let owner_hex = hex::encode(owner.object_signing_key(name).verifying_key().as_bytes()); + site::site_id_for(&owner_hex, name) + }; + // Strictly newer than any version this session or the network has. + let mut seq = now_millis(); + if let Some(prev) = self.hosting.record_for(&site_id).await { + seq = seq.max(prev.seq + 1); + } + if let Some(prev) = self.published.lock().await.get(&site_id) { + seq = seq.max(prev.seq + 1); + } + let record = sign_record(owner, name, &built, seq)?; + self.hosting.publish_site(&built, record.clone()).await?; + self.published.lock().await.insert(site_id.clone(), record); + Ok(site_id) + } + + async fn try_get(&self, site_id: &str) -> Result>> { + if !valid_site_id(site_id) { + bail!("bad site id"); + } + // A copy this node knows: read it over libp2p (or locally). + if let Some(rec) = self.hosting.record_for(site_id).await + && let Ok(Some((_, body))) = self.hosting.read_file(&rec, OBJECT_PATH).await + { + return Ok(Some(body)); + } + // Otherwise any gateway. It only ever sees and returns ciphertext, + // and `open` rejects anything it might substitute. + let url = format!("{}/s/{site_id}/{OBJECT_PATH}", self.gateway); + fetch_url(&self.http, &url).await + } +} + +/// Blobs fetched read-only from a gateway over HTTP (no libp2p node). +pub struct GatewayStore { + pub gateway: String, + http: reqwest::Client, +} + +impl GatewayStore { + pub fn new(gateway: &str) -> Self { + Self { + gateway: gateway.trim_end_matches('/').to_string(), + http: reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .expect("http client"), + } + } +} + +#[async_trait::async_trait] +impl BlobStore for GatewayStore { + async fn put(&self, _owner: &StorageSecret, _name: &str, _blob: Vec) -> Result { + bail!("a gateway is read-only; publishing needs a network client") + } + + async fn try_get(&self, site_id: &str) -> Result>> { + if !valid_site_id(site_id) { + bail!("bad site id"); + } + fetch_url(&self.http, &format!("{}/s/{site_id}/{OBJECT_PATH}", self.gateway)).await + } +} + +async fn fetch_url(http: &reqwest::Client, url: &str) -> Result>> { + let resp = http.get(url).send().await.with_context(|| format!("GET {url}"))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + bail!("GET {url}: HTTP {}", resp.status()); + } + Ok(Some(resp.bytes().await?.to_vec())) +} + +// ─────────────────────────────────────────────────────────── operations + +/// What to store. +#[derive(Default)] +pub struct PutOptions { + pub file_name: Option, + pub content_type: Option, + /// Others who can open it with their storage key (the owner always can). + pub share_with: Vec, +} + +/// A stored object. +pub struct PutResult { + pub site_id: String, + pub dek: DataKey, + /// Every site id published (the object, plus its parts). + pub published: Vec, + pub stored_bytes: u64, +} + +fn recipients(owner: &StorageSecret, extra: &[StoragePublic]) -> Vec { + let mut v = vec![owner.public()]; + for r in extra { + if !v.contains(r) { + v.push(*r); + } + } + v +} + +/// Seal `content` for the owner (and `share_with`) and store it. Content +/// over [`PART_BYTES`] is split into parts, each sealed under its own key; +/// the part keys travel only inside the (sealed) root object. +pub async fn put_bytes(store: &dyn BlobStore, owner: &StorageSecret, content: &[u8], opts: PutOptions) -> Result { + put_bytes_split(store, owner, content, opts, PART_BYTES).await +} + +async fn put_bytes_split(store: &dyn BlobStore, owner: &StorageSecret, content: &[u8], opts: PutOptions, part_bytes: usize) -> Result { + let rcpts = recipients(owner, &opts.share_with); + let mut published = Vec::new(); + let mut stored = 0u64; + let created = now_secs(); + let name = c0mpute_sealed::random_name(); + if content.len() <= part_bytes { + let sealed = c0mpute_sealed::seal( + content, + &SealParams { + kind: Kind::File, + name: &name, + owner, + file_name: opts.file_name, + content_type: opts.content_type, + files: BTreeMap::new(), + parts: Vec::new(), + recipients: &rcpts, + created, + version: 0, + }, + )?; + stored += sealed.blob.len() as u64; + let id = store.put(owner, &name, sealed.blob).await?; + published.push(id.clone()); + return Ok(PutResult { + site_id: id, + dek: sealed.dek, + published, + stored_bytes: stored, + }); + } + let parts = put_parts(store, owner, content, part_bytes, created, &mut published, &mut stored).await?; + let root = c0mpute_sealed::seal( + &[], + &SealParams { + kind: Kind::Multipart, + name: &name, + owner, + file_name: opts.file_name, + content_type: opts.content_type, + files: BTreeMap::new(), + parts, + recipients: &rcpts, + created, + version: 0, + }, + )?; + stored += root.blob.len() as u64; + let id = store.put(owner, &name, root.blob).await?; + published.push(id.clone()); + Ok(PutResult { + site_id: id, + dek: root.dek, + published, + stored_bytes: stored, + }) +} + +async fn put_parts( + store: &dyn BlobStore, + owner: &StorageSecret, + content: &[u8], + part_bytes: usize, + created: u64, + published: &mut Vec, + stored: &mut u64, +) -> Result> { + let mut parts = Vec::new(); + for piece in content.chunks(part_bytes) { + let pname = c0mpute_sealed::random_name(); + // No recipient slots: a part opens only with the key in its root. + let sealed = c0mpute_sealed::seal( + piece, + &SealParams { + kind: Kind::Part, + name: &pname, + owner, + file_name: None, + content_type: None, + files: BTreeMap::new(), + parts: Vec::new(), + recipients: &[], + created, + version: 0, + }, + )?; + *stored += sealed.blob.len() as u64; + let id = store.put(owner, &pname, sealed.blob).await?; + published.push(id.clone()); + parts.push(PartRef { + site_id: id, + key: sealed.dek.to_link_secret().to_string(), + len: piece.len() as u64, + }); + } + Ok(parts) +} + +/// A fetched, decrypted object. +pub struct Fetched { + pub opened: Opened, + pub dek: DataKey, +} + +/// Fetch and decrypt `r`, with its link key or the reader's storage secret. +/// Multipart objects come back reassembled. +pub async fn get(store: &dyn BlobStore, r: &ObjectRef, secret: Option<&StorageSecret>) -> Result { + let blob = store.get(&r.site_id).await?; + let (mut opened, dek) = match (&r.key, secret) { + (Some(k), _) => (c0mpute_sealed::open(&blob, k, Some(&r.site_id))?, k.duplicate()), + (None, Some(s)) => c0mpute_sealed::open_with_secret(&blob, s, Some(&r.site_id))?, + (None, None) => bail!("this object needs a key: your storage identity, or a link with #k="), + }; + check_fresh(&r.site_id, opened.meta.version)?; + if opened.meta.kind == Kind::Multipart { + let mut all = Zeroizing::new(Vec::new()); + for p in &opened.meta.parts { + let pblob = store.get(&p.site_id).await.with_context(|| format!("part {}", p.site_id))?; + let pkey = DataKey::from_link_secret(&p.key)?; + let part = c0mpute_sealed::open(&pblob, &pkey, Some(&p.site_id))?; + if part.meta.kind != Kind::Part || part.content.len() as u64 != p.len { + bail!("part {} does not match its root", p.site_id); + } + all.extend_from_slice(&part.content); + } + opened.content = all; + } + Ok(Fetched { opened, dek }) +} + +/// Fetch an object the caller owns, checking that it does: the per-object +/// key derived from the owner's secret and the object's name must be the +/// key the object was signed with. +async fn get_owned(store: &dyn BlobStore, owner: &StorageSecret, site_id: &str) -> Result<(Vec, Opened, DataKey)> { + let blob = store.get(site_id).await?; + let (opened, dek) = c0mpute_sealed::open_with_secret(&blob, owner, Some(site_id))?; + let mine = hex::encode(owner.object_signing_key(&opened.meta.name).verifying_key().as_bytes()); + if mine != opened.meta.owner { + bail!("you can read this object but you do not own it, so you cannot change who it is shared with"); + } + check_fresh(site_id, opened.meta.version)?; + Ok((blob, opened, dek)) +} + +/// Add readers. Rewraps the key only; the ciphertext and every part stay +/// as they are, so this republishes one small header change. +pub async fn share(store: &dyn BlobStore, owner: &StorageSecret, site_id: &str, add: &[StoragePublic]) -> Result> { + let (blob, opened, dek) = get_owned(store, owner, site_id).await?; + let mut roster = c0mpute_sealed::roster(&blob, owner)?; + for r in add { + if !roster.contains(r) { + roster.push(*r); + } + } + let new = c0mpute_sealed::rewrap(&blob, &dek, owner, &roster)?; + store.put(owner, &opened.meta.name, new).await?; + Ok(roster) +} + +/// Remove readers for real: re-seal everything (root and parts) under new +/// keys and publish the new version, so the old keys open nothing current. +/// Anyone removed may still have kept a copy of what they could read before. +pub async fn revoke(store: &dyn BlobStore, owner: &StorageSecret, site_id: &str, remove: &[StoragePublic]) -> Result<(Vec, PutResult)> { + let (blob, opened, _old) = get_owned(store, owner, site_id).await?; + let roster: Vec = c0mpute_sealed::roster(&blob, owner)? + .into_iter() + .filter(|r| !remove.contains(r)) + .collect(); + let meta = opened.meta.clone(); + let mut published = Vec::new(); + let mut stored = 0; + let (content, parts) = if meta.kind == Kind::Multipart { + let full = get(store, &ObjectRef { site_id: site_id.to_string(), key: None, path: None }, Some(owner)).await?; + // New parts under new names and keys. The old part sites are + // tombstoned only after the new root is published, so a failure + // half way never leaves the object unreadable. + let parts = put_parts(store, owner, &full.opened.content, PART_BYTES, meta.created, &mut published, &mut stored).await?; + (Zeroizing::new(Vec::new()), parts) + } else { + (opened.content, Vec::new()) + }; + let sealed = c0mpute_sealed::seal( + &content, + &SealParams { + kind: meta.kind, + name: &meta.name, + owner, + file_name: meta.file_name.clone(), + content_type: meta.content_type.clone(), + files: meta.files.clone(), + parts, + recipients: &roster, + created: meta.created, + version: next_version(meta.version), + }, + )?; + stored += sealed.blob.len() as u64; + let id = store.put(owner, &meta.name, sealed.blob).await?; + published.push(id.clone()); + note_version(&id, next_version(meta.version)); + for p in &meta.parts { + if let Ok(pblob) = store.get(&p.site_id).await + && let Ok(pkey) = DataKey::from_link_secret(&p.key) + && let Ok(part) = c0mpute_sealed::open(&pblob, &pkey, Some(&p.site_id)) + { + let _ = store.put(owner, &part.meta.name, tombstone()).await; + } + } + Ok(( + roster, + PutResult { + site_id: id, + dek: sealed.dek, + published, + stored_bytes: stored, + }, + )) +} + +/// What a deleted object is replaced with: 4 KiB of random bytes, which no +/// key opens. Publishing it makes holders garbage-collect the ciphertext. +fn tombstone() -> Vec { + let mut b = vec![0u8; 4096]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut b); + b +} + +/// Delete an object (and its parts): publish a tombstone version. +pub async fn delete(store: &dyn BlobStore, owner: &StorageSecret, site_id: &str) -> Result> { + let (_, opened, _) = get_owned(store, owner, site_id).await?; + let mut done = Vec::new(); + for p in &opened.meta.parts { + let pblob = store.get(&p.site_id).await?; + let pkey = DataKey::from_link_secret(&p.key)?; + let part = c0mpute_sealed::open(&pblob, &pkey, Some(&p.site_id))?; + done.push(store.put(owner, &part.meta.name, tombstone()).await?); + } + done.push(store.put(owner, &opened.meta.name, tombstone()).await?); + Ok(done) +} + +/// Pack a directory into one private site object's content + file table. +pub fn pack_site(dir: &Path) -> Result<(Vec, BTreeMap)> { + let paths = site::list_dir(dir)?; + if paths.is_empty() { + bail!("{} has no files to publish", dir.display()); + } + if paths.len() > site::MAX_FILES { + bail!("{} has more than {} files", dir.display(), site::MAX_FILES); + } + let mut content = Vec::new(); + let mut files = BTreeMap::new(); + for (rel, abs) in paths { + let bytes = std::fs::read(&abs).with_context(|| format!("read {}", abs.display()))?; + files.insert( + rel.clone(), + SiteFile { + offset: content.len() as u64, + len: bytes.len() as u64, + content_type: site::content_type_for(&rel).to_string(), + }, + ); + content.extend_from_slice(&bytes); + if content.len() > PART_BYTES { + bail!("a private site can be at most {} MiB", PART_BYTES / (1024 * 1024)); + } + } + Ok((content, files)) +} + +/// Publish (or update) the private site `label` from `dir`. The network name +/// is derived from the owner's secret and the label, so republishing updates +/// the same object; the data key is kept across versions (the link stays +/// valid) unless `rekey`, which invalidates every link given out so far. +pub async fn put_site(store: &dyn BlobStore, owner: &StorageSecret, dir: &Path, label: &str, rekey: bool) -> Result { + let (content, files) = pack_site(dir)?; + let name = owner.named_object(label); + let site_id = site::site_id_for(&hex::encode(owner.object_signing_key(&name).verifying_key().as_bytes()), &name); + // The previous version, fully verified (signature, site id, owner, + // freshness): its key is reused so links keep working, and its roster + // so shares survive. Never take a key from an unverified blob, or a + // gateway could choose it. + let (previous, roster, prev_version) = match store.try_get(&site_id).await? { + None => (None, vec![owner.public()], 0), + Some(blob) => { + let (opened, dek) = c0mpute_sealed::open_with_secret(&blob, owner, Some(&site_id)) + .with_context(|| format!("private site {label}: the published version does not verify; refusing to update it"))?; + let mine = hex::encode(owner.object_signing_key(&name).verifying_key().as_bytes()); + if mine != opened.meta.owner || opened.meta.kind != Kind::Site { + bail!("private site {label}: the published object is not yours"); + } + check_fresh(&site_id, opened.meta.version)?; + let roster = c0mpute_sealed::roster(&blob, owner).unwrap_or_else(|_| vec![owner.public()]); + (if rekey { None } else { Some(dek) }, roster, opened.meta.version) + } + }; + let version = next_version(prev_version); + let params = SealParams { + kind: Kind::Site, + name: &name, + owner, + file_name: Some(label.to_string()), + content_type: None, + files, + parts: Vec::new(), + recipients: &roster, + created: now_secs(), + version, + }; + let sealed = match previous { + Some(dek) => c0mpute_sealed::seal_with_key(&content, ¶ms, dek)?, + None => c0mpute_sealed::seal(&content, ¶ms)?, + }; + let stored = sealed.blob.len() as u64; + let id = store.put(owner, &name, sealed.blob).await?; + note_version(&id, version); + Ok(PutResult { + site_id: id.clone(), + dek: sealed.dek, + published: vec![id], + stored_bytes: stored, + }) +} + +/// Write a fetched private site out as a directory tree. +pub fn unpack_site(opened: &Opened, out: &Path) -> Result { + let mut n = 0; + for (path, f) in &opened.meta.files { + if !site::valid_path(path) { + bail!("refusing unsafe path {path:?} in private site"); + } + let dest = out.join(path); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let start = f.offset as usize; + std::fs::write(&dest, &opened.content[start..start + f.len as usize])?; + n += 1; + } + Ok(n) +} + +// ──────────────────────────────────────────────────────── encrypted volume + +/// One file in a volume. +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VolFile { + pub site_id: String, + /// base64url DEK of the file's object (lives only in the sealed index). + pub key: String, + pub size: u64, + /// Unix seconds. + pub mtime: u64, +} + +impl std::fmt::Debug for VolFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VolFile").field("site_id", &self.site_id).field("key", &"").field("size", &self.size).finish() + } +} + +/// A volume's directory: sealed as one [`Kind::Volume`] object. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VolumeIndex { + pub files: BTreeMap, + pub dirs: std::collections::BTreeSet, +} + +/// An encrypted volume (`c0mpute storage mount`): files are private +/// objects, and the directory is one more private object whose network name +/// is derived from the owner's secret and the volume label. Nodes see a set +/// of unrelated objects with random names; they cannot tell which belong to +/// one volume, what the files are called, or how the tree is shaped. +pub struct Volume { + store: std::sync::Arc, + owner: std::sync::Arc, + name: String, + version: u64, + pub index: VolumeIndex, +} + +fn volume_path_ok(p: &str) -> bool { + p.is_empty() || site::valid_path(p) +} + +impl Volume { + /// Open (or start) the volume `label`. A volume whose directory exists + /// but cannot be fetched or opened is an error, never silently empty, so + /// a network blip cannot make the next write overwrite a real volume. + pub async fn open(store: std::sync::Arc, owner: std::sync::Arc, label: &str) -> Result { + let name = owner.named_object(&format!("volume:{label}")); + let site_id = site::site_id_for(&hex::encode(owner.object_signing_key(&name).verifying_key().as_bytes()), &name); + let mut version = 0; + let index = match store.try_get(&site_id).await? { + None => VolumeIndex::default(), + Some(blob) => { + let (opened, _) = c0mpute_sealed::open_with_secret(&blob, &owner, Some(&site_id)) + .with_context(|| format!("volume {label}: directory does not open with this identity"))?; + if opened.meta.kind != Kind::Volume { + bail!("volume {label}: directory object is a {:?}", opened.meta.kind); + } + check_fresh(&site_id, opened.meta.version)?; + version = opened.meta.version; + serde_json::from_slice(&opened.content).context("volume directory is malformed")? + } + }; + Ok(Self { store, owner, name, version, index }) + } + + pub fn site_id(&self) -> String { + site::site_id_for(&hex::encode(self.owner.object_signing_key(&self.name).verifying_key().as_bytes()), &self.name) + } + + async fn save(&mut self) -> Result<()> { + self.version = next_version(self.version); + let content = Zeroizing::new(serde_json::to_vec(&self.index)?); + let sealed = c0mpute_sealed::seal( + &content, + &SealParams { + kind: Kind::Volume, + name: &self.name, + owner: &self.owner, + file_name: None, + content_type: None, + files: BTreeMap::new(), + parts: Vec::new(), + recipients: &[self.owner.public()], + created: now_secs(), + version: self.version, + }, + )?; + let id = self.store.put(&self.owner, &self.name, sealed.blob).await?; + note_version(&id, self.version); + Ok(()) + } + + /// Read a whole file. + pub async fn read(&self, path: &str) -> Result>> { + let f = self.index.files.get(path).with_context(|| format!("no file {path}"))?; + let r = ObjectRef { + site_id: f.site_id.clone(), + key: Some(DataKey::from_link_secret(&f.key)?), + path: None, + }; + Ok(get(self.store.as_ref(), &r, None).await?.opened.content) + } + + /// Write a whole file: a new object under a fresh key, then the index. + /// The previous version's object is tombstoned afterwards. + pub async fn write(&mut self, path: &str, content: &[u8]) -> Result<()> { + if !volume_path_ok(path) || path.is_empty() { + bail!("invalid path {path:?}"); + } + let put = put_bytes(self.store.as_ref(), &self.owner, content, PutOptions::default()).await?; + let old = self.index.files.insert( + path.to_string(), + VolFile { + site_id: put.site_id, + key: put.dek.to_link_secret().to_string(), + size: content.len() as u64, + mtime: now_secs(), + }, + ); + self.save().await?; + if let Some(old) = old { + let _ = delete(self.store.as_ref(), &self.owner, &old.site_id).await; + } + Ok(()) + } + + pub async fn remove(&mut self, path: &str) -> Result<()> { + let old = self.index.files.remove(path).with_context(|| format!("no file {path}"))?; + self.save().await?; + let _ = delete(self.store.as_ref(), &self.owner, &old.site_id).await; + Ok(()) + } + + pub async fn mkdir(&mut self, path: &str) -> Result<()> { + if !volume_path_ok(path) || path.is_empty() { + bail!("invalid path {path:?}"); + } + self.index.dirs.insert(path.to_string()); + self.save().await + } + + pub async fn rmdir(&mut self, path: &str) -> Result<()> { + if self.children(path).next().is_some() { + bail!("directory not empty"); + } + self.index.dirs.remove(path); + self.save().await + } + + /// Rename a file or directory (index only: no data moves). + pub async fn rename(&mut self, from: &str, to: &str) -> Result<()> { + if !volume_path_ok(to) || to.is_empty() { + bail!("invalid path {to:?}"); + } + let replaced = if let Some(f) = self.index.files.remove(from) { + self.index.files.insert(to.to_string(), f) + } else if self.is_dir(from) { + let prefix = format!("{from}/"); + let moved: Vec = self.index.files.keys().filter(|k| k.starts_with(&prefix)).cloned().collect(); + for k in moved { + let f = self.index.files.remove(&k).expect("listed"); + self.index.files.insert(format!("{to}/{}", &k[prefix.len()..]), f); + } + let dirs: Vec = self.index.dirs.iter().filter(|d| *d == from || d.starts_with(&prefix)).cloned().collect(); + for d in dirs { + self.index.dirs.remove(&d); + self.index.dirs.insert(format!("{to}{}", &d[from.len()..])); + } + None + } else { + bail!("no such file {from}"); + }; + self.save().await?; + if let Some(old) = replaced { + let _ = delete(self.store.as_ref(), &self.owner, &old.site_id).await; + } + Ok(()) + } + + pub fn is_dir(&self, path: &str) -> bool { + path.is_empty() || self.index.dirs.contains(path) || { + let prefix = format!("{path}/"); + self.index.files.keys().any(|k| k.starts_with(&prefix)) || self.index.dirs.iter().any(|d| d.starts_with(&prefix)) + } + } + + /// Direct children of `dir` as (name, is_dir). + pub fn children<'a>(&'a self, dir: &str) -> impl Iterator + 'a { + let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") }; + let mut out: BTreeMap = BTreeMap::new(); + for k in self.index.files.keys().chain(self.index.dirs.iter()) { + if let Some(rest) = k.strip_prefix(&prefix) + && !rest.is_empty() + { + match rest.split_once('/') { + Some((first, _)) => { + out.insert(first.to_string(), true); + } + None => { + let is_dir = self.index.dirs.contains(k); + out.entry(rest.to_string()).or_insert(is_dir); + } + } + } + } + out.into_iter() + } +} + +// ─────────────────────────────────────────────────── rollback protection + +/// The newest version of each object this machine has seen or published. +/// Every object carries a signed version; a node or gateway can still +/// replay an *older* signed version (say, from before a revocation), and +/// only the reader's memory can catch that. The CLI installs one backed by +/// an encrypted file; without one, nothing is checked. +pub struct Freshness { + seen: std::sync::Mutex>, + save: Box) + Send + Sync>, +} + +static FRESHNESS: std::sync::OnceLock = std::sync::OnceLock::new(); + +impl Freshness { + /// Install the process-wide tracker (first call wins). + pub fn install(seen: BTreeMap, save: impl Fn(&BTreeMap) + Send + Sync + 'static) { + let _ = FRESHNESS.set(Freshness { + seen: std::sync::Mutex::new(seen), + save: Box::new(save), + }); + } + + /// Install one persisted next to the local index, encrypted under the + /// owner's local key. + pub fn install_for(owner: &StorageSecret) { + let path = crate::config::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("private-storage-seen.idx"); + let seen = LocalIndex::load(&path, owner).map(|i| i.seen).unwrap_or_default(); + let key = LocalIndex::cipher_key(owner); + Freshness::install(seen, move |map| { + let idx = LocalIndex { + seen: map.clone(), + ..Default::default() + }; + let _ = idx.save_with_key(&path, &key); + }); + } +} + +fn check_fresh(site_id: &str, version: u64) -> Result<()> { + let Some(f) = FRESHNESS.get() else { return Ok(()) }; + let mut seen = f.seen.lock().expect("freshness lock"); + match seen.get(site_id) { + Some(v) if version < *v => bail!( + "object {site_id} was served at an older version than this machine has already seen \ + (a node or gateway is replaying it); refusing" + ), + Some(v) if version == *v => Ok(()), + _ => { + seen.insert(site_id.to_string(), version); + (f.save)(&seen); + Ok(()) + } + } +} + +fn note_version(site_id: &str, version: u64) { + let _ = check_fresh(site_id, version); +} + +fn next_version(prev: u64) -> u64 { + c0mpute_sealed::now_version().max(prev.saturating_add(1)) +} + +// ────────────────────────────────────────────────────────── local index + +/// The owner's list of its objects, on its own disk only, encrypted under +/// a key derived from its storage secret. The network never sees it. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct LocalIndex { + pub objects: BTreeMap, + /// Newest version seen per object (rollback protection). + #[serde(default)] + pub seen: BTreeMap, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct IndexEntry { + pub file_name: Option, + pub size: u64, + pub kind: Kind, + pub created: u64, + #[serde(default)] + pub shared_with: usize, +} + +impl LocalIndex { + fn cipher_key(owner: &StorageSecret) -> Zeroizing<[u8; 32]> { + let mut k = Zeroizing::new([0u8; 32]); + hkdf::Hkdf::::new(None, owner.local_index_key()) + .expand(b"c0mpute-private-local-index-file-v1", k.as_mut()) + .expect("32 bytes"); + k + } + + fn cipher(owner: &StorageSecret) -> aes_gcm::Aes256Gcm { + Self::cipher_from(&Self::cipher_key(owner)) + } + + fn cipher_from(key: &[u8; 32]) -> aes_gcm::Aes256Gcm { + use aes_gcm::KeyInit; + aes_gcm::Aes256Gcm::new_from_slice(key).expect("32-byte key") + } + + pub fn load(path: &Path, owner: &StorageSecret) -> Result { + use aes_gcm::aead::Aead; + let Ok(raw) = std::fs::read(path) else { + return Ok(Self::default()); + }; + if raw.len() < 12 { + bail!("local index {} is corrupt", path.display()); + } + let plain = Self::cipher(owner) + .decrypt(aes_gcm::Nonce::from_slice(&raw[..12]), &raw[12..]) + .map_err(|_| anyhow::anyhow!("local index {} does not open with this identity", path.display()))?; + Ok(serde_json::from_slice(&plain)?) + } + + pub fn save(&self, path: &Path, owner: &StorageSecret) -> Result<()> { + self.save_with_key(path, &Self::cipher_key(owner)) + } + + fn save_with_key(&self, path: &Path, key: &[u8; 32]) -> Result<()> { + use aes_gcm::aead::Aead; + let plain = Zeroizing::new(serde_json::to_vec(self)?); + let mut nonce = [0u8; 12]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce); + let ct = Self::cipher_from(key) + .encrypt(aes_gcm::Nonce::from_slice(&nonce), plain.as_slice()) + .map_err(|_| anyhow::anyhow!("encrypt local index"))?; + if let Some(d) = path.parent() { + std::fs::create_dir_all(d)?; + } + let mut out = nonce.to_vec(); + out.extend_from_slice(&ct); + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, out)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } + + /// `/private-storage.idx`. + pub fn default_path() -> PathBuf { + crate::config::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("private-storage.idx") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> (tempfile::TempDir, DirStore) { + let d = tempfile::tempdir().unwrap(); + let s = DirStore(d.path().join("blobs")); + (d, s) + } + + #[test] + fn site_id_derivation_matches_hosting() { + for (o, n) in [("ab".repeat(32), "x"), ("00".repeat(32), "some-name")] { + assert_eq!(c0mpute_sealed::site_id_for(&o, n), site::site_id_for(&o, n)); + } + } + + #[test] + fn refs_parse() { + let id = "0123456789abcdef0123"; + assert_eq!(ObjectRef::parse(&format!("c0p1:{id}")).unwrap().site_id, id); + assert_eq!(ObjectRef::parse(id).unwrap().site_id, id); + let owner = StorageSecret::generate(); + let sealed = c0mpute_sealed::seal( + b"x", + &SealParams { + kind: Kind::File, + name: "n", + owner: &owner, + file_name: None, + content_type: None, + files: BTreeMap::new(), + parts: vec![], + recipients: &[], + created: 0, + version: 0, + }, + ) + .unwrap(); + let link = browser_link("https://gw.example/", id, &sealed.dek); + let r = ObjectRef::parse(&link).unwrap(); + assert_eq!(r.site_id, id); + assert!(r.key.is_some()); + let r = ObjectRef::parse(&capability_ref(id, &sealed.dek)).unwrap(); + assert!(r.key.is_some()); + assert!(ObjectRef::parse("c0p1:nothex").is_err()); + assert!(ObjectRef::parse("https://gw/s/abc").is_err()); + } + + #[test] + fn sealed_site_is_a_valid_single_file_hosting_site() { + let owner = StorageSecret::generate(); + let blob = vec![7u8; 3 * FILE_CHUNK_BYTES + 5]; + let built = built_site("abc", &blob); + let m = SiteManifest::from_bytes(&built.manifest.to_bytes()).unwrap(); + assert_eq!(m.files.len(), 1); + assert_eq!(m.files[OBJECT_PATH].chunks.len(), 4); + let rec = sign_record(&owner, "abc", &built, 1).unwrap(); + assert!(rec.verify()); + let owner_hex = hex::encode(owner.object_signing_key("abc").verifying_key().as_bytes()); + assert_eq!(rec.owner, owner_hex); + assert_eq!(rec.site_id, site::site_id_for(&owner_hex, "abc")); + } + + #[tokio::test] + async fn put_get_share_revoke_delete() { + let (_d, s) = store(); + let alice = StorageSecret::generate(); + let bob = StorageSecret::generate(); + let data = b"quarterly numbers, do not leak".repeat(100); + let put = put_bytes(&s, &alice, &data, PutOptions { file_name: Some("q3.txt".into()), ..Default::default() }).await.unwrap(); + let r = ObjectRef::parse(&plain_ref(&put.site_id)).unwrap(); + let got = get(&s, &r, Some(&alice)).await.unwrap(); + assert_eq!(&*got.opened.content, &data); + assert_eq!(got.opened.meta.file_name.as_deref(), Some("q3.txt")); + // The stored bytes are ciphertext. + let raw = s.get(&put.site_id).await.unwrap(); + assert!(!raw.windows(9).any(|w| w == b"quarterly")); + assert!(get(&s, &r, Some(&bob)).await.is_err()); + + // Share with bob; bob cannot reshare (he does not own it). + let roster = share(&s, &alice, &put.site_id, &[bob.public()]).await.unwrap(); + assert_eq!(roster, vec![alice.public(), bob.public()]); + let as_bob = get(&s, &r, Some(&bob)).await.unwrap(); + assert_eq!(&*as_bob.opened.content, &data); + assert!(share(&s, &bob, &put.site_id, &[]).await.is_err()); + + // Revoke bob: new keys, bob's old key and slot open nothing. + let (roster, re) = revoke(&s, &alice, &put.site_id, &[bob.public()]).await.unwrap(); + assert_eq!(roster, vec![alice.public()]); + assert_eq!(re.site_id, put.site_id); + assert!(get(&s, &r, Some(&bob)).await.is_err()); + let old_link = ObjectRef { site_id: put.site_id.clone(), key: Some(as_bob.dek.duplicate()), path: None }; + assert!(get(&s, &old_link, None).await.is_err()); + assert_eq!(&*get(&s, &r, Some(&alice)).await.unwrap().opened.content, &data); + + // A capability link works without any identity. + let cap = ObjectRef::parse(&capability_ref(&put.site_id, &re.dek)).unwrap(); + assert_eq!(&*get(&s, &cap, None).await.unwrap().opened.content, &data); + + delete(&s, &alice, &put.site_id).await.unwrap(); + assert!(get(&s, &r, Some(&alice)).await.is_err()); + } + + #[tokio::test] + async fn private_sites_keep_their_link_across_versions() { + let (d, s) = store(); + let alice = StorageSecret::generate(); + let site_dir = d.path().join("site"); + std::fs::create_dir_all(site_dir.join("css")).unwrap(); + std::fs::write(site_dir.join("index.html"), b"

v1

").unwrap(); + std::fs::write(site_dir.join("css/a.css"), b"h1{color:red}").unwrap(); + let v1 = put_site(&s, &alice, &site_dir, "family-photos", false).await.unwrap(); + let link = browser_link("https://gw", &v1.site_id, &v1.dek); + std::fs::write(site_dir.join("index.html"), b"

v2

").unwrap(); + let v2 = put_site(&s, &alice, &site_dir, "family-photos", false).await.unwrap(); + assert_eq!(v2.site_id, v1.site_id); + let got = get(&s, &ObjectRef::parse(&link).unwrap(), None).await.unwrap(); + assert_eq!(got.opened.meta.kind, Kind::Site); + let f = &got.opened.meta.files["index.html"]; + assert_eq!(&got.opened.content[f.offset as usize..(f.offset + f.len) as usize], b"

v2

"); + let out = d.path().join("out"); + assert_eq!(unpack_site(&got.opened, &out).unwrap(), 2); + assert_eq!(std::fs::read(out.join("css/a.css")).unwrap(), b"h1{color:red}"); + // Rekey: the old link stops working. + put_site(&s, &alice, &site_dir, "family-photos", true).await.unwrap(); + assert!(get(&s, &ObjectRef::parse(&link).unwrap(), None).await.is_err()); + } + + #[tokio::test] + async fn multipart_objects_reassemble_and_revoke_fully() { + let (_d, s) = store(); + let alice = StorageSecret::generate(); + let bob = StorageSecret::generate(); + let data: Vec = (0..350_000u32).map(|i| (i % 253) as u8).collect(); + let put = put_bytes_split(&s, &alice, &data, PutOptions { share_with: vec![bob.public()], ..Default::default() }, 100_000).await.unwrap(); + assert_eq!(put.published.len(), 5, "4 parts + root"); + let r = ObjectRef { site_id: put.site_id.clone(), key: None, path: None }; + let got = get(&s, &r, Some(&bob)).await.unwrap(); + assert_eq!(got.opened.meta.kind, Kind::Multipart); + assert_eq!(&*got.opened.content, &data); + let old_parts = got.opened.meta.parts.clone(); + revoke(&s, &alice, &put.site_id, &[bob.public()]).await.unwrap(); + assert!(get(&s, &r, Some(&bob)).await.is_err()); + assert_eq!(&*get(&s, &r, Some(&alice)).await.unwrap().opened.content, &data); + // Bob kept the old part keys: the old part objects are tombstones now. + let pkey = DataKey::from_link_secret(&old_parts[0].key).unwrap(); + let pblob = s.get(&old_parts[0].site_id).await.unwrap(); + assert!(c0mpute_sealed::open(&pblob, &pkey, None).is_err()); + } + + #[tokio::test] + async fn volumes_persist_and_hide_names() { + let (d, s) = store(); + let s: std::sync::Arc = std::sync::Arc::new(s); + let alice = std::sync::Arc::new(StorageSecret::generate()); + let mut v = Volume::open(s.clone(), alice.clone(), "backups").await.unwrap(); + v.mkdir("docs").await.unwrap(); + v.write("docs/passwords-2026.txt", b"hunter2 hunter2").await.unwrap(); + v.write("photo.jpg", &[9u8; 70_000]).await.unwrap(); + v.write("docs/passwords-2026.txt", b"rotated: correct horse").await.unwrap(); + v.rename("photo.jpg", "docs/photo.jpg").await.unwrap(); + let kids: Vec<_> = v.children("").collect(); + assert_eq!(kids, vec![("docs".to_string(), true)]); + // Reopen from the store alone: everything is there. + let v2 = Volume::open(s.clone(), alice.clone(), "backups").await.unwrap(); + assert_eq!(&*v2.read("docs/passwords-2026.txt").await.unwrap(), b"rotated: correct horse"); + assert_eq!(v2.read("docs/photo.jpg").await.unwrap().len(), 70_000); + // Another identity cannot open the volume; a fresh label is empty. + assert!(Volume::open(s.clone(), std::sync::Arc::new(StorageSecret::generate()), "backups").await.unwrap().index.files.is_empty()); + assert!(Volume::open(s.clone(), alice.clone(), "other").await.unwrap().index.files.is_empty()); + // Nothing readable in the stored blobs. + for e in std::fs::read_dir(d.path().join("blobs")).unwrap() { + let b = std::fs::read(e.unwrap().path()).unwrap(); + for needle in [&b"hunter2"[..], b"passwords-2026", b"correct horse", b"docs/"] { + assert!(!b.windows(needle.len()).any(|w| w == needle)); + } + } + } + + #[test] + fn local_index_round_trips_and_is_encrypted() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("idx"); + let alice = StorageSecret::generate(); + let mut idx = LocalIndex::default(); + idx.objects.insert( + "0123456789abcdef0123".into(), + IndexEntry { file_name: Some("tax-return-2026.pdf".into()), size: 5, kind: Kind::File, created: 1, shared_with: 0 }, + ); + idx.save(&p, &alice).unwrap(); + let raw = std::fs::read(&p).unwrap(); + assert!(!raw.windows(10).any(|w| w == b"tax-return")); + assert_eq!(LocalIndex::load(&p, &alice).unwrap().objects.len(), 1); + assert!(LocalIndex::load(&p, &StorageSecret::generate()).is_err()); + } +} diff --git a/node/crates/c0mpute-core/src/supervisor.rs b/node/crates/c0mpute-core/src/supervisor.rs index 00b61e37..5e99a5e8 100644 --- a/node/crates/c0mpute-core/src/supervisor.rs +++ b/node/crates/c0mpute-core/src/supervisor.rs @@ -48,7 +48,10 @@ impl Supervisor { Self::boot_with_identity(config, identity_dir).await } - async fn boot_with_identity(config: Config, identity_dir: std::path::PathBuf) -> Result { + /// Boot with a libp2p identity kept in `identity_dir` (created if + /// missing). Private storage uses a fresh throwaway directory per + /// session, so its publishes are not tied to this machine's usual peer id. + pub async fn boot_with_identity(config: Config, identity_dir: std::path::PathBuf) -> Result { std::fs::create_dir_all(&config.storage.root)?; let store = ChunkStore::open(&config.storage.root).await?; diff --git a/node/crates/c0mpute-core/tests/private_storage_e2e.rs b/node/crates/c0mpute-core/tests/private_storage_e2e.rs new file mode 100644 index 00000000..f84019de --- /dev/null +++ b/node/crates/c0mpute-core/tests/private_storage_e2e.rs @@ -0,0 +1,245 @@ +//! End-to-end private storage on loopback libp2p (DIP-0012 `private` tier). +//! +//! Alice seals a file and publishes it. Exactly three hosting nodes take +//! copies through the normal hosting replication. Then, from the operators' +//! side: no byte of plaintext or the file name exists anywhere in any node's +//! store; a gateway that holds nothing serves only ciphertext; a holder +//! passes a proof-of-storage challenge on the ciphertext. From the users' +//! side: Bob cannot read it, Alice shares it (a header-only new version), +//! Bob reads it through the gateway, Alice revokes him and he cannot read +//! the new version, and a capability link opens it with no identity at all. + +use std::sync::Arc; +use std::time::Duration; + +use c0mpute_core::hosting::{Hosting, Options, Timing}; +use c0mpute_core::sealed_store::{self, BlobStore, GatewayStore, NetworkStore, ObjectRef, PutOptions}; +use c0mpute_net::{ChunkSource, Libp2pNetwork, NetworkConfig}; +use c0mpute_proto::Hash; +use c0mpute_sealed::StorageSecret; +use c0mpute_store::ChunkStore; +use libp2p::multiaddr::Protocol; +use tempfile::tempdir; + +struct StoreSource(ChunkStore); + +#[async_trait::async_trait] +impl ChunkSource for StoreSource { + async fn read_chunk(&self, hash: &Hash) -> anyhow::Result> { + self.0.get(hash).await + } +} + +async fn node(dir: &std::path::Path, bootstrap: Vec) -> (Arc, ChunkStore) { + let store = ChunkStore::open(dir.join("store")).await.unwrap(); + let net = Libp2pNetwork::spawn( + NetworkConfig::for_dir(dir.join("id")) + .with_listen(vec!["/ip4/127.0.0.1/tcp/0".parse().unwrap()]) + .with_local_source(Arc::new(StoreSource(store.clone()))) + .with_bootstrap(bootstrap), + ) + .await + .unwrap(); + (Arc::new(net), store) +} + +async fn listen_addr(net: &Libp2pNetwork) -> libp2p::Multiaddr { + for _ in 0..50 { + if let Ok(addrs) = net.listen_addrs().await + && let Some(a) = addrs.into_iter().next() + { + return a.with(Protocol::P2p(net.peer_id())); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("no listen addr"); +} + +fn fast() -> Timing { + Timing { + announce_every: Duration::from_millis(700), + records_every: Duration::from_millis(1500), + repair_every: Duration::from_millis(500), + holder_ttl: Duration::from_secs(4), + fetch_timeout: Duration::from_secs(20), + } +} + +/// Every file under `dir`, recursively. +fn all_files(dir: &std::path::Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + all_files(&p, out); + } else { + out.push(p); + } + } +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn private_objects_replicate_as_ciphertext_and_only_key_holders_read_them() { + let _ = tracing_subscriber::fmt() + .with_env_filter("c0mpute_core=info,c0mpute_net=warn") + .with_test_writer() + .try_init(); + let tmp = tempdir().unwrap(); + + let (hub_net, hub_store) = node(&tmp.path().join("h0"), vec![]).await; + let hub_addr = listen_addr(&hub_net).await; + let opts = |dir: &std::path::Path| Options { + hosts: true, + catalog_path: Some(dir.join("catalog.json")), + timing: fast(), + ..Default::default() + }; + let mut hosts: Vec = vec![Hosting::start(hub_net.clone(), hub_store, opts(&tmp.path().join("h0"))).await.unwrap()]; + for i in 1..5 { + let d = tmp.path().join(format!("h{i}")); + let (n, s) = node(&d, vec![hub_addr.clone()]).await; + hosts.push(Hosting::start(n, s, opts(&d)).await.unwrap()); + } + let gd = tmp.path().join("gw"); + let (gn, gs) = node(&gd, vec![hub_addr.clone()]).await; + let gateway = Hosting::start(gn, gs, Options { hosts: false, timing: fast(), ..Default::default() }).await.unwrap(); + let app = gateway.router(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gw_addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await }); + let gw_url = format!("http://{gw_addr}"); + + // Alice's client node: seeds her objects, is not a copy. + let pd = tmp.path().join("alice"); + let (pn, ps) = node(&pd, vec![hub_addr.clone()]).await; + let alice_host = Hosting::start(pn, ps, Options { hosts: false, timing: fast(), ..Default::default() }).await.unwrap(); + let alice_net = NetworkStore::with_hosting(alice_host.clone(), &gw_url); + tokio::time::sleep(Duration::from_secs(3)).await; + + let alice = StorageSecret::generate(); + let bob = StorageSecret::generate(); + const MARKER: &[u8] = b"PLAINTEXT-MARKER-7f3a"; + const FILE_NAME: &str = "medical-records-2026.pdf"; + let mut data = Vec::new(); + while data.len() < 3 * 1024 * 1024 + 123 { + data.extend_from_slice(MARKER); + data.extend_from_slice(b" some patient data that must stay private. "); + } + + let put = sealed_store::put_bytes( + &alice_net, + &alice, + &data, + PutOptions { + file_name: Some(FILE_NAME.into()), + content_type: Some("application/pdf".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + let site = put.site_id.clone(); + let copies = alice_net.wait_for_copies(Duration::from_secs(90), |_, _| {}).await; + assert!(copies[&site] >= 3, "three network copies: {copies:?}"); + tokio::time::sleep(Duration::from_secs(3)).await; + let holders = hosts[0].holders(&site).await; + assert_eq!(holders.len(), 3, "replicates to exactly the target: {holders:?}"); + + // ── Operator view: nothing but ciphertext anywhere ──────────────── + let alice_pub = alice.public().0; + let mut scanned = 0usize; + let mut holder_dirs = 0usize; + for (i, h) in hosts.iter().enumerate() { + let dir = tmp.path().join(format!("h{i}")); + let mut files = Vec::new(); + all_files(&dir, &mut files); + if holders.iter().any(|p| p == h.peer_id()) { + holder_dirs += 1; + } + for f in files { + let bytes = std::fs::read(&f).unwrap(); + scanned += bytes.len(); + assert!(!contains(&bytes, MARKER), "plaintext on node h{i}: {}", f.display()); + assert!(!contains(&bytes, FILE_NAME.as_bytes()), "file name on node h{i}: {}", f.display()); + assert!(!contains(&bytes, b"application/pdf"), "content type on node h{i}: {}", f.display()); + assert!(!contains(&bytes, &alice_pub), "owner identity on node h{i}: {}", f.display()); + } + } + assert_eq!(holder_dirs, 3); + assert!(scanned > 3 * 3 * 1024 * 1024, "the copies are really there ({scanned} bytes scanned)"); + + // The gateway serves the object as opaque bytes, and the loader page + // is static and locked down. + let client = reqwest::Client::new(); + let served = client.get(format!("{gw_url}/s/{site}/o")).send().await.unwrap(); + assert_eq!(served.status(), 200); + assert_eq!(served.headers()["content-type"], "application/octet-stream"); + let served = served.bytes().await.unwrap(); + assert!(!contains(&served, MARKER)); + assert_eq!(&served[..8], b"C0PRIV\x00\x01"); + let loader = client.get(format!("{gw_url}/p/{site}/")).send().await.unwrap(); + assert_eq!(loader.status(), 200); + assert!(loader.headers()["content-security-policy"].to_str().unwrap().contains("default-src 'none'")); + assert_eq!(loader.headers()["referrer-policy"], "no-referrer"); + let html = loader.text().await.unwrap(); + assert!(html.contains("c0mpute-private-v1 body") && !html.contains(&site), "static loader, nothing object-specific"); + + // Proof-of-storage over the ciphertext. + let rec = alice_host.record_for(&site).await.expect("record known"); + let challenger = libp2p::identity::Keypair::generate_ed25519(); + let receipt = alice_host.challenge_one(&challenger, &rec, &holders[0]).await.unwrap(); + assert!(receipt.ok && receipt.verify(), "holder answers a challenge on ciphertext"); + + // ── User view ───────────────────────────────────────────────────── + let via_gw = GatewayStore::new(&gw_url); + let r = ObjectRef::parse(&sealed_store::plain_ref(&site)).unwrap(); + let got = sealed_store::get(&via_gw, &r, Some(&alice)).await.unwrap(); + assert_eq!(&*got.opened.content, &data); + assert_eq!(got.opened.meta.file_name.as_deref(), Some(FILE_NAME)); + assert!(sealed_store::get(&via_gw, &r, Some(&bob)).await.is_err(), "bob has no slot"); + + // Share with Bob: a new version that changes only the header. + sealed_store::share(&alice_net, &alice, &site, &[bob.public()]).await.unwrap(); + let mut bob_read = None; + for _ in 0..120 { + if let Ok(f) = sealed_store::get(&via_gw, &r, Some(&bob)).await { + bob_read = Some(f); + break; + } + alice_net.wait_for_copies(Duration::from_secs(1), |_, _| {}).await; + } + let bob_read = bob_read.expect("bob reads the shared version through the gateway"); + assert_eq!(&*bob_read.opened.content, &data); + + // Revoke Bob: re-sealed under a new key, published as a new version. + sealed_store::revoke(&alice_net, &alice, &site, &[bob.public()]).await.unwrap(); + let old_key = ObjectRef { site_id: site.clone(), key: Some(bob_read.dek.duplicate()), path: None }; + let mut locked_out = false; + for _ in 0..120 { + let alice_ok = sealed_store::get(&via_gw, &r, Some(&alice)).await.is_ok(); + let bob_fails = sealed_store::get(&via_gw, &r, Some(&bob)).await.is_err() && sealed_store::get(&via_gw, &old_key, None).await.is_err(); + if alice_ok && bob_fails { + locked_out = true; + break; + } + alice_net.wait_for_copies(Duration::from_secs(1), |_, _| {}).await; + } + assert!(locked_out, "after revocation bob's slot and his old key open nothing current"); + + // A capability link (key in the fragment) needs no identity. + let current = sealed_store::get(&via_gw, &r, Some(&alice)).await.unwrap(); + let link = sealed_store::browser_link(&gw_url, &site, ¤t.dek); + let by_link = sealed_store::get(&via_gw, &ObjectRef::parse(&link).unwrap(), None).await.unwrap(); + assert_eq!(&*by_link.opened.content, &data); + + // Alice's client leaves; the network keeps serving her ciphertext. + alice_host.stop(); + tokio::time::sleep(Duration::from_secs(2)).await; + let blob = via_gw.get(&site).await.unwrap(); + assert!(!contains(&blob, MARKER)); + assert_eq!(&*c0mpute_sealed::open_with_secret(&blob, &alice, Some(&site)).unwrap().0.content, &data); +} diff --git a/node/crates/c0mpute-gateway/src/auth.rs b/node/crates/c0mpute-gateway/src/auth.rs index 50c7e3f6..ac11b018 100644 --- a/node/crates/c0mpute-gateway/src/auth.rs +++ b/node/crates/c0mpute-gateway/src/auth.rs @@ -1,8 +1,10 @@ //! Signed-request auth for storage writes (CIP-002, DIP-0007). //! -//! Reads of public objects need no auth — knowing a blake3 hash is itself the -//! capability, and `private`-tier objects are ciphertext (CIP-011), so -//! confidentiality comes from the key rather than an ACL. Writes are +//! Reads need no auth: knowing a blake3 hash is itself the capability, and +//! `private`-tier objects are sealed by their owner before upload +//! (`c0mpute-sealed`, docs/private-storage.md), so this node only ever holds +//! their ciphertext and confidentiality comes from the owner's key rather +//! than an ACL. Writes are //! authenticated so a stranger cannot fill an operator's disk. //! //! The envelope is an ed25519 signature over a canonical string. Identity is