What the service can see, what it can't, and how the encryption works at each step.
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.
To check whether someone is in custody, families are usually steered toward:
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.
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 →
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.
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.)
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.
#key) — never sent to server, client-side decryption onlyFrio 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 Case | Privacy Dial | Encryption | Network | Who 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.
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.
A family member submits a search via frio.help. Their search target becomes a cryptographically sealed shard.
The web chat widget collects: last name, first names, nicknames, region, and (optionally) a Signal group link for notifications.
Intake state machine in intake_engine.py. Pure functions, no I/O. The conversational flow collects structured data from natural language input.
Search params become structured atoms in a content-addressed shard. Each field maps to a ShardAtom with kind, entity, key, value.
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.
For encrypted shards, search params are sealed using the Frio service's public key. Only the service's private key can decrypt.
nacl.public.SealedBox) on server, tweetnacl-js in browserspiritwriter.fabric.sealed.seal_for_owner(plaintext, owner_pubkey)~/.frio/service.key (private, chmod 600) + ~/.frio/service.pubIf 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.
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.
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.
An attacker with access to a running Frio node (disk + process) can:
{store}/results/. Family receives false "your person was found at facility X" notifications — causing panic, unnecessary travel, or misdirected legal action.~/.frio_state.json so real matches never trigger notifications. Family never learns their person was detained.frio_status to "cancelled" or "expired" on active shards.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.
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.
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.
This content hash is the foundation for everything above it — dedup, IPFS CID alignment, and shard replication all depend on "same content = same ID."
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.
Spiritwriter's ShardStore is a local primitive — JSON files on disk. How shards move between nodes is a separate concern with three distinct modes:
| Mode | Storage | Replication | Transport | Status |
|---|---|---|---|---|
| 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 |
frio_store.py:FrioStore — wraps spiritwriter.fabric.store.ShardStoreput() is idempotent — if file exists with same shard_id, skipped_force_write() used for mutable field updates (meta, last_checked)/home/frionode/.frio/shards//api/intakeLIBP2P_FORCE_PNET=1, swarm key from spiritwriter secrets/api/profiles/publish)Single node: all shards on one disk. Disk access = full shard access (mitigated by encryption). Single point of failure.
~/.frio/shards/ only.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).
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.
| Field | Plaintext shard | Encrypted shard |
|---|---|---|
| last_name, first_names | Visible | Sealed |
| signal_group_link | Visible | Sealed |
| threshold | Visible | Sealed |
| frio_status (hot/warm/cold) | Visible | Visible |
| region | Visible | Visible |
| requestor_pubkey | N/A | Visible |
| created_at, last_checked | Visible | Visible |
| shard_id (content hash) | Visible | Visible |
Every 15 minutes, the daemon fetches rosters, decrypts search params, runs fuzzy matching, and dispatches alerts.
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.
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.
Sources fall into two categories with very different privacy properties:
Daemon fetches ALL inmates — no search query sent. The search target never leaves the daemon. Matching happens locally in memory.
OCV, Napa CJNet, Spokane, Clark WA, Thurston, Shasta, citizenRIMS, Socrata — 55+ sources
Site requires a last name query. The search target IS sent to the source server. This is a fundamentally different privacy model.
Clark County NV (CCDC), NDOC, Fresno, Sonoma, Kitsap — ~10 sources
roster_cache.py handles all HTTP. BULK_CUSTOM_TYPES set vs _dispatch_custom_fetch() per-shardThe 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.
SequenceMatcher compares each inmate name against the search target. Threshold default: 0.80. Prefix matching + length-ratio filtering prevent substring false positives.
fuzzy.py:fuzzy_match() — SequenceMatcher with prefix and length-ratio guardsMatch results are encrypted to the requestor and delivered via their chosen channel.
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.
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:
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.
Three notification paths, all via signal_dispatch.py:
signal-cli -a {SENDER} send -g {GROUP_ID} — message includes inmate name + source~/.frio_state.json tracks sent alerts by (shard_id, source, name) tuple~/.frio/signal_groups.jsonSignal: 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.
Shards age out automatically. Data doesn't persist forever — operational security through temporal limits.
frio_store.py:process_decay() — runs each daemon cyclelast_viewed > last_checked > created_atlast_viewed| Transition | Shard file | Sealed results | Signal group |
|---|---|---|---|
| hot → warm | Kept (meta updated) | Kept | Still joined |
| warm → cold | Kept (meta updated) | Kept | Still joined |
| cold → expired | Pruning candidate | Pruning candidate | Should leave |
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.
Browser extension (Tampermonkey) lets volunteers contribute collection capacity. Their browser handles CAPTCHAs naturally. The contributor learns nothing about the search.
nacl.box.keyPair() (tweetnacl)GM.setValue('frio_contributor_keypair', ...)Decrypted params exist only during form population. Never persisted. Dev tools could capture them — accepted risk.
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.
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.
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
The full visibility matrix across all actors in the system.
| 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)
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.
How the architecture scales from a single node to a fleet — and where each layer adds complexity, capability, and attack surface.
Each layer builds on the one below. A deployment can stop at any layer — the upper layers are optional.
| Layer | What it adds | Requires |
|---|---|---|
| 1. Spiritwriter core | MemoryShard, ShardStore, sealed-box, decay | Python + PyNaCl |
| 2. Frio single-node | FrioStore, roster fetching, fuzzy matching, notifications | Layer 1 + requests |
| 3. Mesh sync | Shard replication between nodes via HTTP API | Layer 2 + Tailscale (or any VPN) |
| 4. IPFS transport | Content-addressed distribution, pubsub job dispatch | Layer 2 + Kubo/IPFS node |
| 5. Contributor network | Browser extension fleet, CAPTCHA solving, cross-validation | Layer 4 + extension coordinator |
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.
| Data | Replicates? | Why |
|---|---|---|
| Shard atoms (search target) | Yes | Daemon node needs them to run matching |
| Shard meta (status, region) | Partially | Initial meta replicates; runtime updates (last_checked, check_count) are node-local |
| Sealed results | Optional | Could replicate for redundancy, but only requestor can read them |
| Dedup state | No | ~/.frio_state.json is node-local — same match may alert from multiple nodes |
| Service key | Manual* | Shared across nodes that need to decrypt sealed shards. Copied via SSH — no automated distribution. (#149) |
| Signal account | No** | signal-cli is single-device. Only one node can send from a number. |
| Roster cache | No*** | 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.
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.
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 case | Without IPFS | With IPFS |
|---|---|---|
| Shard sync between nodes | HTTP POST over Tailscale | Pin on one node, fetch by CID on another |
| Job dispatch to contributors | Direct HTTP to contributor (needs their IP) | Pubsub on contributor's topic (no IP needed) |
| Result collection from contributors | Contributor POSTs to coordinator | Contributor pins, coordinator fetches by CID |
| Ability/skill distribution | HTTP download from coordinator | Pin and distribute via CID (cache-friendly) |
LIBP2P_FORCE_PNET=1 — only peers with swarm key can connectspiritwriter secrets (IPFS_SWARM_KEY)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.
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.