How Frio handles your search

What the service can see, what it can't, and how the encryption works at each step.

Why this exists

Custody information is public record. The usual path to it — a sheriff's-office app or a roster login — often isn't a neutral viewer: it carries trackers, and the act of searching can leave a record of who's looking for whom. That's the problem Frio is built around.

!

Searching the normal way can leave a trail

To check whether someone is in custody, families are usually steered toward:

  • Sheriff's-office apps and county jail sites that log queries by IP, timestamp, and search term
  • Notification services like VINELink and JailTracker that require an account tied to an email or phone number — linking the searcher's identity to the person they're checking on

Each direct search can leave a record: this person, from this IP, at this time, searched for this name at this facility. For a family with an undocumented member, that record is itself a risk.

!

What the app audit found

We statically audited 20 county sheriff apps across five states (AL, FL, GA, NV, TX) — every one an OCV com.ocv.* build, every one rated HIGH RISK. Alongside the custody feed, each shipped advertising and location SDKs: Google AdMob and Play Services Location in all 20, the Facebook SDK and the Appriss VINE feed in 19 of 20, real-time and household-inventory tracking SDKs in 16 and 18, and 139 hardcoded secrets across the set.

The full permission and tracker tables, the VINELink/Equifax analysis, and a method you can run on your own county's app live on the accountability page.

How public custody information actually reaches you →

Audit methodology and verification

Each audit scans the APK binary: DEX bytecode strings (via Rizin rz-bin), AndroidManifest.xml permissions, embedded .properties files for SDK configs, and hardcoded secrets (API keys, AWS credentials, Firebase IDs). Findings are classified against a canonical registry of 24 known finding types. Every audit produces a hash-chained trace (SHA-256 linked events from APK binary → extracted evidence → findings → report) and a self-hashing witness document for tamper-evident provenance.

Counties audited: Baldwin AL, Calhoun AL, Chambers AL, Colbert AL, Covington AL, Limestone AL, Marshall AL, Montgomery AL, Russell AL, Shelby AL, Talladega AL, Tallapoosa AL, Franklin FL, Monroe FL, Coffee GA, Floyd GA, Liberty GA, Douglas NV, Lyon NV, Chambers TX.

Full audit reports (JSON, trace chains, witness documents) are published in the frio repository.

→

The path Frio takes instead

Frio is a roster monitor a family reaches through a single URL — no app to install, no account to create. (The VINELink/Equifax side of this — why the "official" notification channel is itself a data-broker pipeline — is covered on the accountability page.)

  • No account: no email, no phone number, no login. The URL is the credential.
  • No search trail at the source: the family never touches the sheriff's app. Frio's daemon does the polling from a VPS, so the family's IP never appears in the source server's logs.
  • Bookmarkable and shareable: text it to a relative, bookmark it, write it down. No install, no account recovery.
  • No conspicuous app: the URL looks like any other link — there's no "jail monitor" icon sitting on the phone.
  • Works on any device: a browser is enough — no Google Play account, which the OCV sheriff apps require.
→

The tradeoff

Anyone with the URL can see results. If the URL is intercepted (shoulder surfing, shared device, text message logs), the interceptor sees match results. There's no second factor.

That's a deliberate choice: the alternative — accounts, passwords, 2FA — creates exactly the kind of identity trail this is meant to avoid. The threat model here is bulk data from sheriff-app platforms being used to identify who is looking for detained people — not a sophisticated attacker targeting one URL.

The URL token is 32-byte random (256 bits of entropy). It cannot be guessed or enumerated. The only way to get it is to be given it or intercept it in transit.

Defense in depth
  • Status page served over HTTPS (Cloudflare) — URL path encrypted in TLS, not visible to network observers
  • No analytics, no cookies, no tracking pixels on the status page
  • Signal notification link is E2E encrypted — URL shared via Signal is protected in transit
  • Frio's daemon IP (Netherlands VPS) appears in source logs, not the family's IP
  • Private key embedded in URL fragment (#key) — never sent to server, client-side decryption only
  • The capability token can be rotated if compromised (new search = new token)
◆

Built on Spiritwriter

Frio is built on Spiritwriter — a general-purpose framework for content-addressed memory shards with encryption, lifecycle decay, and capability-based access control. The privacy model is a dial.

The same shard primitives serve radically different use cases by adjusting which layers are active:

Use CasePrivacy DialEncryptionNetworkWho Sees Content
Frio
Jail monitoring for families
Maximum Sealed-box (NaCl) Private IPFS swarm Requestor only
Foraging guide
Seasonal spots + harvest logs
Medium Trusted-circle entitlements Shared with friends Entitlement holders
Car assistant
Suzuki repair KB + diagnostics
Low None (public content) Public IPFS + Discord Everyone

All three use the same MemoryShard, the same content addressing (SHA-256), the same decay lifecycle (hot→warm→cold). Frio turns on sealed-box encryption and private networking. The car assistant turns those off and publishes openly. The foraging guide sits in between — your morel spot coordinates are shared with trusted friends, not the public.

→

How the rest of this page is organized

The tabs that follow trace how a single search actually moves through the system — intake, storage, the match cycle, result delivery, lifecycle decay, the contributor extension, who can see what, and how it scales across nodes. Frio runs the privacy dial at maximum: sealed-box encryption, content-addressed shards, a dual-key design, and lifecycle decay.

Phase 1: Search Intake

A family member submits a search via frio.help. Their search target becomes a cryptographically sealed shard.

1

User enters name + region

The web chat widget collects: last name, first names, nicknames, region, and (optionally) a Signal group link for notifications.

last_name: "martinez" first_names: ["carlos", "carlitos"] region: "Nevada" signal_group_link: "https://signal.group/#..."
Tech spec

Intake state machine in intake_engine.py. Pure functions, no I/O. The conversational flow collects structured data from natural language input.

2

Encode as Spiritwriter MemoryShard

Search params become structured atoms in a content-addressed shard. Each field maps to a ShardAtom with kind, entity, key, value.

ShardAtom(ENTITY, "target", "last_name", "martinez")
ShardAtom(ENTITY, "target", "first_names", "carlos,carlitos")
ShardAtom(FACT, "search", "threshold", "0.80")
ShardAtom(ENTITY, "notify", "signal_group_link", "https://...")
meta: { frio_status: "hot", region: "Nevada", notify_channel: "signal" }
Tech spec

shard_engine.py:shard_from_web_intake() — creates the shard. Scope = frio:search. Decay class = ACTIVE.

The shard_id is a SHA-256 content hash of the atoms — same input always produces the same ID. This is how dedup works.

3

Encrypt with service public key (sealed-box)

For encrypted shards, search params are sealed using the Frio service's public key. Only the service's private key can decrypt.

{ last_name, first_names, threshold } → service_pubkey (X25519) → NaCl sealed-box ciphertext
atom.value = base64(sealed_box_encrypt(params_json))
Tech spec: NaCl Sealed Box
  • Algorithm: X25519 key agreement + XSalsa20-Poly1305 AEAD
  • Library: PyNaCl (nacl.public.SealedBox) on server, tweetnacl-js in browser
  • Key size: 32-byte Curve25519 keys
  • Ephemeral sender: sealed-box generates a throwaway keypair per encryption — no sender identity
  • Ciphertext overhead: 48 bytes (32-byte ephemeral pubkey + 16-byte Poly1305 tag)
  • Code path: spiritwriter.fabric.sealed.seal_for_owner(plaintext, owner_pubkey)
  • Service key: ~/.frio/service.key (private, chmod 600) + ~/.frio/service.pub
Attack surface

If service private key is compromised: attacker can decrypt all sealed search shards and see who is being searched for. Mitigation: key stored with chmod 600, only on the daemon host. Key rotation planned (#120).

Key rotation tradeoff (#120): rotating the service key invalidates existing sealed shards (they're encrypted to the old key). Three strategies: (a) re-encrypt existing shards during rotation window (requires old key), (b) key versioning — keep old keys in a read-only keyring, decrypt old shards with old key, encrypt new shards with new key, (c) let old shards decay naturally (30d/90d) and only use the new key going forward. Option (c) aligns with the lifecycle decay model — a compromised key only exposes shards that haven't yet expired. Multi-node key distribution tracked in #149.

If service public key is replaced: new shards would be encrypted to attacker's key. Mitigation: public key is pinned in the web intake code.

4

Store requestor's public key in meta

The requestor's public key (generated client-side or via intake) is stored in shard meta — not encrypted. This allows the service to encrypt match results back to the requestor without knowing what they searched for.

meta.requestor_pubkey = "base64-encoded-X25519-pubkey"
meta.encrypted = true
Tech spec: Dual-key architecture
  • Service key: encrypts/decrypts search params at rest (service holds private key)
  • Requestor key: encrypts match results for delivery (requestor holds private key)
  • The service can decrypt the search target (to run matching) but cannot read the results it encrypts for the requestor
  • The requestor can read their results but cannot see other users' shards
Attack surface: requestor pubkey in cleartext meta

Correlation risk: the pubkey is a stable pseudonym. An attacker with disk access can't see what was searched, but can see that pubkey X made N searches across regions Y and Z over time period T. If the pubkey is linked to a person via another channel (network logs, intake session), all their searches are deanonymized.

Mitigations: (a) per-shard ephemeral keypairs — breaks correlation but requestor manages multiple keys, (b) encrypt the pubkey to the service key — hides it from disk forensics, adds a decryption step during result sealing, (c) accept the risk — in the single-operator model, the operator already has the service key and can see more sensitive data.

Attack surface: compromised Frio node

An attacker with access to a running Frio node (disk + process) can:

  • Inject fake matches: encrypt fabricated results to the requestor's pubkey (available in cleartext meta) and write them to {store}/results/. Family receives false "your person was found at facility X" notifications — causing panic, unnecessary travel, or misdirected legal action.
  • Suppress real matches: delete or modify dedup state in ~/.frio_state.json so real matches never trigger notifications. Family never learns their person was detained.
  • Silently stop monitoring: set frio_status to "cancelled" or "expired" on active shards.
  • Impersonate via Signal: signal-cli account data is mounted from host. Attacker can send arbitrary messages to any joined Signal group as the Frio service number.
  • Harvest Signal groups: from plaintext shards, extract group links, join groups, observe members (deanonymization of requestors).

Mitigation (result integrity): all stored match results are now signed with the service Ed25519 key. The requestor verifies the signature against the pinned service pubkey to confirm results came from a legitimate daemon cycle. Fake result injection is detectable — the attacker cannot forge a valid signature without the service private key.

Phase 2: Shard Storage

Shards are stored as content-addressed JSON files on disk. The shard_id is derived from the content — same content, same ID. This is the local model; distributed modes add replication on top.

1

Content addressing

The shard_id is a SHA-256 hash of the atoms array (serialized as JSON). This gives us natural deduplication — submitting the same search twice produces the same shard.

atoms JSON → SHA-256 → shard_id: "a7f3c2..."

This content hash is the foundation for everything above it — dedup, IPFS CID alignment, and shard replication all depend on "same content = same ID."

Tech spec

spiritwriter.fabric.shard._sha256() — hashes the canonical JSON representation of atoms. Note: meta, last_checked, and check_count are mutable and NOT part of the content hash.

The SHA-256 content hash aligns with IPFS CIDv1 addressing — a shard_id can be used as an IPFS CID with minimal wrapping, enabling content-addressed distribution without a translation layer.

2

ShardStore persistence — three deployment modes

Spiritwriter's ShardStore is a local primitive — JSON files on disk. How shards move between nodes is a separate concern with three distinct modes:

ModeStorageReplicationTransportStatus
Single node Local ShardStore
~/.frio/shards/
None N/A Production (nl1)
Mesh sync Local ShardStore per node Web API sync over Tailscale HTTP POST between nodes Partial (intake → daemon)
IPFS distributed Local ShardStore + IPFS pinning Content-addressed via CID IPFS private swarm (libp2p) Infra ready, not wired
Tech spec: Single node (current)
  • frio_store.py:FrioStore — wraps spiritwriter.fabric.store.ShardStore
  • put() is idempotent — if file exists with same shard_id, skipped
  • _force_write() used for mutable field updates (meta, last_checked)
  • Bind-mounted into Docker containers at /home/frionode/.frio/shards/
  • This is all a simple Spiritwriter deployment needs. No IPFS, no replication, no network.
Tech spec: Mesh sync (partially deployed)
  • Web intake on nl1 creates shards via /api/intake
  • Shards synced to daemon host via Tailscale API call
  • Currently one-directional: intake node → daemon host
  • No conflict resolution — shards are append-only (content-addressed = no conflicts)
  • Mutable meta (frio_status, last_checked) is node-local — not replicated
Tech spec: IPFS distributed (infrastructure ready)
  • Private IPFS swarm on nl1: Kubo in Docker, LIBP2P_FORCE_PNET=1, swarm key from spiritwriter secrets
  • shard_id (SHA-256) aligns with IPFS CIDv1 — same content, same address on both systems
  • Extension spec assumes IPFS for job dispatch (pubsub) and result collection (pin + fetch)
  • Currently only used for extension profile publishing (/api/profiles/publish)
  • The IPFS layer is a transport choice, not an architectural requirement. Multi-node Frio could replicate via Tailscale HTTP, IPFS, or any content-addressed transport.
Attack surface by mode

Single node: all shards on one disk. Disk access = full shard access (mitigated by encryption). Single point of failure.

  • Mitigation: full-disk encryption (LUKS/FileVault) on the host. Sealed shards protect search targets even if disk is imaged. Docker containers run as non-root with bind-mount scoped to ~/.frio/shards/ only.
  • Mitigation (SPOF): shard replication to a second node (#147). Canary health check detects daemon failures — git commit logged at startup, staleness warning after 7 days of no activity.

Mesh sync: shards transit over Tailscale (WireGuard encrypted). Tailscale compromise = shard interception during sync. Mutable meta divergence between nodes (status on node A ≠ status on node B).

  • Mitigation (transit): Tailscale uses WireGuard (ChaCha20-Poly1305 + Curve25519). Even if Tailscale's coordination server is compromised, peer-to-peer traffic remains encrypted. For defense-in-depth, sealed shards add a second encryption layer — intercepted shards are still NaCl-sealed.
  • Mitigation (meta divergence): meta fields that diverge (last_checked, check_count) don't affect correctness — matching is idempotent. Status transitions (hot→warm) may diverge but worst case is one node checks more frequently than needed. Formal resolution via CRDT or coordinator is tracked in #148.

IPFS distributed: private swarm limits peers, but a compromised swarm key = any node can join and fetch all pinned shards. IPFS pubsub messages (job dispatch) visible to all swarm members — job envelopes must assume swarm-wide visibility.

  • Mitigation (swarm key): swarm key stored in spiritwriter secrets (encrypted keychain), not in repo or env vars. Key rotation = generate new swarm key, distribute to trusted peers, restart IPFS nodes. Old peers are immediately excluded.
  • Mitigation (pubsub visibility): job payloads are NaCl sealed-box encrypted to the specific contributor — swarm peers see the envelope (facility_id, priority) but not the search target. Design principle: IPFS is the transport, not the trust boundary (#150).
  • Mitigation (pinned shards): only pin sealed shards and non-PII content (abilities, profiles). Never pin plaintext search shards to IPFS.
3

What's in cleartext vs encrypted

FieldPlaintext shardEncrypted shard
last_name, first_namesVisibleSealed
signal_group_linkVisibleSealed
thresholdVisibleSealed
frio_status (hot/warm/cold)VisibleVisible
regionVisibleVisible
requestor_pubkeyN/AVisible
created_at, last_checkedVisibleVisible
shard_id (content hash)VisibleVisible

Phase 3: Match Cycle

Every 15 minutes, the daemon fetches rosters, decrypts search params, runs fuzzy matching, and dispatches alerts.

1

Decrypt search params (encrypted shards only)

sealed atom value → service_private_key → { last_name, first_names, threshold }
Tech spec

shard_engine.py:decrypt_search_params() — calls unseal_as_owner() with the service private key. For plaintext shards, reads atoms directly.

Decrypted params exist only in memory during the match cycle. Never written to disk in plaintext.

Attack surface: decryption window

Memory exposure: during the match cycle (~seconds per shard), the decrypted search target exists in process memory. A memory dump, core dump, or debugger attach during this window exposes the plaintext name. Mitigation: match cycles are short; Python doesn't persist decrypted values beyond scope.

Service key is the crown jewel: whoever holds ~/.frio/service.key can decrypt any sealed shard at any time, not just during match cycles. The zero-knowledge guarantee depends entirely on this key being held only by the daemon process. Key stored chmod 600, Docker container runs as non-root. Rotation: #120. Multi-node distribution: #149.

Plaintext shards bypass this entirely: for non-encrypted shards (legacy or fallback path), search params are in cleartext atoms — no decryption needed, no protection at rest. Sealed-box encryption is now the default for web intake, but the plaintext path still exists as a fallback.

2

Fetch rosters — two pathways

Sources fall into two categories with very different privacy properties:

Bulk sources (preferred)

Daemon fetches ALL inmates — no search query sent. The search target never leaves the daemon. Matching happens locally in memory.

GET /feed/rtjb/{agency_id}/inmates → full roster

OCV, Napa CJNet, Spokane, Clark WA, Thurston, Shasta, citizenRIMS, Socrata — 55+ sources

Search-form sources (PII sent)

Site requires a last name query. The search target IS sent to the source server. This is a fundamentally different privacy model.

POST last_name=martinez → matching inmates

Clark County NV (CCDC), NDOC, Fresno, Sonoma, Kitsap — ~10 sources

Tech spec
  • roster_cache.py handles all HTTP. BULK_CUSTOM_TYPES set vs _dispatch_custom_fetch() per-shard
  • Bulk sources fetched once per cycle, shared across all shards
  • Search-form sources fetched per-shard, per-cycle — each shard's last name is sent as a query
  • Cached snapshots written to disk for instant checks (< 15 min TTL)
Attack surface: search-form sources

The source server sees the last name. A network observer, the county IT department, or a law enforcement agency with access to server logs could determine which names are being searched and when. This is unavoidable for sites that don't expose a full roster.

What we limit: only the last name is sent (never first names, never the requestor's identity, never the Signal group link). The query is indistinguishable from any member of the public using the same search form manually. Volume provides some cover — these are public-facing sites with regular traffic.

What we can't prevent: if a source server is specifically monitoring for queries about a particular surname, they will see it. The contributor extension (#128) mitigates this by distributing queries across multiple IPs/browsers, but for direct daemon fetching, the daemon's IP is visible.

Design preference: always prefer bulk sources when available for a jurisdiction. Search-form sources are a fallback for sites that don't expose a full roster API.

3

Fuzzy matching (in memory)

SequenceMatcher compares each inmate name against the search target. Threshold default: 0.80. Prefix matching + length-ratio filtering prevent substring false positives.

roster: "MARTINEZ, CARLOS J" vs target: martinez, [carlos, carlitos] → match (0.94)
Tech spec
  • fuzzy.py:fuzzy_match() — SequenceMatcher with prefix and length-ratio guards
  • Small rosters: last-name-only matching. Large rosters: require first+last
  • Lenient first-name mode when last name is exact
  • Hyphenated surname support (e.g., JEREZ-ANDRADE vs andrade)
  • All matching is in-process, in-memory. No external calls.

Phase 4: Result Delivery

Match results are encrypted to the requestor and delivered via their chosen channel.

1

Encrypt match for requestor (encrypted shards)

match: { source, name, details } → requestor_pubkey → sealed result (only requestor can read)
Tech spec

shard_engine.py:seal_match_result() — seals match dict with requestor's X25519 pubkey. Stored as JSONL in {store}/results/.

The service encrypts the result and can never read it again. Only the requestor's private key can unseal.

Attack surface: result encryption

Plaintext window: between matching and sealing, the match result exists in cleartext in process memory. The match dict includes the inmate name, source facility, and booking details. A memory dump during this window exposes the match.

Plaintext fallback path: sealed-box encryption is now the default intake flow. A plaintext fallback still exists for non-JS clients or error cases, but the standard web intake always produces sealed shards. Plaintext shards skip result sealing entirely — match results are sent as plaintext notifications without dual-key encryption.

Result integrity: all stored match results are signed with the service Ed25519 key. The requestor verifies the signature against the pinned service pubkey to confirm the result came from a legitimate daemon match cycle, not a fabricated injection.

Result persistence: sealed results in {store}/results/ persist indefinitely. Even after the search shard decays, the encrypted results remain on disk. Only the requestor can read them, but the ciphertext itself reveals that a match occurred for a given shard_id — metadata leakage.

Requestor key management: for Frio's UX, the recommended design is to embed the requestor's private key in the URL fragment: frio.help/status/{token}#{key_fragment}. The fragment (after #) is never sent to the server — only the browser sees it. This means:

  • The URL IS the key. Sharing the URL shares decryption capability. Same mental model as today's capability token — don't lose it, don't share it with people you don't trust.
  • Server never sees the private key. The server stores sealed results and serves them. Client-side JavaScript decrypts using the fragment. Zero-knowledge is preserved.
  • Lost URL = lost key. But this is identical to today's "lost bookmark" scenario. No worse than the current model, strictly better (results are encrypted at rest on the server).
  • No account, no recovery. By design. Key escrow would create exactly the kind of backdoor this architecture exists to prevent.

For a general-purpose Spiritwriter deployment (not Frio-specific), the requestor manages their own keypair and key loss means permanent data loss. Frio's URL-fragment approach is a UX-specific adaptation that collapses "credential" and "key" into one shareable artifact.

2

Dispatch notification

Three notification paths, all via signal_dispatch.py:

Signal (v2): per-user group, sends match summary
Discord: operator webhook, sends match embed
Status page: match visible at /status/{token}
Tech spec
  • Signal: signal-cli -a {SENDER} send -g {GROUP_ID} — message includes inmate name + source
  • Discord: webhook POST with red embed
  • Dedup: ~/.frio_state.json tracks sent alerts by (shard_id, source, name) tuple
  • Group IDs cached at ~/.frio/signal_groups.json
Attack surface: notification channel

Signal: messages are E2E encrypted. Signal group link in the shard is sensitive — anyone with it can join the group. For encrypted shards, the link is sealed.

Discord: NOT E2E encrypted. Operator webhook means Discord (the company) and anyone with webhook URL can see match notifications. Discord is for community/ops only, not sensitive searches.

Status page: capability-token URL — the core security feature. No account, no trail, no app. See the Why this exists tab for the rationale and tradeoff.

Phase 5: Lifecycle Decay

Shards age out automatically. Data doesn't persist forever — operational security through temporal limits.

1

Decay transitions

HOT →
30 days
WARM →
90 days
COLD → EXPIRED
Tech spec
  • frio_store.py:process_decay() — runs each daemon cycle
  • Reference time: last_viewed > last_checked > created_at
  • Activity resets the clock — viewing the status page refreshes last_viewed
  • No renewal flow yet (#119) — shards decay without user intervention
2

What happens to encrypted material

TransitionShard fileSealed resultsSignal group
hot → warmKept (meta updated)KeptStill joined
warm → coldKept (meta updated)KeptStill joined
cold → expiredPruning candidatePruning candidateShould leave
Attack surface: data retention

Expired shards are not auto-deleted yet. They're marked as expired in meta but the file persists on disk until manually pruned. This means search targets remain recoverable from disk forensics even after "expiry." Shard renewal prompts before expiry tracked in #119.

Sealed results in {store}/results/ also persist. The requestor's private key is needed to read them, but the ciphertext is there.

Phase 6: Contributor Extension

Browser extension (Tampermonkey) lets volunteers contribute collection capacity. Their browser handles CAPTCHAs naturally. The contributor learns nothing about the search.

1

Job dispatch (sealed to contributor)

{ last_name, facility_id } → contributor_pubkey (X25519) → encrypted job shard
Tech spec
  • Contributor keypair generated on first install: nacl.box.keyPair() (tweetnacl)
  • Stored in Tampermonkey: GM.setValue('frio_contributor_keypair', ...)
  • Job shard envelope (cleartext): facility_id, skill_cid, priority, expires_at
  • Job shard payload (encrypted): search params + result encryption key
2

Contributor decrypts and executes

encrypted job → contributor_secretkey → { last_name } (ephemeral, in memory only)

Decrypted params exist only during form population. Never persisted. Dev tools could capture them — accepted risk.

Tech spec

crypto.js:decryptJobParams() — sealedbox.open(encrypted, pk, sk)

The contributor's browser populates the jail's search form with the decrypted name, submits, and extracts results from the DOM.

3

Results encrypted back (contributor can't read)

extracted records → result_encryption_key (from job) → sealed result shard
Tech spec

crypto.js:encryptResults(records, metadata, resultPublicKeyBase64)

The contributor encrypts ALL extracted records (not just matches). Match determination happens node-side after decryption. The contributor never knows if there was a match.

Attack surface: adversary contributor

Can observe: which facility, timing of jobs, search params (ephemeral, via dev tools)

Cannot observe: who requested the search, whether there was a match, other contributors' activity

Can fabricate: results — mitigated by cross-validation (#131)

Cannot forge: signed results are attributable via contributor pubkey

Phase 7: Who Sees What

The full visibility matrix across all actors in the system.

Visibility Matrix

Data Operator Requestor Contributor Source server
Search target (name) Encrypted* Yes (they submitted it) Ephemeral** Last name only***
Signal group link Encrypted* Yes No No
Match results Sealed to requestor Yes (their key) Sealed before they see it No
Shard exists (someone searching) Yes (count, status) Their own See job assignments No
Region / facility Yes (meta) Yes Yes (in job envelope) Their own
Requestor identity Pubkey only Themselves No No

* Encrypted shards only — plaintext shards expose search target to operator
** Decrypted in browser memory during form population, not persisted
*** Search-form sources only (bulk sources don't receive the name)

Trust Boundaries

Requestor trusts: service key holder (can decrypt search target during matching)
Operator trusts: nothing (zero-knowledge for encrypted shards — processes data it can't see)
Contributor trusts: coordinator (assigns jobs, but can't read results)
Honest-but-curious operator model

The operator (Aaron) currently holds the service private key, meaning he CAN decrypt search targets during matching. The zero-knowledge guarantee is against third parties and data at rest, not against the active operator. This is documented and intentional — a single-operator service where the operator is aligned with the mission.

For a multi-operator deployment, the service key would need HSM-based isolation or multi-party computation to prevent any single operator from accessing search targets.

Phase 8: Distributed Model

How the architecture scales from a single node to a fleet — and where each layer adds complexity, capability, and attack surface.

1

Layer model

Each layer builds on the one below. A deployment can stop at any layer — the upper layers are optional.

LayerWhat it addsRequires
1. Spiritwriter coreMemoryShard, ShardStore, sealed-box, decayPython + PyNaCl
2. Frio single-nodeFrioStore, roster fetching, fuzzy matching, notificationsLayer 1 + requests
3. Mesh syncShard replication between nodes via HTTP APILayer 2 + Tailscale (or any VPN)
4. IPFS transportContent-addressed distribution, pubsub job dispatchLayer 2 + Kubo/IPFS node
5. Contributor networkBrowser extension fleet, CAPTCHA solving, cross-validationLayer 4 + extension coordinator
Key insight

Layers 3 and 4 are alternatives, not sequential. A multi-node deployment can use mesh sync (HTTP over Tailscale) OR IPFS — or both for different shard types. The choice is operational, not architectural. The shard primitives are the same either way.

2

What replicates vs. what stays local

DataReplicates?Why
Shard atoms (search target)YesDaemon node needs them to run matching
Shard meta (status, region)PartiallyInitial meta replicates; runtime updates (last_checked, check_count) are node-local
Sealed resultsOptionalCould replicate for redundancy, but only requestor can read them
Dedup stateNo~/.frio_state.json is node-local — same match may alert from multiple nodes
Service keyManual*Shared across nodes that need to decrypt sealed shards. Copied via SSH — no automated distribution. (#149)
Signal accountNo**signal-cli is single-device. Only one node can send from a number.
Roster cacheNo***Each node fetches its own. Could share to reduce load on sources.

* Service key must be on every node that decrypts sealed shards, but distribution is manual SCP today — no protocol, no audit trail, no revocation
** Signal's single-device model means only one node owns the sender number. Multi-node notification requires a coordinator or separate numbers per node
*** Sharing roster cache between nodes would reduce load on sources but adds a replication dependency. Currently each node independently fetches.

Attack surface: replication

Service key distribution (#149): Every node that needs to decrypt sealed shards needs the service private key. More copies = more exposure. The key is currently copied manually via SSH — no automated key distribution, no audit trail, no revocation.

Dedup divergence (#148): Node A and Node B both match the same inmate. Both send alerts because dedup state is local. The requestor gets duplicate notifications. Annoying but not a security issue — more of an operational one.

Stale meta (#147): Node A transitions a shard to "warm" but Node B still has it as "hot." Both keep checking. Wastes resources but doesn't break correctness — matching is idempotent. Formal resolution via CRDT or coordinator is part of the replication protocol design.

Signal bottleneck (#153): signal-cli is single-device — only one node can own the sender number. If that node goes down, no Signal alerts from any node. Multi-node notification requires a coordinator model or separate numbers per node.

3

IPFS: when and why

IPFS is compelling for Frio because the content-addressing models align — a shard_id (SHA-256) maps directly to an IPFS CID. But it's not required for multi-node operation.

Use caseWithout IPFSWith IPFS
Shard sync between nodesHTTP POST over TailscalePin on one node, fetch by CID on another
Job dispatch to contributorsDirect HTTP to contributor (needs their IP)Pubsub on contributor's topic (no IP needed)
Result collection from contributorsContributor POSTs to coordinatorContributor pins, coordinator fetches by CID
Ability/skill distributionHTTP download from coordinatorPin and distribute via CID (cache-friendly)
Tech spec: current IPFS setup
  • Kubo (go-ipfs) in Docker on nl1, private swarm
  • LIBP2P_FORCE_PNET=1 — only peers with swarm key can connect
  • Swarm key from spiritwriter secrets (IPFS_SWARM_KEY)
  • Ports: 4001 (swarm), 5001 (API, localhost only)
  • Currently used for: extension profile publish only
  • IPFS adds value when: (a) contributors need to exchange data without direct network access to coordinator, (b) multiple nodes need the same shard and pull-based is better than push-based, (c) you want content integrity verification for free (CID = hash)
Attack surface: IPFS private swarm

Key mitigation: Frio runs a private IPFS swarm, not the public IPFS network. The swarm is isolated — only nodes with the swarm key can connect. No content is discoverable on the public IPFS DHT. Most people picture IPFS as global, public, and permanent. Frio's is a private libp2p mesh that happens to use IPFS protocols for content-addressed transport.

Swarm key compromise: anyone with the swarm key can join the private swarm, see all pubsub traffic, and fetch any pinned content. The swarm key is effectively a network-wide shared secret. Stored in spiritwriter secrets (encrypted keychain), rotatable by generating a new key and restarting nodes — old peers are immediately excluded.

Pubsub visibility: all swarm members see all pubsub messages. Job dispatch envelopes (facility_id, priority, expiry) are visible to every peer. Only the encrypted payload is protected.

Mitigation: sealed-box encryption on job payloads ensures that even if an adversary joins the swarm, they can only see envelopes, not search targets. IPFS is the transport, not the trust boundary. Content pinned to the swarm is either non-PII (abilities, profiles) or sealed (encrypted search shards).

For how the privacy dial works across different Spiritwriter deployments (Frio vs foraging guide vs car assistant), see the "The Threat" landing page.

4

Trust boundaries in distributed mode

Shard content (atoms): trust boundary = sealed-box encryption. Transport doesn't matter.
Shard meta (status, region): trust boundary = network perimeter (Tailscale/swarm key). Visible to all nodes.
Service key: trust boundary = manual distribution. Every node with the key is equally trusted.
Results: trust boundary = requestor's sealed-box. No node (including the coordinator) can read results after sealing.
Design principle

The transport is never the trust boundary. Whether shards move over Tailscale, IPFS, or carrier pigeon, the security properties come from the sealed-box encryption on content and the signing on results (#146). The network layer provides availability and routing, not confidentiality.