// 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