This document is an honest statement of what Castra protects, how, and what it does not. It is intended for operators evaluating Castra for a real deployment — not as marketing copy. Where a property is enforced by code, the file path is cited so the claim can be verified. Where a gap exists, it is named explicitly with a recommended mitigation.
Castra is a local-only, single-host CLI that maintains a sovereign identity, an RBAC-governed task graph, and a hash-linked audit trail for AI-agent collaboration on a developer machine. The core protections are:
- Sovereign identity — a single Ed25519 keypair per host, generated on
castra init -g, bound to a device record, and used to sign challenge-response payloads for--sovereigninvocations. - RBAC enforcement — every command declares an
AllowedRoles()list; the router rejects mismatches beforeExecuteis reached, with secondary inline guards on sensitive verbs. - Audit chain — every state-mutating action writes a row into the
logstable whosehashcoversprev_hash, forming a per-project chain that is detectably tamper-evident undercastra log verify. - At-rest confidentiality of
castra.db— the workspace database is encrypted page-by-page via a custom SQLite VFS keyed off the device private key.
Castra is not:
- A hardened multi-tenant SaaS. The trust unit is a single host, a single device key, a single operator (or a small team that already trusts each other at the OS level).
- A defence against host compromise. Anyone with read access to
~/.castra/device.keyand the device ID can derive every key Castra uses. - A defence against an adversarial sovereign-key holder. The sovereign role is a root-equivalent capability inside Castra by design; protecting the key is the operator's responsibility.
- A multi-purpose network service. Castra has one opt-in network surface:
castra exocortex daemon start(internal/commands/surface/exocortex/daemon_start.go, registered viainternal/commands/exocortex.goatinternal/commands/registry.go:285) binds an mTLS listener on127.0.0.1:9437(internal/exocortex/config.go:20,internal/exocortex/server.go:212-214). It is loopback-only by default and gated by mutual TLS (CA-signed client certs) — same-host non-Castra processes cannot connect without a valid client cert. The "no network surface" property holds only when the daemon is not started; see §3 for the threat model when it is.
| Property | Primitive | Code |
|---|---|---|
| Device identity & sovereign challenge-response | Ed25519 (sign / verify) | internal/crypto/keygen.go, internal/crypto/signing.go, internal/device/device.go, internal/sovereign/verify.go |
castra.db at-rest encryption |
AES-256-CTR per SQLite page via custom VFS | internal/vfs/vfs.go, invoked by castra db encrypt (internal/commands/surface/db/db.go) |
iris.db at-rest encryption |
AES-256-CTR per SQLite page via the iris-VFS (castra-iris-vfs) |
internal/vfs/vfs.go (RegisterNamed), internal/irisdb/encrypted.go, invoked by castra --sovereign iris db encrypt (internal/commands/surface/iris/db_encrypt.go) |
| Backup payload encryption | AES-256-GCM, 12-byte nonce, sealed bytes = nonce || ciphertext+tag |
internal/commands/surface/db/backup_crypto.go (sealAESGCM, OpenAESGCM) |
castra.db AES key derivation |
HKDF-SHA256(IKM=privKey.Seed(), Salt=deviceID, Info="castra-db-v1") |
internal/dbkey/dbkey.go (DeriveDBKey) |
iris.db AES key derivation |
HKDF-SHA256(IKM=privKey.Seed(), Salt=deviceID, Info="castra-iris-db-v1") — domain-separated from castra.db |
internal/dbkey/dbkey.go (DeriveIrisDBKey) |
| Log-chain HMAC key derivation | HKDF-SHA256 with Info="castra-log-hmac-v1" (domain-separated) |
internal/dbkey/dbkey.go (DeriveHMACKey) |
| Encryption sentinel | HMAC-SHA256(key=dbKey, msg=deviceID), hex-encoded, 0600 | internal/sentinel/sentinel.go |
| Audit chain | SHA-256 over seq|project_id|session_id|role|action|entity_type|entity_id|msg|prev_hash; each entry's prev_hash = previous entry's hash |
internal/log/log.go (computeHash, Verify); HMAC sig variant via AddSigned / VerifySigned / VerifyStrict |
| Constant-time comparison | hmac.Equal for sentinel verify, sovereign payload uid compare, persona-ack nonce compare |
internal/sentinel/sentinel.go:74, internal/crypto/signing.go:71, internal/commands/surface/persona/ack.go:52 |
The VFS uses a deterministic per-page nonce (page number in bytes 0..7, zero in 8..11). This is required for SQLite's idempotent page-write contract under WAL recovery; it is correct for AES-CTR provided the key never changes within a database (which is enforced by the sentinel mismatch check). The 32-byte AES key is zeroed in the calling slice immediately after vfs.Register returns; subsequent operations use the C-side copy held by the registered VFS.
castra log verify recomputes every hash in a project's chain and reports the first broken link. Cross-project splicing is prevented because project_id is the second canonical field — moving a row between chains produces a hash mismatch.
These are real and operator-relevant. They are listed here so you can make an informed deployment decision rather than discover them in production.
iris.dbVFS encryption parity — CLOSED. The Iris exocortex database (~/.castra/iris.db— episodic memory, identity blocks, audit log) is now routed through a domain-separated SQLite VFS (castra-iris-vfs) that mirrorscastra-vfs's AES-256-CTR per-page construction. Operators migrate an existing plaintextiris.dbwithcastra --sovereign iris db encrypt; greenfield deployments encrypt on first run of the same verb. The HKDF info label iscastra-iris-db-v1(vs.castra-db-v1forcastra.db), so a compromise of one DB's key does not extend to the other. The HMAC sentinel pinning the iris key to the device lives at~/.castra/iris.sentineland is included incastra db backup/ restored bycastra db restore. Seeinternal/irisdb/encrypted.goandinternal/commands/surface/iris/db_encrypt.go. Filesystem-level encryption (LUKS / FileVault) remains a sound additional layer but is no longer the only gate between disk-read and Iris content.device.keyis at rest unencrypted. Stored as PEM with mode0600in~/.castra/device.key(internal/crypto/keygen.go:43). The threat model is symmetric to an SSH private key: anything with read access to the file (root, a backup script, a misconfigured cloud-sync daemon) can derive every Castra key. For high-security deployments, hold the device key in a hardware-backed keystore (HSM, YubiKey, TPM) and present it to Castra only at session start.- No built-in rate limiting on
persona activate/persona ack. A misbehaving local client can hammer the activation/ack path. (Theinternal/exocortex/ratelimitlimiter applies to the Iris exocortex API surface, not persona-session creation.) For multi-user deployments, front Castra with a reverse proxy or supervisor that enforces a per-source rate limit. - Single-tenant trust model. All operators sharing a single
castra.dbshare authority within that DB. RBAC enforces role separation (architect cannot ship code, doc-writer cannot approve work), but it does not enforce user separation: two humans both holding architect tokens are indistinguishable to the audit trail beyond their session token. TheMultiUser-creator-keystask in this milestone introduces per-user creator-key provisioning to address this; once shipped, read its docs and revisit this section before a multi-user rollout. - Optional exocortex mTLS daemon expands local-host surface when started.
castra exocortex daemon startbinds127.0.0.1:9437with mutual-TLS client-cert auth (internal/exocortex/server.go:212-214,internal/exocortex/config.go:20). The daemon is opt-in at the cert-provisioning layer, not at the session-activation layer: without provisioned mTLS certs,EnsureDaemonRunning(internal/exocortex/lifecycle.go:147) is a best-effort no-op and returns without binding the port. When certs are present, however, the daemon co-cycles automatically with persona sessions —castra persona activateinvokesEnsureDaemonRunning(internal/commands/surface/persona/activate.go:84), which spawnscastra exocortex daemon startas a detached subprocess viaDefaultDaemonStarter(internal/exocortex/lifecycle.go:178);castra persona deactivatecallsStopDaemon(internal/exocortex/lifecycle.go:199) when the last active session ends. The operational consequence: an operator who has run cert provisioning is implicitly opting into the loopback mTLS daemon for every persona session on that install — the listening port appears whenever any role activates a session, not only when an operator runscastra exocortex daemon startexplicitly. When the daemon is running, the relevant local-host attack surface includes: certificate provisioning trust path (whoever issues client certs from the configured CA can talk to the daemon), opcode handlers (currentlymemory.write), and listening-port DoS / FD exhaustion. Loopback bind blocks remote attackers without a same-host foothold; mTLS blocks same-host non-Castra processes. Operators evaluating Castra against a strict "no network" baseline should either leave certs un-provisioned (which keepsEnsureDaemonRunninga no-op) or scope their host-firewall and process-supervision posture for the surface listed above on the assumption that the daemon will be running for the duration of every active persona session. - Creator-key deletion-downgrade is closed (SEC-001). A substrate that has run
castra --sovereign creator-key initcarries a~/.castra/creator.provisionedsentinel (mode 0644, no secret content) alongsidecreator.pub. The trust-anchor resolver (internal/creatorkey/creatorkey.go) refuses to fall back to the compiled-in defaultCreatorPubKeywhenever the sentinel is present butcreator.pubis missing or unreadable — it returnsErrCreatorPubMissingAfterProvisionand emits an unambiguous warning to stderr. This blocks the attack where an adversary holding the private key matching the compiled-in constant deletes a per-usercreator.pubto silently downgrade the trust anchor. Greenfield substrates (no sentinel, nocreator.pub) continue to use the compiled-in default unchanged. If you ever need to intentionally decommission a per-user trust anchor and accept the downgrade, removecreator.provisionedexplicitly — never justcreator.pub. iris.dbVFS encryption parity is now closed; filesystem-level encryption (LUKS / FileVault) remains recommended as defence-in-depth alongsideiris db encrypt, not as the sole control.
| Role | One-line jurisdiction |
|---|---|
architect |
plans milestones, sprints, tasks; cannot write implementation code |
senior-engineer |
core implementation, build & test; cannot create milestones |
junior-engineer |
scoped tasks assigned by architect; cannot plan |
qa-functional |
test plans and review findings; cannot ship feature code |
security-ops |
security audit and hardening findings; cannot ship feature code |
doc-writer |
technical documentation; cannot modify implementation logic |
designer |
UI/UX specifications and assets; cannot ship backend code |
Iris (the orchestrator) is not an activatable session role. It intercepts unrouted input by default; castra persona activate --role iris is rejected.
The --sovereign flag is gated by an Ed25519 challenge-response in internal/sovereign/verify.go. Verification:
- Decodes
creator_uid,challenge_nonce,signaturefrom hex. - Constant-time compares
creator_uidagainst the compiled-in trusted creator pubkey (internal/creatorkey). ed25519.Verify(pubKey, nonce, sig).- Replay check: rejects if
nonce_hexalready exists insovereign_nonce_logwithin the TTL.
Per-command sovereignGuard(ctx, "<verb>") calls in internal/commands/surface/db/db.go, internal/commands/log.go, internal/commands/device_info.go, etc., reject non-sovereign callers with no further detail. Every sovereign invocation is logged to the audit chain with role = "sovereign" before the command executes.
castra persona activate --role <role> returns a base-36 session token (crypto/rand-sourced, uniqueness-checked against the sessions table) and a one-shot nonce. The session is locked until castra persona ack --nonce <nonce> --session <token> succeeds; nonces are compared via hmac.Equal to prevent timing-oracle leakage (internal/commands/surface/persona/ack.go:52). All commands except persona ack and persona deactivate are rejected on a session whose nonce has not been confirmed.
Every state mutation is logged via castra log add (or auto-logged by the calling verb) with role, session token, timestamp, action, entity type, and entity ID. The chain is hash-linked: each entry's hash covers the previous entry's hash, so any tampering is detectable by castra log verify --project <id>. All entries written on Castra v4.6+ are HMAC-signed via AddSigned / VerifySigned using an HKDF-derived device key — HMAC is not optional for post-v35 chains.
Two verification modes are available:
castra log verify [--project <id>]— default mode: verifies hash-chain integrity and HMAC signatures where present; accepts pre-v35 entries with emptyhmac_sigas hash-chain-only (compatible with chains that span the v35 migration boundary).castra log verify --strict [--project <id>]— strict mode: rejects any entry whosehmac_sigis empty, closing the null-HMAC forgery gap (an adversary with direct DB write access can rebuild a valid SHA-256 hash chain without knowing the device key, then clearhmac_sigto bypassVerifySigned's compatibility branch;--strictprevents this). Use strict mode on any chain whose entries were all written post-v35.castra --sovereign log verify-all [--strict]— verifies all project chains in one pass, with optional strict mode.
Strict mode requires the device HMAC key to be available (device must be initialised on the current machine).
Sovereign-only export is available via castra log export --project <id> (internal/commands/log.go:269). The pre-v3.0.0 audit_log table coexists for legacy reads — the current castra audit --break-glass query (internal/commands/audit.go) surfaces architect override events from that table.
castra --sovereign db backup --out <path> [--encrypt]
Produces an uncompressed tarball containing manifest.json plus inner files (castra.db, optional iris.db). The manifest carries SHA-256 digests of every inner file plus a timestamp-independent payload_sha256 (sorted concatenation of inner file hashes — stable across re-runs given identical content). With --encrypt, the tarball bytes are sealed with AES-256-GCM under a 32-byte key derived from the device identity (same KDF used by db encrypt); the on-disk format is nonce(12) || ciphertext+tag. See internal/commands/surface/db/backup.go.
Restore.
castra --sovereign db restore --from <tarball> [--encrypted-key <hex>] [--force]
Implemented in internal/commands/surface/db/restore.go (RestoreCommand.Execute at line 69, RunRestore at line 102; registered at internal/commands/registry.go:106). The flow is sovereign-gated and verification-first: it (1) reads the tarball, (2) attempts plain-tar parse and falls back to AES-GCM open with the device-derived key (or --encrypted-key hex if supplied), (3) recomputes payload_sha256 over the inner files in canonical sorted order and aborts on mismatch, (4) refuses to overwrite an existing destination unless --force is passed, (5) writes each inner file to <dst>.restore.tmp, fsyncs, then atomically renames into place with mode 0600. On any error mid-flow, tmp files are cleaned and originals are left intact.
Do not bypass the verb. Hand-rolling unseal + untar + atomic rename skips the manifest digest check, the sovereign gate, and the documented atomic-replace ordering — every one of those exists for a reason.
Backup-key handling. The encryption key is derived from device.key + device ID via HKDF-SHA256 (internal/dbkey/dbkey.go, loadDeviceAESKey at internal/commands/surface/db/backup_crypto.go:91). The key is never written to disk and is not surfaced by any GA-1.0.0 verb. If the device key is destroyed, the backup is unrecoverable on that machine — restore on a different device requires either restoring device.key to the new host first (treat device.key as part of the backup-set, transmitted over a separate secure channel) or independently deriving the key via the public dbkey.DeriveDBKey function and passing it to --encrypted-key <hex>. The latter is an advanced path; there is no supported CLI emit for the derived key in v1.0.0. The operator must keep device.key separately from the encrypted backup — a backup encrypted with a key stored next to it offers no protection.
Operations cadence. Test restore quarterly, against a real backup, in an isolated working directory. A backup that has never been restored is a hypothesis, not a recovery plan.
Open a GitHub issue with the security label on iris-castra/castra. Include reproduction steps and an impact assessment.
There is currently no dedicated security@ mailbox. If you operate Castra in an environment where coordinated disclosure over email is required, add one to your fork's SECURITY.md and route it to your incident-response process — Castra does not impose one.