Unauthorized DB reads at rest
The database at ~/.castra/castra.db is encrypted with AES-256-CTR on a per-page basis using a key derived from the device Ed25519 private key. Opening the file with plain sqlite3 returns SQLITE_NOTADB. Without device.key and the correct deviceID, the data is unreadable.
Log tampering
Each project maintains its own independent SHA-256 hash-linked chain partitioned by project_id. Every entry includes prev_hash (the hash of the preceding entry within the same project chain) and hash (the hash of its own canonical fields, with project_id as the second canonical field). Modifying any entry invalidates all subsequent hashes in that project's chain. Cross-project chain splicing is cryptographically prevented — an entry copied from project A into project B produces a hash mismatch on verify because the canonical hash includes the originating project_id. castra log verify --project <id> traverses that project's chain in ascending sequence order, recomputes every hash, and reports the first broken link.
Session replay
Session tokens are generated via crypto/rand and enforced for uniqueness against the sessions table at creation time. The session validation middleware (Gate 2) verifies the token on every command that does not implement BypassAuth. A deactivated or expired session cannot be reused.
Role impersonation
The PersonaCompliant linter (Gate 3) validates the active session's role against the command's AllowedRoles() list before any execution. On failure, a persona_non_compliance entry is written to the audit_log and the command is rejected. Role is stored in the session row, not in the session token, so there is no token-level role claim to forge.
Break-glass abuse
The architect role can bypass QA and Security approval gates. Every bypass is recorded explicitly via the qa_bypassed and security_bypassed boolean columns on the affected task row. These columns are permanent — they are never reset by subsequent approvals. The TUI break-glass view surfaces them for post-incident review. There is no silent override path.
Symlink attacks in project context
When printing project context, Castra rejects symlinks using entry.Type().IsRegular() checks. A symlink pointing outside the project root will not be followed. (CWE-22)
Token leakage in the terminal UI The session token is redacted in the TUI status bar. The full token value is never rendered to the terminal in normal operation. (CWE-200)
Key material residency in memory
The 32-byte AES-256 key slice derived by DeriveDBKey is explicitly zeroed after VFS.Register() completes. The key does not persist in memory beyond the DB open call. (CWE-316)
Physical access or root compromise
If an attacker has access to ~/.castra/device.key and the deviceID from the devices table, they can derive the DB encryption key using the same HKDF-SHA256 construction Castra uses. The DB encryption provides protection for a DB file in transit or on a stolen disk, not against a compromised operating system account.
Memory forensics after compromise The key is zeroed after use, which removes it from the normal heap lifetime. However, OS-level memory dumps, swap files, or kernel-level introspection tools are outside the scope of Castra's threat model.
Network attacks Castra has no network surface. It is a local-only binary that operates on a local file. There are no listening sockets, no RPC endpoints, and no HTTP APIs. Network-level threats do not apply.
On first castra init -g, the device package generates an Ed25519 keypair using crypto/rand.Reader via ed25519.GenerateKey. The keypair is written to ~/.castra/ as PEM-encoded files:
| File | Permissions | PEM Type |
|---|---|---|
device.key |
0600 | ED25519 PRIVATE KEY |
device.pub |
0644 | ED25519 PUBLIC KEY |
The ~/.castra/ directory is created with 0700 permissions. A device UUID (UUIDv7) is assigned and stored in the devices table. The public key fingerprint (base64-encoded public key) is stored in devices.public_key for identification purposes.
The device identity is immutable after initialization. castra init -g --force regenerates the keypair, which invalidates any previously encrypted database.
"castra-vfs" is a custom SQLite VFS registered via modernc.org/libc and modernc.org/sqlite/lib. It wraps the platform default VFS (unix on macOS/Linux) so that every page read and written to the database file is transparently decrypted or encrypted.
The VFS is registered by name. Databases are opened via the URI parameter ?vfs=castra-vfs.
Every full SQLite page is encrypted with AES-256 in CTR mode. AES-CTR is used because it is seekable — each page can be independently decrypted without reading prior pages, which matches SQLite's random-access I/O model.
Nonce construction: Each page uses a unique, deterministic 12-byte nonce:
nonce[0:8] = little-endian uint64(pageNumber) // 1-based
nonce[8:12] = 0x00 0x00 0x00 0x00
The page number is computed as (offset / iAmt) + 1 where iAmt is the I/O size. Because the page number is included in the nonce, two pages with identical content will encrypt to different ciphertext. The nonce is deterministic (no random component) because SQLite requires idempotent page writes for WAL safety — a re-encrypted page must match the previously encrypted page.
WAL passthrough: I/O smaller than 512 bytes (the minimum SQLite page size) bypasses encryption. This covers WAL headers and journal headers, which SQLite must be able to parse during recovery without the VFS key being loaded.
The database encryption key is derived on demand via dbkey.DeriveDBKey:
HKDF-SHA256(
IKM = privKey.Seed(), // 32-byte entropy seed from the Ed25519 private key
Salt = []byte(deviceID), // device UUID from the devices table
Info = []byte("castra-db-v1")
) → 32-byte key
privKey.Seed() is used as the IKM rather than the full 64-byte privKey because the full Ed25519 private key is seed || publicKey, meaning the second 32 bytes carry no additional entropy. Using only the seed maximizes IKM entropy density.
The info string "castra-db-v1" is a context binding string. Its presence in the HKDF derivation means that a key derived for one application context cannot be reused in another even if the same keypair and device ID are present.
key, _ := dbkey.DeriveDBKey(privKey, deviceID)
vfs.Register(key)
for i := range key {
key[i] = 0
}The key slice is explicitly set to zero bytes immediately after vfs.Register() completes. After this point, the AES key does not exist anywhere in the process heap as a live slice. (CWE-316)
The encrypted database file is created with 0600 permissions. The castra db encrypt --sovereign command creates a new encrypted database at a temporary path, applies all 26 migrations, copies all data row-by-row (no ATTACH DATABASE — that would route the plaintext source through castra-vfs, producing SQLITE_NOTADB), checkpoints and closes the destination, then performs an atomic os.Rename to the final path.
Each project has its own independent chain. project_id is the second canonical field in the hash, making each chain cryptographically bound to its project — an entry cannot be transplanted between chains without detection.
Every log entry's hash is computed over a pipe-separated canonical string:
seq|project_id|session_id|role|action|entity_type|entity_id|msg|prev_hash
The pipe separator (and the original null-byte separator used inside crypto.ComputeHash) eliminates hash extension attack ambiguity between adjacent fields. The canonical form is deterministic — identical inputs always produce the same hash.
seq is per-project, starting at 1. The first entry in each project's chain has prev_hash = "". Every subsequent entry's prev_hash must equal the hash of the entry at seq - 1 within the same project_id. castra log verify --project <id> detects:
- Hash mismatches (entry data was modified)
- Prev-hash linkage breaks (entries were reordered or spliced)
- Sequence gaps (entries were deleted)
- Cross-project splicing (project_id mismatch in canonical hash)
Add() is wrapped in a SQLite transaction. The MAX(seq) read and INSERT are atomic, preventing duplicate seq values under concurrent writes. A UNIQUE INDEX on (project_id, seq) enforces uniqueness at the DB level.
Sovereign invocations are logged with project_id = "", session_id = NULL, and role = "sovereign" — they form their own system chain, isolated from all project chains.
Session tokens are generated by token.GenerateUnique:
- Character set:
0123456789abcdefghijklmnopqrstuvwxyz(base-36) - Length: configurable, default is
token.DefaultLength - Source:
crypto/randvia/dev/urandom - Uniqueness: verified against the
sessionstable with up to 10 retries before error
Session tokens are short enough for practical use in a terminal but carry sufficient entropy for local-only authentication. They are not designed to resist brute-force over a network — Castra has no network surface.
Sovereign audit trail: Every --sovereign invocation is logged to the logs chain with session_id = NULL and role = "sovereign" before the command executes. The sovereign path cannot be invoked silently.
Role enforcement operates at three layers:
-
AllowedRoles()router check (Gate 3 — PersonaCompliant): The router checks the session's role against the command's declared allowed roles beforeExecuteis called. This is the primary enforcement layer. -
Inline
Execute()guard: Some commands perform an additional inline role check insideExecuteitself, particularly where the AllowedRoles list at the router level was removed to ensure the inline check produces the correct error message (see v3.0.7 fix forSprintRemoveDepCommand). Both layers may be present simultaneously. -
Break-glass audit: The architect role can bypass QA and Security approval gates. Each bypass sets
qa_bypassed = 1orsecurity_bypassed = 1on the task. These columns are never cleared and are always visible incastra task viewoutput and the TUI.
| CWE | Title | Mitigation |
|---|---|---|
| CWE-22 | Path Traversal | entry.Type().IsRegular() check in the project context printer rejects symlinks before following them |
| CWE-200 | Exposure of Sensitive Information | Session token is truncated/redacted in the TUI status bar; the full token is never rendered to the terminal in normal operation |
| CWE-316 | Cleartext Storage of Sensitive Information in Memory | The 32-byte AES key slice is explicitly zeroed (key[i] = 0 for all i) immediately after vfs.Register() completes |
Do not open a public GitHub issue for security vulnerabilities.
Use GitHub's private vulnerability reporting feature on this repository. Provide a description of the vulnerability, steps to reproduce, and your assessment of the impact. You will receive a response within the repository's normal review cadence.