Work in progress — not a finished security story. The encryption-at-rest machinery described in this document is partial by design. It raises the bar for specific separation scenarios (see §2) and does not attempt to defend against an attacker who gains operator-level access to the host.
Two operator-level paths to plaintext, both trivial:
- The Python codebase is editable.
paramem/on disk is plain source. An attacker with write access to the installed package path — which the operator user has by definition — can neutralize encryption with a three-line edit toparamem/backup/encryption.py::envelope_encrypt_bytesand restart the server. There is no code-signing, no bytecode integrity check, no TPM-backed attestation.- Config-level data exfiltration via
debug: true. The operator can flip one line inconfigs/server.yamland restart. From that point on,debug: trueforces retention of every consolidated session's transcript regardless ofconsolidation.retain_sessions, and additionally writes per-cycle debug artifacts underdata/ha/debug/episodic/.../cycle_<N>/(snapshot JSON files) — the per-session JSONLs underdata/ha/sessions/are already plaintext on disk while pending, independent of this flag; see §4 carve-outs for the exact retention geometry. No code edit, no crypto break — just the legitimate debug path used against the intent of a Security-ON deployment. This is intentional behaviour for debugging (see §4 carve-outs); it is named here because an attacker with config-write access can use it as a data-extraction primitive. (The simulate-mode graph store —graph.jsonunder<adapter_dir>/<tier>/— is encrypted by default and respectsrequire_encryption; it is NOT a debug carve-out.)Closing either gap is outside what this project can do alone; both require host-level integrity tooling (IMA/EVM on Linux, equivalent on Windows) plus operator discipline on config-write permissions.
Statements in this document describe the current implementation, not a finished target.
ParaMem is a personal memory service that stores conversational and personal facts as weight deltas in a local LoRA adapter, plus a small set of on-disk metadata files (registry, knowledge graph, session queue, voice profiles). It runs on a single host under a single admin and is designed for home / edge deployment — not multi-tenant or server-farm use.
Scope — what ParaMem provides vs. what the operator owns. ParaMem ships the cryptographic primitives (age envelopes, two-identity daily + recovery key model, passphrase-wrapped on-disk key file) and the key-lifecycle tooling (generate-key, rotate-daily, rotate-recovery, change-passphrase, restore). Everything above that boundary — host integrity, runtime attestation, hardware-backed key storage, physical isolation of keys from data at rest, who owns which filesystem path, backup-storage separation, network scoping — is a deployment-shape decision that belongs to the operator. The operator chooses their own threat model: bare metal with a YubiKey recipient, containerised under a read-only rootfs, keys on removable media unmounted at rest, data on a NAS while keys stay local, single host with defaults accepted, or any other shape. ParaMem does not prescribe one; it provides the foundation that makes multiple shapes workable. This document names what the primitives protect against and what they don't, so operators can make informed trust decisions for their own deployment.
This document describes what ParaMem defends today, what it does not, the trust boundaries in the design, and the operator contract for running it as securely as the current implementation allows. It is a living document; the security posture will tighten as work packages from the hardening plan land.
| Artifact | Content | Format |
|---|---|---|
| Adapter weight tensors | Personal facts, preferences, episodic memories | .safetensors — opaque numerical tensors |
| Indexed key registry | Key identifiers, SimHash fingerprints, timestamps | JSON |
| Cumulative knowledge graph | Entities, predicates, relations | JSON (NetworkX) |
| Session queue | Transcript + speaker binding awaiting consolidation | JSON (atomic temp-file + rename) |
| Session snapshot | RAM state at graceful shutdown | age-encrypted when a key is configured |
| Speaker profiles | Voice embeddings + disclosed names | JSON (biometric data — see §8) |
| Background trainer resume state | Epoch counter + checkpoint references | JSON |
| Adapter manifest sidecars | Base-model SHA, tokenizer fingerprint, LoRA shape | JSON |
Adapter weights are the dominant artifact by volume and sensitivity. They are numerical — not directly readable for facts — but also not probe-resistant. Under Security ON they are encrypted at rest as age envelopes alongside the JSON metadata. The indexed-recall path still requires the encrypted registry for key enumeration; weight encryption adds defense-in-depth against blob-copy attackers. See §4 and §8 for the full picture.
Trust assumption. The admin / operator of the host is a trusted authority. ParaMem does not attempt to protect data from an attacker who has the operator's OS credentials, process-memory access, or write access to the installed Python package. The operator holds the daily passphrase and the daily-key file; these travel in the same trust domain as the data they protect. A hostile process running as the operator can read the decrypted store from RAM, modify paramem/ source (e.g. replace envelope_encrypt_bytes with a pass-through), or read the wrapped daily key plus passphrase and decrypt at rest. None of these are defended against.
What Security ON actually buys — the narrow, honest claim. When the data directory is separated from the key material (decoupled from the running server), the data directory alone is not decryptable. Concretely, encryption at rest narrows the blast radius in exactly these separation scenarios:
- Accidental cloud-sync of
data/ha/alone (OneDrive, iCloud, rsync to NAS) — data appears at the sync destination but is unreadable without the key material kept at~/.config/paramem/andPARAMEM_DAILY_PASSPHRASE. - Backup exfiltration (a backup copy of the data directory without the config dir) — same story.
- Filesystem read by a different OS user on the same host (mode
0600on~/.config/paramem/daily_key.ageand.envenforced at startup). - Theft of a powered-off host IF the passphrase is not co-located (depends on operator discipline — typically a weak defense because
.envlives on the same disk).
In scope beyond data-at-rest:
- LAN-adjacent attackers sending unauthenticated requests — mitigated by the bearer-token auth layer (see §5).
- Prompt-injection attempts via voice input.
- Careless maintainers, accidental commits, screenshots of on-disk state.
Out of scope — explicitly:
- Any attacker with operator-user OS credentials, root, or process memory access.
- Anyone with write access to the installed Python package (can neutralize encryption in three lines of source).
- Nation-state adversaries.
- Supply-chain compromise of the Python runtime or pinned dependencies beyond version pinning.
- Multi-user isolation on the same host (ParaMem is a single-admin service).
- Side-channel attacks on the CPU or GPU during inference.
- User voice → STT. Raw audio arrives on a Wyoming protocol port. Transcript + speaker embedding cross into the FastAPI process on the shared asyncio event loop.
- Home Assistant ↔ ParaMem. A thin HA custom component POSTs to the
/chatendpoint over HTTP on the LAN, carrying a bearer token minted intoUserTokenStore(typically an unattributedchat-scope token). Bearer-token authentication is a two-posture model (OFF / ON) governed entirely by whether a per-user token store is wired — see §5 for the full model. When auth is OFF the server accepts any LAN request, announced at startup as an explicit open posture, not a silent one. - ParaMem → Home Assistant.
agents.ha_agent_idmust name a LOCAL HA conversation agent — the built-inconversation.home_assistant, or a self-hosted LLM agent on the operator's own hardware. ParaMem sends this hop cleartext, gated by intent classification only, and treats it as on-premise: it is the leg that stays reachable when the cloud leg is closed bycloud.enabled: falseor bycloud.allow_degraded_serving: falseduring an outage. Configuring a cloud-backed HA agent (conversation.groq,conversation.openai, …) silently re-opens cloud egress one hop away, outside every ParaMem switch, with no sanitization applied. (Unrelated and unaffected: the HA custom component's own fallbackagent_id, which exists to prevent recursive routing when ParaMem is HA's default conversation agent.) - ParaMem → cloud. Sanitized queries may be sent to a configured cloud agent for escalation or cloud enrichment. This path is opt-in via config; nothing is sent without an active cloud configuration. Whether a query is personal — and therefore kept on-device — is decided by the intent classifier, supplemented by an encoder-based "is this about the speaker?" check with multilingual exemplars under
configs/personal_referent/(English token-set fallback when the encoder isn't loaded). A query that does egress under an anonymizingcloud_modeis rewritten to placeholders by the local model first. No speaker name or id is ever sent to the cloud system prompt (_escalate_to_cloudcarries no identity line at all — see §3, Routing-time intent classifier). Coverage scales with the exemplar files and the classifier; see §7 and §8 for the operator's responsibility and the residual risk. - Routing-time intent classifier (privacy property). The intent classifier in
paramem/server/intent.pyruns before any retrieval and outside the PA reasoning path. Underintent.mode: llm(default) the local Mistral 7B is invoked with the focused classifier section ofconfigs/prompts/pa_voice.txtonly — no identity-injection helper is applied, so the speaker is not named in the classifier system message and the query is classified on content alone. Underintent.mode: embeddingsthe sentence-encoder cosine match never receives speaker identity. Either path keeps routing-time classification orthogonal to personal-context exposure: the only identity that reaches a model-facing prompt at all is on the response-time local reasoning leg, and even there it is the rawspeaker{N}token (never the display name) injected by_build_speaker_prefix/_build_system_prompt— the human-readable name is substituted only at the reply boundary (resolve_speaker_tokens) and never sent to any model, local or cloud. The cloud leg (_escalate_to_cloud) carries no identity line at all. - Unidentified caller → relay. A request reaches the relay path only when every resolution step — bound token, voice-embedding match, session history, anonymous-speaker promotion — has failed to yield a speaker; it is then served on a relay path (HA / cloud / local base model only) with no knowledge-store access, no history egress, and no consolidation while it stays unattributed (a later voice or name-disclosure claim can still attribute and consolidate it normally). A personal interrogative on this path is caught by a dedicated short-circuit and gets the canned no-identity response instead of cloud. See
architecture.mdAD-23. - Adapter files at rest. The on-disk artifacts listed in §1 live under the configured data directory. At-rest encryption is governed by the binary switch in §4.
- Backup at rest. Session snapshots and every other piece of infrastructure metadata follow the Security-ON/OFF contract in §4 — encrypted as age envelopes when the daily identity is loaded, and plaintext only when no key is configured.
ParaMem operates in one of two modes, governed by the loaded key material. There are no partial states.
PARAMEM_DAILY_PASSPHRASE is set AND ~/.config/paramem/daily_key.age exists. When ~/.config/paramem/recovery.pub is also present, every new write is multi-recipient (daily + recovery).
All infrastructure metadata — registry, graph, queue, snapshots, speaker profiles, manifest sidecars, backup artifacts — is age-encrypted on disk and decrypted only into process RAM on load. The universal read path sniffs the envelope magic at the start of each file (the literal bytes age-encryption.org/v1 followed by a newline) and routes to the decryptor; plaintext is passed through verbatim. On startup the server logs one of:
SECURITY: ON (age daily identity loaded, recovery recipient available)
SECURITY: ON (age daily identity loaded, recovery recipient missing — run `paramem generate-key` to re-enable multi-recipient writes)
No key material is loaded. All infrastructure metadata is plaintext on disk. This is a documented operator opt-out, not a gap. On startup the server logs:
SECURITY: OFF (no key — all infrastructure metadata is plaintext on disk)
and surfaces encryption: off on the /status endpoint. The server does not silently degrade between modes: if the daily identity is loaded but on-disk files are plaintext (or vice versa), startup refuses with an actionable message.
The Security-OFF opt-out is the operator's choice. Deployments that want a misconfiguration to fail loud rather than silently land plaintext on disk can set security.require_encryption: true in configs/server.yaml. When set, the server refuses to start unless the daily identity is loadable — a uniform startup gate covering every feature that writes to disk (snapshots, checkpoint shards, backups, infrastructure metadata). Default is false (the AUTO-everywhere posture described above).
- age files on disk without the daily identity loaded → startup refused with a clear message pointing at
PARAMEM_DAILY_PASSPHRASE+ the daily-key file path. - Plaintext files alongside age envelopes → startup refused; reconcile the store before restart.
- Plaintext files while the daily identity is loaded → startup refused; migrate the store or unset the passphrase.
For the migration and reset runbooks when a refusal occurs, see DEPLOYMENT.md — Encryption & recovery operations.
Some on-disk artifacts are intentionally kept plaintext in both modes:
data/ha/state/trial.json— migration-trial marker (paths, hashes, timestamps). Encrypting would brick recovery on key loss.data/ha/state/backup.json— scheduled-backup runner status. Same reasoning.data/ha/backups/<kind>/<ts>/*.meta.json— backup artifact sidecars (timestamp, ciphertext SHA-256, tier, label). Encrypting would turn a wrong-key restore into a silent "backup not found" instead of a clear decrypt error. The paired*.bin.encpayload remains encrypted.data/ha/sessions/<session_id>.jsonl— raw per-session transcripts. A single long conversation can span several of these files — a session rotates on an idle gap or once it grows past what one consolidation pass can take in (see DEPLOYMENT.md for the rotation triggers). Written unconditionally the moment a turn is appended, independent ofdebug— there is no RAM-only mode for a pending session (SessionBuffer._append_turn's 2026-05-14 invariant) — and kept until the session consolidates. At consolidation,consolidation.retain_sessions(defaulttrue) decides the JSONL's fate:truemoves it under the retention rootsession_retention_dir()resolves (typicallydata/ha/debug/episodic/[interim_<stamp>/]cycle_<N>/run_<run_id>/sessions/, falling back todata/ha/debug/cycle_<N>/sessions/whendebug: false), with document-ingest chunks and their original bytes grouped under a<doc_id>/subdirectory, and sessions that retry-capped instead of consolidating cleanly isolated further under aretired_recall_failed/subdirectory of that same root;falsedeletes it instead — this is the operator's choice, not a data-loss bug.debug: trueforces retention regardless ofretain_sessions. Plaintext throughout by design — the point istail/cat/grepinspection, not adebug-only opt-in.- Per-cycle debug artifacts under
data/ha/debug/episodic/[interim_<stamp>/]cycle_<N>/run_<run_id>/(episodic_rels_snapshot.json,procedural_rels_snapshot.json,graph_merged_snapshot.json,graph_enriched_snapshot.json) — written only whendebug: true. Always plaintext, inspection-first, regardless of Security posture. The simulate-mode output under<adapter_dir>/<tier>/(graph.json,indexed_key_registry.json— SimHash fingerprints live inside this file, not a separate one) is a SEPARATE, encrypted store and does NOT use this carve-out. - Per-session extraction snapshots under
data/ha/debug/episodic/.../cycle_<N>/run_<run_id>/sessions/<session_id>/(graph_snapshot.json,procedural_graph_snapshot.json) — written by the consolidation loop whendebug: trueandsave_cycle_snapshotsis enabled. Same plaintext-inspection rationale as the per-cycle aggregates above. data/ha/debug/episodic/.../cycle_<N>/run_<run_id>/calibrate_extract_<session_id>_<ts>.json— the full result of an operator-invoked/calibrate/extractcall (parsed graph including diagnostics, phase records, raw model output). Written only whendebug: true; the write self-gates off underdebug: false. Same plaintext-inspection rationale as the other per-cycle debug artifacts. None of the first three carry user facts. The session-transcript bullet also carries user facts, but — unlike the three debug-artifact bullets that follow it, which are produced only at operator request via thedebugflag — it is written unconditionally; only its post-consolidation fate (retain vs. delete) is flag-governed, byconsolidation.retain_sessions(debug: trueforcing retention regardless). Adapter weight blobs carry user facts as numerical patterns; see §8 for the probe-resistance limit that encryption does not fully close.
The auth layer is independent of the encryption mode — it governs which REST requests are accepted, not how data is written to disk. All credentials live in one place — UserTokenStore (user_tokens.json) — populated exclusively via mint-user-token; there is no separate shared-secret validation path. The startup log always emits exactly one AUTH: line naming the active posture:
| Posture | Condition | Effect |
|---|---|---|
| OFF | No per-user token store wired | The server is usable without credentials — conversational endpoints (/chat, /voice, /push/*, /status) accept any request. Fail-closed admin: the auth middleware stamps the non-admin chat scope on every pass-through request, so admin endpoints (/gpu/*, /consolidate, /backup/*, etc.) 403 via require_admin until a store is configured (i.e. until the first mint-user-token). Startup emits a loud AUTH: OFF warning. Default for a fresh install with mobile_pwa.enabled: false and no prior mint. |
| ON | A per-user token store is wired — either mobile_pwa.enabled: true, or user_tokens.json already exists from a prior mint |
All endpoints require a per-user opaque bearer token. Fail-closed: a wired store with zero active tokens still 401s every request rather than reverting to open access. Each token carries a scope — chat (the secure default, including pre-scope-field tokens) or admin. Admin scope is required for operational endpoints. The chat scope reaches /chat, /voice, /push/*, and /status. |
Every accepted token — attributed to a speaker_id or not — additionally carries a capability scope:
| Scope | Endpoints reached | How to mint |
|---|---|---|
admin |
All endpoints (conversational + operational) | mint-user-token <speaker> --scope admin, or --unattributed --scope admin --force-admin |
chat |
/chat, /voice, /push/*, /status only |
mint-user-token <speaker> --scope chat (the default), or --unattributed --scope chat |
Token minting, revocation, and the mint-user-token CLI syntax are documented in DEPLOYMENT.md — Per-user token management.
PARAMEM_API_TOKEN — carrier, not a credential. The environment variable name survives as the well-known place infrastructure consumers (the systemd consolidation-tick timer, the HA custom component) read their own bearer-token value from — see paramem/server/systemd_timer.py::render_service_unit. The server itself never reads or validates it as a credential; the value placed in it must be a token actually minted into UserTokenStore (typically an unattributed admin token, mint-user-token --unattributed --scope admin --force-admin, for infrastructure callers; an unattributed chat token for the HA component). Minting the infrastructure token is a deployment step the operator performs once.
Upgrading from the shared-token model. A deployment that predates per-user tokens set PARAMEM_API_TOKEN as a single shared credential, validated by the server directly. That validation path is retired — the variable is now only the carrier described above. A deployment that set the env var, never enabled mobile_pwa.enabled, and never ran mint-user-token lands in the OFF posture on upgrade: every REST endpoint is open, and the old token value is silently ignored. paramem/server/auth.py::log_startup_posture detects exactly this case — env var set, no per-user store wired — and emits an additional loud warning alongside the standard AUTH: OFF line, naming mint-user-token as the fix. The migration is one command: mint an admin-scope token (paramem mint-user-token --unattributed --scope admin --force-admin) and update PARAMEM_API_TOKEN (and any systemd drop-in / HA component config reading it) to the minted value.
Security properties of per-user tokens:
- Tokens are opaque random secrets. The plaintext token is displayed once at mint time and never stored or logged. Only the
sha256(token)hash is persisted on disk, inuser_tokens.json. Scope is a capability boundary — it is derived server-side from the stored record, never from a claim in the request. user_tokens.jsonfollows the deployment-wide encryption posture: plaintext under Security OFF, age-encrypted when the daily key is loaded. It is covered by the startup mode-consistency check — a plaintext credential file alongside a loaded key is refused at startup.- Fail-closed: revoking the last active token in the store keeps the auth layer fail-closed rather than silently reverting to open access.
- Token-never-logged: the plaintext token is never written to any log file.
user_tokens.jsonstores onlysha256(token).
Live reload. Revocation and scope changes (re-mint + revoke) take effect on the running server without a restart: UserTokenStore re-reads user_tokens.json on the next authenticated request when the file's mtime changes. Accepted cross-process revocation race window: the narrow in-flight window between a revoke write and the next request; not a meaningful attack surface for typical deployment cadences.
Rotation. Rotating any token — the infrastructure carrier included — is revoke-then-mint: revoke-user-token the suspected-compromised token, mint-user-token a fresh one, and update every consumer that reads the old plaintext value (.env/systemd drop-in for the infrastructure carrier, device Settings for a per-user token, the HA component config for its token). There is no separate shared-secret revocation path any more — every credential goes through the same store.
Revoking unattributed tokens. revoke_speaker() skips entries whose speaker_id is None and raises ValueError if called with None — preventing accidental bulk-revocation. Use revoke-user-token --label <label> to revoke an unattributed token by its device label.
Web Push infrastructure files (when mobile_pwa.push_enabled: true):
vapid_keys.json— EC P-256 VAPID private key (PEM). Auto-generated on first startup when push is enabled; auto-loaded on subsequent startups. Both files follow the same encryption posture asuser_tokens.json: plaintext under Security OFF, age-encrypted under Security ON, covered by the startup mode-consistency scan viainfra_paths().push_subscriptions.json— per-speaker Web Push endpoint registrations. Schema:{"version":1, "subscriptions": {"<speaker_id>": [{endpoint, keys:{p256dh,auth}}...]}}.- VAPID key stability: rotating
vapid_keys.jsoninvalidates all existing browser push subscriptions (browsers will not receive notifications until they re-subscribe). Treat the keypair as effectively immutable once browsers have subscribed. Key rotation is intentionally out of scope. - Notification-only ping posture: no personal content passes through the push relay. The push payload is intentionally empty (or carries only a generic title); real content is fetched by the client after the user taps the notification.
- Revocation is per-token or per-speaker and takes effect immediately on the next request.
- Token carriers:
Authorization: Bearer <token>HTTP header — this is the carrier the PWA uses in practice. The middleware also accepts the configured cookie name if one is presented by the client, but the server does not issue a cookie; the PWA stores the token inlocalStorageand sends it exclusively via theAuthorizationheader.
Path exemptions. The following paths are exempt from bearer-token checks so the browser can load the PWA shell and liveness checks can operate before a token is presented:
/— redirects to/app/; exempt so the browser follows the redirect before a token is presented/app— bare mount redirect (307 →/app/); exempt so it reaches theStaticFileshandler/health— unauthenticated liveness endpoint for HA binary sensors and external pollers/app/prefix — the PWA shell, its static assets, and the service worker (/app/sw.js)
All other endpoints enforce the active posture. The Wyoming STT/TTS ports have no protocol-level auth; see §6.
HTTPS/TLS is required. Three features hard-fail on plain HTTP:
- PWA install /
getUserMedia— browsers block microphone access and PWA service-worker registration on non-HTTPS origins (exceptlocalhost). - Web Push — the Web Push standard mandates HTTPS; browsers reject subscriptions over plain HTTP.
- Bearer tokens — tokens are only confidential over TLS. Plain HTTP exposes them to any LAN observer.
For HTTPS setup and the Tailscale configuration see DEPLOYMENT.md.
Trust-boundary assumption. The threat model assumes a Tailscale VPN or a trusted private LAN as the transport layer. The server is never intended for direct internet exposure. Specifically:
- Wyoming STT (port 10300) and Wyoming TTS ports have no protocol-level authentication. They must not be reachable from the public internet — secure via firewall or Tailscale ACLs.
/gpu/*,/consolidate,/backup/*,/admin/*,/calibrate/*, and/debug/*are admin-only endpoints. Exposing them to the internet is a security risk even with a strong admin-scope token.- The HA custom component reaches the server over HTTP on the LAN; place it behind a Tailscale exit node or restrict it to a dedicated VLAN.
An admin-scope token is the sole authentication barrier for the admin surface. Mint least-privilege chat-scope tokens for conversational endpoints (mint-user-token <speaker> --scope chat, or --unattributed --scope chat for a shared device) to narrow the blast radius if a token leaks; reserve admin scope for the operator and for the infrastructure carrier described in §5.
The security model follows BitLocker semantics: the key material is the only path to the data. Losing it is equivalent to losing the data; gaining it is equivalent to gaining the data (see §2 on the admin/operator trust model). There is no backdoor, no author escrow, no cloud recovery service.
The deployment uses two keys:
- Daily access key. A per-host daily identity (age X25519) stored on disk as a passphrase-wrapped envelope at
~/.config/paramem/daily_key.age(mode0600, parent directory0700). The passphrase is provided via thePARAMEM_DAILY_PASSPHRASEenvironment variable — loaded from the operator's environment or a systemd drop-in. Hardware-backed unlock (TPM2, Windows DPAPI, libsecret) is a future upgrade path behind the same loader interface and does not change the operator-facing contract. Rotatable without operator intervention. - Recovery key. A separate age X25519 identity (bech32
AGE-SECRET-KEY-1…), minted alongside the daily identity byparamem generate-key. The public recipient is persisted at~/.config/paramem/recovery.pub(mode0644) so every new envelope lists it alongside the daily recipient. The secret is printed once to stderr at generation time with a BitLocker-style warning — operators must confirm they have saved it before the key files are written — and is never persisted on this device. Store it offline: printed paper, metal seed plate, password-manager secure note, or a safe. Used only when the daily access path fails (passphrase loss, disk loss, hardware replacement). Survives hardware replacement; restoring decrypts the store and enrolls a fresh daily identity on the new host.
Both keys decrypt the same data. Loss of the daily key is routine (rotate it). Loss of the recovery key — with the daily path also unavailable — is unrecoverable.
Rotation. paramem rotate-daily mints a fresh daily identity, re-encrypts every age infrastructure file to [daily_new, recovery] — including all adapter_model.safetensors blobs enumerated via infra_paths — and atomically swaps the new daily key file into place. The recovery recipient is preserved. paramem rotate-recovery mints a fresh recovery identity, prints the new bech32 secret once with the same refuse-without-confirm UX as generate-key, and re-encrypts every file to [daily, recovery_new]. Both commands are crash-safe: per-file atomic rename plus a rotation manifest at ~/.config/paramem/rotation.manifest.json that records pending vs done files, so a crash resumes from where it left off (rotate-recovery excepted — the print-once secret cannot be resumed and must be restarted cleanly).
Hardware replacement. paramem restore --recovery-key-file <path> is the entry point after losing the original device. Given the recovery bech32 from paper, it sanity-checks against an on-disk age envelope, mints a fresh daily identity (new operator-supplied passphrase), writes daily_key.age + recovery.pub to the new machine, and re-encrypts every age file to [daily_new, recovery]. The recovery identity is reused on the envelopes — it is the thing that authorised the restore, and the operator's paper copy remains valid. Crash-safe via the same rotation-manifest mechanism; a typo in the bech32 aborts before any on-disk mutation. Distinct from paramem backup-restore, which restores a backup archive over REST.
Backup restore across key rotation. Age-encrypted backups do not carry a key fingerprint in the sidecar — the fingerprint concept does not map onto X25519 recipient lists. A stale daily identity surfaces as a decrypt error on restore (HTTP 500 decrypt_invalid_token), which is equally actionable: the operator either re-keys the backup via rotate-daily / rotate-recovery or restores from the recovery bech32. Backups written while Security was OFF are plaintext and always restore.
Discarding the interim ring. POST /interim/discard removes interim-tier facts from the live store and from disk, but — the same property that governs /speaker/forget — does not reach into backup bundles taken beforehand: a bundle captured before the discard still contains those slots, and restoring it brings them back.
Full-snapshot restore (migration revert). Beyond per-artifact config restores, POST /backup/restore with restore_config: true restores a complete snapshot_bundle — every tier's adapter weights, donor checkpoints, registries, key_metadata.json, speaker profiles, and server.yaml — verifying every file hash and decrypt-probing the daily identity before any mutation, and safety-snapshotting the current state first so the revert is itself reversible. This is the revert path for a migration that has already been accepted (its trial marker cleared): the pre-migration bundle is the rollback, restored over REST followed by a restart. It is refused during an active TRIAL/STAGING migration or while consolidation/training is running. Base-swap snapshot bundles (pre_base_swap tier) additionally retain a non-restored server.yaml.candidate sidecar — the candidate config that was staged for the swap — so the operator can extract it and retry after a rollback; these bundles are retention-immune for 30 days (same class as pre-migration snapshots), surviving pruning even after the trial marker is cleared.
Infrastructure integrity check. paramem integrity (and GET /integrity) verifies on-disk registries, simhashes, manifests, and per-tier graphs for validity and cross-tier consistency. It runs at startup, as a migration pre-flight gate, and on demand — surfacing a corrupt or half-written store (including a backup that no longer decrypts under the current daily identity) before it propagates.
Biometric unlocks (Windows Hello, fingerprint, FIDO2) are supported as access conveniences for the daily path only. They are not a recovery mechanism: biometrics unlock a sealed key on specific hardware; they do not regenerate the key on a new device. Any sensible deployment pairs biometric-unlocked daily access with a printed recovery artifact.
For the encryption-lifecycle command reference and startup-gate reset runbook, see DEPLOYMENT.md — Encryption & recovery operations.
ParaMem is a single-admin service. The operator — the person running the server — is responsible for:
- Generating and storing key material. Run
paramem generate-keyto mint the daily identity (stored passphrase-wrapped on this host) and the recovery identity (printed once — save it offline). Do not rely on a single storage location for the only copy of the recovery bech32. - Scoping LAN exposure. Set
PARAMEM_LISTEN_IPto the specific host interface that should accept incoming requests, andPARAMEM_NAS_IPto scope the Windows Firewall rule to the Home Assistant source host. Unset values default to an open posture with a loud startup warning. - Choosing the appropriate auth posture (§5) for the deployment. Enable
mobile_pwa.enabled: true(or run onemint-user-token, which wires the store the same way — after a restart; the store handle is assigned once at server startup, so a mint against a from-scratch, auth-OFF deployment does not flip the already-running server ON) for per-user tokens that carry speaker identity; mint a--scope chattoken for the HA component and an--unattributed --scope admin --force-admintoken for the systemd infrastructure carrier (PARAMEM_API_TOKENin.env). Until the first mint (and the restart that picks it up) the server is usable by any reachable peer for conversational endpoints, but administrative endpoints (/gpu/*,/consolidate,/backup/*, etc.) 403 until a store is configured — a loud startup warning is emitted regardless. - Rotating a compromised token.
revoke-user-tokenthe compromised token,mint-user-tokena fresh one, and update every consumer that reads the old plaintext value:.env/systemd drop-in for the infrastructure carrier, device Settings for a per-user token, the HA component config for its token. - Managing
.envand per-secret files under~/.config/paramem/secrets/with file mode0600and directory mode0700. The server refuses to start if permissions are looser. - Scoping the Home Assistant long-lived access token to a dedicated, minimal-privilege HA user — not to a full admin.
- Keeping
agents.ha_agent_idpointed at a LOCAL HA conversation agent. See §3. A cloud-backed HA agent turns the one hop ParaMem treats as on-premise into unsanitized cloud egress that none of ParaMem's switches can close. - Deciding
cloud.allow_degraded_serving. When the local model becomes unavailable for a reason the operator did not choose (GPU held by another process, insufficient VRAM, a failed adapter reload/apply, a persistent CUDA fault), the ship defaultfalsecloses the cloud leg: HA still answers, and anything HA cannot serve returns a canned limited-mode reply. Setting ittrueaccepts that the household's questions route to a third party during an outage. In that state there is no local model — so no personal verdict and no anonymization are computed, and the current turn is sent to the cloud verbatim. Only the query's own text leaves; the memory store is unreachable, so no stored facts egress. History turns are still drop-gated. Conversation context itself is always assembled server-side from the stored transcript, for every entry point — voice turns are history-eligible exactly like text turns — so no client ever sends history; nothing a client fabricates can enter the model's context or a cloud payload. - Handling backups. A backup that captures the data directory but not the master-key source defeats the encryption.
- Reviewing the cloud-egress classifier exemplar files for the languages the deployment serves. The sanitizer's first-person check is encoder-based with multilingual exemplars under
configs/personal_referent/<class>.<lang>.txt; coverage on a language without dedicated exemplars relies on cross-lingual transfer in the multilingual encoder and may miss idioms or low-resource phrasings. For deployments serving non-English speakers, add a file pair (about_speaker.<lang>.txt+not_about_speaker.<lang>.txt) and verify with a probe set before going live. The same applies toconfigs/sentence_types/for the abstention gate.
The security properties are honest, not aspirational. The following are the limitations an operator should understand before deploying.
-
Adapter probe resistance is limited. An attacker with (a) the adapter weight file, (b) the base model, and (c) knowledge of relevant entity names can extract a meaningful fraction of stored facts through systematic probing. The adapter is opaque to grep but not opaque to a model that asks the right questions. This is inherent to any LoRA-based parametric memory — the knowledge must be accessible to be useful.
-
Weight encryption narrows blob-copy risk but does not close probe surface. ParaMem encrypts the key registry (
registry/key_metadata.json) and, per adapter tier, itsindexed_key_registry.json(SimHash fingerprints live inside this file, not a separate registry) and — under Security ON — the LoRA weight tensors (adapter_model.safetensors) as age envelopes. The registry encryption blocks the systematic extraction path: the indexed-recall template requires knowing the key string (graph17,proc4, …), and without the registry an attacker cannot enumerate keys. Encrypting the weight tensors adds defense-in-depth against an attacker who copies only theadapters/subtree without the key material. What remains: an attacker with (a) the decrypted weights, (b) the base model, and (c) knowledge of entity names can still extract facts through targeted natural-language probing ("what did Alex say about X?"), membership inference, and continued fine-tuning — these require running inference on the weights and are not closed by encryption alone. -
Runtime exposure is identical to RAG. While the server is reasoning over a recalled fact, that fact lives as plaintext in GPU / CPU RAM inside the server process. Any system reasoning over private data has this property; we isolate it to one process behind a local API rather than streaming recalled context to external tools.
-
Extraction-stage cloud enrichment narrows but does not eliminate PII egress, and completeness cannot be independently verified. When
consolidation.extraction_enrichment_provideris set to a cloud provider (default""= disabled), the extraction pipeline runs a local-anonymization pass — the SAME local model that ran extraction, on a second prompt — before sending an anonymized transcript and fact array to the cloud for coreference resolution, compound splitting, and dedup. The local model is the sole classifier: given the operator'ssanitization.scrubvocabulary (default: person name, email address, phone number, physical address, social/professional profile URL), it decides per value whether it is in scope, mints a type-tagged placeholder for it (e.g.Person_1), and returns that{real → placeholder}mapping. There is no code-side entity-type gate downstream of that decision, and no second, independent classifier — this is a deliberate design choice, argued below, not an oversight.The two payload surfaces this pass builds have different guarantees. Facts are anonymized mechanically and deterministically: the script substitutes the model's mapping into
graph.relations' subject/object fields via an exact, edge-aware, case-sensitive substitution primitive — the predicate is never a substitution target. Given a correct mapping, fact substitution is exact and complete; there is nothing left for a post-hoc scan to verify on this surface. Case sensitivity is load-bearing here: it is the only signal separating a person namedBillfrom the common nounbill(an invoice), orWill/will,Mark/mark,Rose/rose. A case-insensitive match would fire on the common noun for any entity-derived name and silently disable cloud enrichment for that user on every session, so the pipeline never performs one.With scrubbing active, self-introduced names are folded onto the anchor before egress — including the chat system prompt. When a speaker introduces themselves in the conversation ("I'm Priya", "call me Priya"), the local anonymizer folds that spoken name onto the speaker's own
speaker{N}anchor before anything crosses the cloud boundary, so it is scrubbed from the extraction-stage payload the same way any other in-scope name is, and it was never present in a chat-egress system prompt to begin with (no speaker name or id ever is — see §3). This is the same fallible single-model classification described in residual (i) below, not a separate guarantee — and it does not apply when person-name scrubbing is itself off (an empty or narrowedscrub, see below), in which case the transcript egresses without this fold like any other unscrubbed content. The anchor itself is the one exemption from the placeholder-shape contract (seearchitecture.mdAD-16).The transcript, by contrast, is authored by the model itself rather than mechanically rebuilt from the mapping — the model rewrites the prose in-context, replacing only the values it placeholdered and leaving everything else unchanged. Prose classification is context-dependent in a way the case-sensitive fact fields are not — "Will" the name and "Will I?" the verb are identical bytes — so only the model, holding sentence context, can make that call; there is no mechanical exactness guarantee on this surface, and no runtime check re-verifies it.
The genuine residual risk is, stated plainly: (i) classification miss — the model fails to label an in-scope value as sensitive. Not code-recoverable once it has egressed. Mitigated by prompt design (a canonical vocabulary and few-shot boundary examples) and, before ship, by the
/calibrate/extractrecall gate over labeled PII (names and attribute-borne PII) — never by a runtime re-check. For instance-level doubt (the category IS in scope; is this particular value really an instance of it), the prompt's posture is the opposite of leaving it verbatim: it instructs placeholdering the value, since under-scrubbing sends real PII to the cloud and can never be undone — over-scrubbing, not under-scrubbing, is the accepted failure direction there. It is still a genuine omission risk in a different way: egress fails closed only on one inconsistent shape — the model named something (a non-empty mapping) over a non-empty input transcript but returned a missing/empty rewrite. An empty/missing rewrite is otherwise legitimate — nothing to rewrite, because the input transcript was itself empty or because the model's own mapping came back empty — and egress proceeds on the ORIGINAL transcript in that case — the same "nothing in scope" verdict this document already treats as legitimate whenever a returned mapping is itself empty, applied consistently now rather than blocked by a model omission that carried no information either way. It proceeds whenever the anonymizer runs successfully, including with a mapping that is empty (a legitimate "nothing in scope" verdict) or one that names only some of the in-scope content — there is no guard that checks a returned mapping's completeness against the actual content. A mapping that names 2 of 10 in-scope persons and silently drops the other 8 still egresses; the remaining 8 names go out verbatim. (ii) Transcript-authoring fidelity — because the model rewrites the prose rather than the script substituting it, a fidelity failure (content dropped or reworded beyond the intended substitutions) is possible in principle; this is verified offline at the same calibration gate (transcript fidelity, not just recall), not at runtime. (iii) Single-model classification with no independent second opinion. This is deliberate: a second, code-side detector cannot resolve the homograph problem above — only the model holds the sentence context needed to tell "Will" the name from "Will I?" the verb. And no code-side rule can express thescrubvocabulary in the first place:physical address(scrubbed) andcity(deliberately left verbatim, so the cloud can still reason about places) are the same graph entity type, so a rule keyed on entity type would either over-scrub places or under-scrub addresses — there is no type-level line to draw. Building a rule-based backstop would mean re-authoringscrubas a second, closed code-side vocabulary, which is the exact artifact this design avoids everywhere.Free-form secrets (API keys, passwords, tokens) are not a
scrubcategory and are not scrubbed by this pass. Place names and organization names are deliberately left verbatim under the defaultscrubso the cloud can still reason about them (e.g. "What's a good restaurant in Berlin?"). Document ingestion goes through the same pipeline; for documents that may contain machine credentials, keepextraction_enrichment_provider=""or scrub credentials before ingest.scrubis configurable viasanitization.scrub; narrowing or broadening it is a privacy-vs-utility tradeoff the operator makes consciously, per deployment. That residual is why the ship default iscloud.enabled: false— the one master switch for all cloud egress (refinement_enrichment: "off"for the graph-tier pass below): the operator's only hard guarantee is the off switch.The graph-tier enrichment pass (
paramem.training.graph_enrich.enrich_graph, dispatched viaGraphTierRefiner.run_enrichment, the post-merge cross-session second-order-relation pass) runs the SAME anonymize → cloud → de-anonymize chain, throughparamem.cloud(anonymize.py/deanonymize.py) — the one round-trip contract every cloud-egress path shares — over the cumulative cross-session graph — it is not a separate, unprotected cloud call, and it carries the identical single-model-classification limitation described above. The cumulative fold graph carries no reliable entity types of its own (registry-derived relations have none), and this pass does not attempt to derive one: before each chunk's cloud call it runsanonymize(the SAME chain session-tier extraction uses, over the chunk's triples with no transcript), scoped against the samescrub. Because the local model's mapping keys are surface strings it produced independently of the fold graph's own canonical node-key text, each key is first reconciled to the graph's node identities viacanonical()internally — a re-cased, separator-varied, or diacritic-varied key still resolves against the node it names, with the model's own placeholder preserved verbatim; an entry that matches no node in its chunk (or an ambiguous multiple) is dropped rather than guessed. This tier fails closed when the local anonymizer itself fails to parse (mirroring the extraction stage's guard); a local mapping that comes back empty is a legitimate "nothing in scope" verdict and proceeds, exactly like the extraction stage. The one additional guard here is narrower and applies at the level of the individual fact, not the whole chunk: when the local anonymizer names something but reconciliation onto the chunk's actual node keys drops all of it for content that is real (non-speaker), that residual is a classification/identity failure rather than evidence of out-of-scope content — content whose anonymization cannot be verified this way is withheld from the cloud call rather than sent unmasked, while the rest of that chunk's content, having classified successfully, still reaches the chunk's one cloud call. Only when nothing in a chunk survives this check is the chunk's cloud call itself skipped, counted inprivacy_skipped_chunks. The withheld direction is always the safe one: this guard only ever removes content from what egresses, never adds unauthorized content to it. Operator opt-out is an explicit privacy-posture change at this tier: an emptysanitization.scrubshort-circuits before any local-anonymizer call and the chunk's triples egress to the cloud VERBATIM — this tier previously always ran the local anonymizer regardless ofscrub, so an operator who had setscrub: []expecting the prior (accidentally-still-scrubbing) behavior now gets true unmasked egress at this tier; the fix aligns it with the session tier's and chat-egress's existing opt-out contract, but it is a real behavior change operators relying on the old default should be aware of. On the RESPONSE side, no unresolvable/orphan placeholder token ever reaches the graph — that fail-closed property is unchanged — but the unit it is enforced against is the individual item, not the whole chunk delta: every relation this tier's cloud response returns is treated as new (there is no local baseline here to fall back to), sodeanonymize_facts's residual sweep drops only the relation(s) actually carrying an unresolvable token, counted indropped_relations; asame_aspair with either member unresolved is dropped the same way, per pair, viadeanonymize_text. This is distinct fromprivacy_skipped_chunks, which fires on the OUTBOUND side before any cloud call is made —dropped_relationsis the fail-closed backstop on what comes back. Neither outbound guard is a totality check on the OUTBOUND content: a chunk (or the surviving content within it) in which the model names only some of the real content, or whose mapping only partially survives reconciliation, still passes, and the unnamed/dropped names egress verbatim — a chunk that partially egresses (part of its content sent, a failed part withheld) is a normal outcome, not a fault condition. The anonymousspeaker{N}handle is never a mapping KEY at either tier — a code-level guard drops any such entry regardless of tier. Unlike the session tier, which can fold a self-introduced name onto the anchor as a mapping VALUE, this tier's prompt carries no fold rule at all, so the handle simply never appears as a value here either — it reaches the payload bare. Accepted consequence: person-levelsame_ascoreference (recognizing two surface forms of the same person, e.g. a nickname or honorific variant) can no longer be detected by the cloud judge once both surfaces are opaque tokens — the name-surface signal that coreference depends on is gone for people under the defaultscrub. Coreference for organizations, places, and other non-person entities is unaffected (those surfaces stay verbatim under the defaultscrub). This pass runs at the full consolidation fold only — an interim cycle never reaches it, regardless ofrefinement_enrichment/cloud.enabled— so strictly less content leaves the process than the prior (interim-reachable) posture; this is a reduction of the egress surface, not a new one, and introduces no new residual. -
Cloud escalation can leak. The sanitizer applied before escalation has two arms: a known-entity scrub (substitution against the speaker's graph entities) and a self-reference gate (encoder-based "is this about the speaker?" classifier with multilingual exemplars under
configs/personal_referent/, falling back to an English token-set when the encoder isn't loaded). The self-reference gate classifies purely from text content — it does not require a resolvedspeaker_idto fire. On the relay path (no speaker resolved at all), history is always empty, so this is scoped to the current turn only: a personal interrogative is caught by a dedicated no-identity short-circuit before HA or cloud is even tried, and a personal declarative is governed by the samesanitization.cloud_modepolicy every other leg applies (with a live local model to anonymize it, when one is loaded). Residual risk: the encoder operates on lexical/semantic shape; the local model can still rewrite a query in a form that embeds a personal fact while passing the gate. Cross-lingual transfer in the multilingual encoder lifts coverage past the languages with explicit exemplars (en/de today) but is not guaranteed for every locale or idiom — adding<class>.<lang>.txtexemplar files for production languages tightens the bound. -
LAN authentication is operator-provisioned. When no per-user token store is wired (no
mobile_pwa.enabled: trueand no prior mint), conversational REST endpoints are accessible to any LAN peer (Security OFF posture); administrative endpoints remain 403 fail-closed until a store is configured. Wyoming STT / TTS ports do not support protocol-level auth at all and rely on network-layer scoping (firewall rule) for access control. -
No auth rate-limiting. The bearer-token layer does not implement brute-force throttling. The design relies on high-entropy opaque tokens (infeasible to guess) and network-layer scoping (Tailscale / LAN) rather than rate limiting. If a token leaks, revoke it immediately.
-
Key loss is total. No backdoor, no recovery service, no escrow. The recovery key is the backdoor; losing it is losing the data.
-
Biometrics are convenience, not security. Biometric unlock binds to specific hardware and specific OS sessions. A new device or a TPM clear invalidates the daily path. Biometrics cannot be rotated if compromised and are not cryptographic secrets.
-
Supply chain pinning is not auditing. Dependency versions are pinned in
pyproject.toml, including the CUDA-specificbitsandbytesdevelopment wheel required for RTX 50-series hardware. Pinning prevents silent updates but does not constitute a reviewed supply chain. -
Voice embeddings are biometric data. Under GDPR Article 9 (EU) voice embeddings are special-category personal data. They are encrypted at rest under Security-ON; losing the recovery key is privacy-protective for this data, but sharing the key exports biometrics.
-
/healthis unauthenticated. The/healthendpoint is exempt from token checks by design (HA binary sensors and pollers need it without credentials). It returns only liveness state, not personal data.
The biggest limit — that the Python package is not attested and can be trivially tampered with by an operator-level attacker — is named in the top-of-document disclaimer, not repeated here.
Please do not open a public GitHub issue for suspected security vulnerabilities.
Contact: Tobias Preusser — tobias.preusser75@gmail.com.
When reporting, include:
- Affected version / commit
- Deployment configuration (Security ON / OFF, cloud enabled / disabled, HA connected)
- A clear reproduction or the minimum data needed to reason about the issue
ParaMem is research software maintained by a single author. There is no formal SLA for response times. Responsible disclosure is appreciated; public coordination will be on a best-effort basis.
README.md— project overview, configuration, setupDEPLOYMENT.md— installation, configuration, encryption lifecycle, token management, backup & migrationparamem/server/auth.py,paramem/server/user_tokens.py,paramem/server/secret_store.py— runtime entry points for the boundaries described above- The internal hardening plan and empirical probe results live outside the public repository; enquiries should be routed through the disclosure channel in §10.