Skip to content

Security: amangsingh/castra

Security

docs/SECURITY.md

Castra Security Posture (GA-1.0.0)

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.

1. Threat model

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 --sovereign invocations.
  • RBAC enforcement — every command declares an AllowedRoles() list; the router rejects mismatches before Execute is reached, with secondary inline guards on sensitive verbs.
  • Audit chain — every state-mutating action writes a row into the logs table whose hash covers prev_hash, forming a per-project chain that is detectably tamper-evident under castra 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.key and 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 via internal/commands/exocortex.go at internal/commands/registry.go:285) binds an mTLS listener on 127.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.

2. Cryptographic foundations

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.

3. Known gaps

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.db VFS 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 mirrors castra-vfs's AES-256-CTR per-page construction. Operators migrate an existing plaintext iris.db with castra --sovereign iris db encrypt; greenfield deployments encrypt on first run of the same verb. The HKDF info label is castra-iris-db-v1 (vs. castra-db-v1 for castra.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.sentinel and is included in castra db backup / restored by castra db restore. See internal/irisdb/encrypted.go and internal/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.key is at rest unencrypted. Stored as PEM with mode 0600 in ~/.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. (The internal/exocortex/ratelimit limiter 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.db share 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. The MultiUser-creator-keys task 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 start binds 127.0.0.1:9437 with 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 activate invokes EnsureDaemonRunning (internal/commands/surface/persona/activate.go:84), which spawns castra exocortex daemon start as a detached subprocess via DefaultDaemonStarter (internal/exocortex/lifecycle.go:178); castra persona deactivate calls StopDaemon (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 runs castra exocortex daemon start explicitly. 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 (currently memory.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 keeps EnsureDaemonRunning a 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 init carries a ~/.castra/creator.provisioned sentinel (mode 0644, no secret content) alongside creator.pub. The trust-anchor resolver (internal/creatorkey/creatorkey.go) refuses to fall back to the compiled-in default CreatorPubKey whenever the sentinel is present but creator.pub is missing or unreadable — it returns ErrCreatorPubMissingAfterProvision and 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-user creator.pub to silently downgrade the trust anchor. Greenfield substrates (no sentinel, no creator.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, remove creator.provisioned explicitly — never just creator.pub.
  • iris.db VFS encryption parity is now closed; filesystem-level encryption (LUKS / FileVault) remains recommended as defence-in-depth alongside iris db encrypt, not as the sole control.

4. Access control model

Roles

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.

Sovereign override

The --sovereign flag is gated by an Ed25519 challenge-response in internal/sovereign/verify.go. Verification:

  1. Decodes creator_uid, challenge_nonce, signature from hex.
  2. Constant-time compares creator_uid against the compiled-in trusted creator pubkey (internal/creatorkey).
  3. ed25519.Verify(pubKey, nonce, sig).
  4. Replay check: rejects if nonce_hex already exists in sovereign_nonce_log within 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.

Session protocol

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.

5. Audit trail

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 empty hmac_sig as hash-chain-only (compatible with chains that span the v35 migration boundary).
  • castra log verify --strict [--project <id>] — strict mode: rejects any entry whose hmac_sig is 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 clear hmac_sig to bypass VerifySigned's compatibility branch; --strict prevents 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.

6. Backup and recovery

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.

7. Reporting vulnerabilities

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.

There aren't any published security advisories