<!-- grove:last-verified v1.98 -->
> Build identity is per-release โ check grove version or /api/version.
Grove uses a peer-to-peer trust model โ you explicitly choose who to connect with. There is no central authority.
| What | Algorithm | Key size |
|---|---|---|
| File chunks | ChaCha20-Poly1305 | 256-bit |
| Manifest metadata (filename, path) | ChaCha20-Poly1305 | 256-bit (per-recipient envelope) |
| Grant key wrapping | X25519 + HKDF-BLAKE2b | 256-bit |
| Chunk/content addressing | BLAKE2b | 256-bit |
| Cell identity | Ed25519 | 256-bit |
| Chat key exchange | X25519 | 256-bit |
| Chat messages (peer + portal) | ChaCha20-Poly1305 | 256-bit |
| Password hashing | PBKDF2-SHA256 | 600k iterations, random salt (versioned salt:iters:hash; 100k is the legacy fallback for un-versioned hashes) |
| Peer secret comparison | HMAC-SHA256 (timing-safe) | โ |
~/.grove/dashboard_auth as a versioned salt:iters:hash record. Un-versioned legacy hashes still verify at 100k so the iteration bump can't lock out an existing user.HttpOnly, SameSite=Lax, 30-day expiryauth_lockout)X-Real-IP authoritatively; Grove uses it for lockout and rate checks)X-Grove-Secret header on peer API calls โ except on the peer/public relay surface (replication + chunk-inventory, reachability, revoke-grant, version, ai/status) covered by #124 request signing (see below), where the bearer secret is deliberately dropped over the relay so no replayable credential rides the wire. The owner-tier endpoints (friend-remove/peer-remove/delete-chunks/ai-proxy) still carry it over the relay, staged behind config relay_secret_drop_owner_tier (default OFF) until the whole fleet accepts the signed, secret-less call._derive_peer_secret). The global peer secret is a fallback for legacy callers only.X-Grove-Sender header identifies the calling cell's pubkey; the receiver derives the expected secret for that identity and rejects mismatches โ this prevents a peer from using a valid global secret to masquerade as a different identity.X-Grove-Sig over method + path + body-hash + timestamp + sender + recipient pubkey. _verify_peer_request_sig() accepts it only when fresh (300 s window) and single-use (replay-checked), so a relay-forwarded request authenticates with no replayable secret at all. As of #183 Part 2 (v75) the bearer secret is dropped over the relay across the whole peer/public surface, so a relay operator observes no replayable credential there โ only the staged, default-OFF owner-tier endpoints still carry it. A compromised relay can never forge or replay the Ed25519 signature; it holds no peer's private key. A valid signature proves WHO sent a request, never that we know them โ verify_peer_request additionally requires _is_known_peer(sender). Without that gate a signature from any throwaway keypair passed, which was AUDIT-2026-07-25 P0-3 (open v51 โ v1.94, closed by A1). Read the two as a pair: identity and membership.get_owner_token() mints a per-cell ~/.grove/owner_token (mode 0600, never handed to peers) that is accepted on localhost owner paths in both check_dashboard_auth and verify_owner_request. This decouples local owner / CLI / self-update auth from the fleet-shared peer secret. A soak-gauge (_note_shared_secret_use) instruments the remaining global-secret uses ahead of its retirement.hmac.compare_digest (constant-time, prevents timing attacks).A joiner accepting an invite must prove two things before anything is trusted:
invite_accept demands an Ed25519 signature over the canonical grove-invite-join\n{token_id}\n{pubkey} string, verified with verify_signature, before the invite token is consumed. Both an absent proof and a present-but-invalid proof return 403. A bare token holder who cannot prove possession of the matching private key cannot impersonate an invitee.peer_request for owner approval, and returns pending: true with no peer_secret on the wire. The joiner is trusted only after the owner Approves; both sides then derive the pairwise credential as HMAC over the shared secret and the two pubkeys. The fleet-wide global secret is never handed to a joiner.claim-joiner broker call so the joiner bonds to the real inviter, not the gateway โ gated by a known-gateway check plus the joiner's #147 key-proof, and issuing a fresh per-pair secret (never the global one). The gateway stays relay infrastructure and never friends the joiner. Accepting an invite is a mutual gesture (the inviter is friended back on accept), and half-open peerings self-heal via /api/peer-check + _reconcile_mutual_peers (which completes a one-sided peering to symmetric, never downgrades it).Prior to build d487e1d (2026-05-20), manifest metadata โ filename, source path, version note, delete state โ was stored in plaintext on every peer that held even one chunk for replication. Any replication-only peer could enumerate a user's entire library. This was a real leak against Grove's stated privacy goals.
The rule + signable-byte spec live in INVARIANTS.md ยง4 (canonical); the original design note is archived at archive/research/MANIFEST-METADATA-LEAK.md. Summary:
filename, source_path, version_note) live in an encrypted_metadata blob, encrypted with a per-manifest metadata key.metadata_envelope holds the metadata key wrapped with X25519 for each authorized recipient (creator + every grantee). Peers without a grant entry cannot decrypt the metadata.<content_hash>.json (not filename-derived), so the filename is not revealed by the filesystem either.peer_can_access_manifest() in grove.py gates which peers receive manifests during sync. Metadata visibility is strictly per-grant.4f358be). The fleet holds no plaintext v0 manifests for own files.Identity in Grove is a keypair (Ed25519 for signing, X25519 for encryption). Three tiers:
| Tier | Who | What they can do |
|---|---|---|
| Peer | Any connected cell | Replicate encrypted chunks; no file/metadata access |
| Friend | Mutual explicit trust | Chat + file sharing (grants); metadata visible only for explicitly granted files |
| Observer | Portal account | Sees only files the cell owner has explicitly shared to that account |
peer_can_access_manifest./api/revoke-grant is only accepted if the request's granted_by field matches the X-Grove-Sender header. An attacker who compromises a peer secret cannot forge revocations from a different identity. See INVARIANTS.md / INVARIANT-AUDITS.md.| Data | Visible to replication-only peers? |
|---|---|
| File contents | No โ encrypted chunks, key never shared |
| File names | No โ v1 manifest metadata is encrypted; opaque on-disk names |
| Source paths | No โ encrypted in v1 manifest metadata |
| Chunk hashes | Yes โ needed for deduplication/replication |
| Your cell's pubkey | Yes โ identity |
| Your cell's name | Yes โ you choose what to advertise |
| Your IP address | Yes โ needed to connect |
| Storage usage (chunk count) | Yes โ chunk count visible |
| Chat messages | No โ end-to-end encrypted |
Friends with grants can decrypt filename/path for the specific files they were granted access to. They cannot enumerate your full library.
| Data | Visible? |
|---|---|
| Message contents (file chunks, chat) | No โ E2E encrypted |
| Peer auth bearer secret | No on the peer/public surface โ dropped over the relay as of v75 (#183 Part 2); still present only on the staged, default-OFF owner-tier endpoints |
| Request metadata (endpoints, manifest/chunk hashes) | Yes โ the relay forwards application HTTP requests |
| Who's connected | Yes โ pubkeys + IPs |
| Message size / timing | Yes โ traffic metadata |
| Which cells communicate | Yes โ routing info |
The portal is an owner-gated window into a cell, accessible over the public WAN.
X-Grove-Public-Edge: 1 (overwriting any client-supplied value). This is a PER-VHOST directive and the entire edge boundary derives from it โ familynook publishes two WAN vhosts, and grove.nook.website shipped without the tag, silently disabling every edge protection for traffic through it until 2026-08-18. Any new vhost needs its own copy; see CLAUDE.md's "Live artifacts NOT carried by any deploy". check_dashboard_auth rejects owner session cookies and portal-admin /api/ access when this tag is present. The owner dashboard and admin APIs are therefore only reachable over Tailscale (or from localhost). This closed a real hole on the familynook node (2026-06-06, da49bd0).*portal_owner stamp matches) or files explicitly shared to it. _get_portal_manifests() (web.py) enforces this. This closed prior IDOR / roster-enumeration / XSS / reshare bugs (tasks #57-#60).portal_chat.key) before being written to the database (v1:<nonce|ct> format)./portal, /site, /invite, /relay, and explicitly public APIs are reachable over the public edge. Everything else requires tailnet or localhost.Grove's AI features (chat inference, image generation, peer AI proxying) include several security layers:
peer_model_policy config key (default: friends) gates which peers may use your cell's inference capacity โ friends = only mutual friends, none = no remote access.encrypt_chat_message). The relay cannot read inference traffic._image_prompt_moderation() before reaching the worker queue. Hard-reject token patterns block the request at submission time and log to ~/.grove/image-gen-rejections.log. This was added after a 2026-05-18 incident.Chunks belonging to a peer are only deleted from your node by one path: a signed tombstone that propagates via the regular manifest-sync loop. process_tombstones() verifies the Ed25519 signature against the leaving peer's pubkey before purging anything.
The live /api/peer-leaving notification is treated as a hint only โ it removes the peer from config and health, but never deletes data, even when authenticated. Reason: shared peer secrets can leak; Ed25519 signatures cannot be forged. An attacker who obtained a peer's secret could remove the peer from your peer list, but cannot trigger a data purge.
The same "signatures, not bearer secrets" principle now guards the whole replication surface: #124 request signing (X-Grove-Sig, above) lets relay-forwarded peer requests authenticate without a replayable secret, so a leaked or relay-observed peer secret cannot be replayed into a forged replication or grant action.
All private key files should be chmod 600 (owner read/write only):
~/.grove/node.key # Ed25519 private key โ NEVER share
~/.grove/node_x25519.key # X25519 private key โ NEVER share
~/.grove/.key # Master encryption key โ NEVER share
~/.grove/peer_secret # API auth secret โ NEVER share
~/.grove/owner_token # Local owner token (#189) โ localhost-only, NEVER share
~/.grove/dashboard_auth # Password hash โ keep private
~/.grove/portal_chat.key # Portal chat at-rest key โ keep private
If you lose .key, your encrypted chunks are unrecoverable. Back up keys regularly.
Grove has 14+ locked behavioral invariants covering replication counting, trust boundaries, grant gating, routing, and chunk management. They are the authoritative source of truth for expected security behavior:
This document does not duplicate them. When a code path appears to violate an invariant, consult those files first.
X-Grove-Public-Edge tag; tailnet-only gate)_host_ssrf_safe rejects IP-literal hosts pointed at link-local / cloud-metadata 169.254.169.254 / fe80::/10 / multicast, in both URL builders โ grove._peer_url for peer calls and web._peer_api_url for portal calls. It shipped in only the portal one, so the peer path โ the one that talks to arbitrary peer-advertised hosts โ was unguarded until AUDIT-2026-07-25 P2a-3. It is a denylist: every Grove peer legitimately lives on a private address (Tailscale 100.64/10, RFC1918, Yggdrasil 200::/7), so an allowlist would refuse the whole fleet. Hand-built URLs elsewhere still bypass it.)disable_auto_update: true)desired_factor โฅ 3 reduces single-peer loss risk--require-hashes, pins llama.cpp to a specific release tag (b9946), and verifies the starter GGUF model's sha256 โ deleting it on mismatch. The install surface is hash-verified, not floating-HEAD.