Audit of the security posture implemented in the working tree. For threat-modelling philosophy, competitive analysis, and compliance mappings see
docs/current-arch/SECURITY.md.
- Vault & Crypto
- Duress & Panic Wipe
- SQLite Encryption at Rest
Secret<T>DisciplineunsafeAudit- WASM Sandbox
- API Authentication & Transport
- TLS Policy
- Manifest Signing
- Sentinel & Dispatch
- Release Profile
- Dependency Advisories
- Compliance Matrix
crates/springtale-crypto/src/vault/.
Argon2id with OWASP minimum profile.
| Parameter | Value |
|---|---|
| Memory cost | 64 MiB |
| Iterations | 3 |
| Parallelism | 4 |
| Output length | 32 B (256-bit) |
| Salt | 16 B random, per-vault |
Source: vault/kdf.rs. Derived keys are returned in SecretBox<[u8; 32]> and pinned into physical RAM via memsec::mlock where the OS permits.
passphrase random salt (16 B)
│ │
└──────────┬──────────────────────────┘
▼
┌───────────────────┐
│ Argon2id │ memory=64 MiB
│ │ iterations=3
│ │ parallelism=4
└─────────┬─────────┘
▼
SecretBox<[u8; 32]> ─── memsec::mlock (page pinned)
│ └─── madvise DONTDUMP (no core dumps)
▼
XChaCha20-Poly1305 key
Fig. 2. KDF pipeline. The 32-byte key never leaves SecretBox except inside audited expose_secret() call sites at the exact AEAD invocation.
XChaCha20-Poly1305 (authenticated AEAD).
| Parameter | Value |
|---|---|
| Nonce size | 24 B |
| Nonce generation | fresh OsRng per save |
| Tag | Poly1305 MAC authenticates salt + nonce + ciphertext |
Key exposure sites are annotated // SECURITY: expose needed for AEAD {encrypt,decrypt}. See §4 for the full count.
Single region (legacy):
0 16 40 end
┌───────────┬───────────┬─────────────────────────┐
│ salt 16 │ nonce 24 │ ciphertext (AEAD body) │
└───────────┴───────────┴─────────────────────────┘
Dual region (duress):
0 40 65576 131152
┌────────────────┬────────────────────────┬────────────────┬────────────────────────┐
│ header_A 40 │ ct_A 65536 bytes │ header_B 40 │ ct_B 65536 bytes │
└────────────────┴────────────────────────┴────────────────┴────────────────────────┘
▲ salt + nonce ▲ salt + nonce
▲ real passphrase decrypts this ▲ duress passphrase decrypts this
▲ writing this never touches the other region
Total file size: 131,152 bytes — constant regardless of contents.
Fig. 1. Vault file layout. No magic bytes, no headers — statistically indistinguishable from random without the passphrase.
Two AEAD-encrypted regions, each 64 KiB + 40 B header. The inactive region is preserved byte-for-byte on every save — writing the real vault does not touch the decoy region and vice versa.
| Passphrase | Region | Contents |
|---|---|---|
| Real | 0 | Full identity, connectors, rules, memory |
| Duress | 1 | Decoy profile — minimal config, no history |
Tests verify constant file size, asymmetric isolation between regions, and indistinguishability from random without the correct passphrase.
Single-pass random overwrite in 4 KiB chunks, fsync, unlink. Completes in under 3 seconds on a 1 MB vault. Ephemeral vaults skip file I/O.
Limitation: on SSDs with wear levelling, overwriting does not guarantee erasure of residual ciphertext in the flash translation layer. Panic wipe destroys the key material, which is sufficient to make any surviving ciphertext unreadable; full-disk encryption is the only way to physically remove the bytes. This is documented in docs/guide/security.md §6.
The database is encrypted with SQLite3MultipleCiphers (sqlite3mc) — the same full-database encryption approach as Signal/SQLCipher, but with pure-C ChaCha20 and no OpenSSL.
Cargo.toml patches libsqlite3-sys via a local crate at crates/libsqlite3-sys-mc/ wrapping the sqlite3mc amalgamation. Every rusqlite opener in the workspace transparently gains cipher support.
-
Cipher:
chacha20via sqlite3mc. -
Key: 32 raw bytes, hex-encoded, supplied as
PRAGMA key = "x'{hex}'"before schema apply or WAL setup (crates/springtale-store/src/backend/sqlite/mod.rs). -
Derivation (
apps/springtaled/src/runtime/boot/crypto.rs):db_key = HMAC-SHA256(key=b"springtale-db-encryption-v1", msg=passphrase)The context string differs from the API token derivation, so the two keys are independent.
-
The derived hex key flows through the runtime as
RuntimeStoreConfig.encryption_key_hex. It is not a user-facing config field; it is populated at boot from the vault passphrase.
When ephemeral = true, the store is in-memory SQLite with no file and no key. All state is lost on exit.
Springtale uses secrecy::SecretBox<T>. SecretBox compile-time forbids Debug, Display, Clone, and Serialize, and zeroizes on drop.
| Crate / module | expose_secret() sites |
Notes |
|---|---|---|
springtale-crypto::vault::kdf |
0 | Keys wrapped in SecretBox, never unwrapped |
springtale-crypto::vault::store |
3 | AEAD encrypt / decrypt / backup |
springtale-crypto::vault::duress |
3 | Per-region AEAD |
springtale-crypto::mlock |
2 | Pointer for memsec::mlock / munlock |
springtale-ai::anthropic |
1 | HTTP x-api-key header |
springtale-ai::openai |
1 | HTTP Authorization header |
springtale-ai::voice::tts |
1 | ElevenLabs xi-api-key header |
| Connectors | varies | HTTP header + webhook signature verification |
Convention: // SECURITY: expose needed for X. Every site carries a justification. No Secret<T> value reaches an API response, log line, or error message.
- Config structs derive
Deserializeonly — neverSerialize. Prevents secrets being round-tripped through config dumps. AiRequestis a closed enum with concreteStringfields. Secrets cannot be statically typed into an AI request.#[deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]on all library crates blocksunwraponOption<Secret>.
| Crate | Attribute |
|---|---|
springtale-core |
#![forbid(unsafe_code)] |
springtale-transport |
#![forbid(unsafe_code)] |
springtale-scheduler |
#![forbid(unsafe_code)] |
springtale-store |
#![forbid(unsafe_code)] |
springtale-ai |
#![forbid(unsafe_code)] |
springtale-mcp |
#![forbid(unsafe_code)] |
springtale-bot |
#![forbid(unsafe_code)] |
springtale-sentinel |
#![forbid(unsafe_code)] |
springtale-runtime |
#![forbid(unsafe_code)] |
springtale-crypto |
#![deny(unsafe_code)] (3 audited blocks in mlock.rs) |
springtale-connector |
#![deny(unsafe_code)] |
crates/springtale-crypto/src/mlock.rs contains three audited unsafe blocks, all operating on a 32-byte heap allocation owned by a SecretBox<[u8; 32]>:
memsec::mlock(ptr, 32)— pin the page into physical RAM, preventing swap.libc::madvise(ptr, 32, MADV_DONTDUMP)— exclude the page from Linux core dumps.memsec::munlock(ptr, 32)— release the page lock before drop.
Safety invariant on all three: ptr is a stable heap address owned by SecretBox, never reallocated, len == 32. The FFI calls are the minimum required to request the relevant kernel hints. Every block carries a // SAFETY: comment explaining the invariant. No other unsafe appears in the workspace.
crates/springtale-connector/src/wasm/. Built on Wasmtime with the Component Model.
wasm/runtime.rs:
| Setting | Value |
|---|---|
consume_fuel |
true |
epoch_interruption |
true |
cranelift_opt_level |
OptLevel::Speed |
| Component Model | enabled (WASI P2) |
An eternal tokio task increments the engine epoch every 1 s from springtale-runtime/src/init.rs, driving the wall-clock timeout.
Fresh Store per execute() call (wasm/connector.rs). Limits enforced via StoreLimitsBuilder:
| Limit | Default |
|---|---|
| Memory | 64 MB (1024 pages) |
| Fuel | 10,000,000 instructions |
| Epoch deadline | 30 s wall clock |
| Instance cap | 10 |
| Table cap | 10 |
| Memory cap | 2 |
Each invocation gets a fresh Store — no cross-call state leakage.
Before module load (wasm/connector.rs), the host computes SHA-256 over the wasm bytes and compares against manifest.wasm_hash. Mismatch aborts loading.
All host functions gate through CapabilityChecker::check() before performing any real work. Currently:
| Function | Gate |
|---|---|
http_request(url, method) |
NetworkOutbound { host: parsed(url).host } |
Return codes: 0 = allowed, -1 = invalid input, -2 = denied by capability.
apps/springtaled/src/api/.
Tokens are issued, never derived. The passphrase-derived hash survives only as the login verifier.
boot: passphrase ──HMAC-SHA256(·, "springtale-api-token")──▶ verifier[32B]
│
POST /auth/login {passphrase} ─── constant-time compare ──────┘
│ match
▼
OsRng ──▶ token[32B] ──▶ hex-encoded, returned exactly once
│
└──▶ stored as sha256(token)
· session → process memory
· long-lived → api_tokens table
every request: Authorization: Bearer <hex(token)>
- 32 B (256-bit) token straight from the OS CSPRNG, hex-encoded, with no structure. Returned exactly once, in the minting response.
- Two kinds, both accepted by
require_auth: sessions (POST /auth/login, in-memory, idle + absolute timeouts, dropped on vault lock or restart, revoked byPOST /auth/logout) and named long-lived tokens (POST /auth/tokens, rows inapi_tokens, revoked byDELETE /auth/tokens/{id}). - Neither is ever stored in the clear — both live as
sha256(token). - Comparison uses
subtle::ConstantTimeEq— timing-attack resistant. A token never issued, one expired, and one revoked are all the same401. - SSE tickets:
EventSourcecannot set custom headers, and the token never goes in a URL.POST /stream/ticket(bearer-authenticated) returns{ "ticket": "<64 hex chars>", "ttl_secs": 30 }; the client opens the single multiplexedGET /stream?ticket=…with it. Tickets are single-use and expire after 30 seconds. - Rotating the vault passphrase rotates no token. It changes only what a future
POST /auth/loginmust present, and a running daemon keeps the old verifier in memory until it restarts.
In outside-in composition order (api/mod.rs):
incoming request
│
▼
┌──────────────────────────────────────────────────────┐
│ 1. TraceLayer HTTP trace span │
├──────────────────────────────────────────────────────┤
│ 2. SetResponseHeaderLayer ×5 security headers §7.3 │
├──────────────────────────────────────────────────────┤
│ 3. RequestBodyLimitLayer 1 MiB │
├──────────────────────────────────────────────────────┤
│ 4. HandleErrorLayer rate-limit err → 429 │
├──────────────────────────────────────────────────────┤
│ 5. BufferLayer (256) fronting rate limiter │
├──────────────────────────────────────────────────────┤
│ 6. RateLimitLayer 100 req/s default │
├──────────────────────────────────────────────────────┤
│ 7. TimeoutLayer 30 s → 503 │
├──────────────────────────────────────────────────────┤
│ require_auth Bearer / stream ticket │
│ ValidatedPath segments ≤ 256 bytes │
└──────────────────────┬───────────────────────────────┘
▼
handler
Fig. 3. Middleware stack. Applied to every route; SSE endpoints authenticate with a one-time ticket from POST /stream/ticket instead of the bearer header.
Webhook routes (/webhook/{connector}/{trigger}) go through the same require_auth layer as every other authenticated endpoint. The per-connector Connector::verify_webhook() method additionally checks a per-sender signature (HMAC-SHA256, RSA, etc.) on the body.
Five headers are set on every response:
| Header | Value |
|---|---|
X-Frame-Options |
DENY |
Content-Security-Policy |
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*; img-src 'self' data:; frame-ancestors 'none' |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
no-referrer |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), accelerometer=(), gyroscope=() |
HSTS is deliberately omitted. RFC 6797 §8.1 forbids sending Strict-Transport-Security over plaintext HTTP, and the daemon defaults to 127.0.0.1 without TLS. An operator terminating TLS in front of the daemon (reverse proxy, mesh sidecar) should set HSTS at that layer.
- Default:
127.0.0.1:8080(loopback only). 0.0.0.0binds emit a warning at boot.- CSP
connect-srcrestricts the dashboard to'self'andhttp://127.0.0.1:*.
rustls exclusively. native-tls is banned through a vendor stub at vendor/native-tls-stub/ wired via [patch.crates-io] in the workspace root. Any transitive pull of native-tls fails to compile.
reqwest = { version = "0.12", default-features = false,
features = ["rustls-tls", "json", "stream"] }
rustls = "0.23"
tokio-rustls = "0.26"
rustls-pemfile = "2"
axum-server = { version = "0.8", features = ["tls-rustls"] }| Connector | TLS feature |
|---|---|
connector-discord (twilight-gateway, twilight-http) |
rustls-webpki-roots |
connector-slack (tokio-tungstenite) |
rustls-tls-webpki-roots |
TLS certificate validation is never disabled in any code path.
Ed25519 over canonical JSON.
verify_manifest_signature(manifest, author_public_key):
1. hex-decode manifest.signature (expect 64 B)
2. strip `signature` field from manifest → canonical JSON
3. springtale_crypto::signature::verify::verify_canonical_json(
&canonical_bytes, &signature, &public_key)
4. on mismatch → ConnectorError::SignatureInvalid
Canonical JSON is produced deterministically (sorted keys, no whitespace), so a byte-identical manifest always hashes the same.
name,version,authormust be non-empty.NetworkOutbound.hostmust be non-empty.NetworkOutbound.hostmay not contain*(wildcard rejected).
- On
connector install(operations/connectors/install.rs). - Unit tests cover: valid signature, tampered manifest, wrong key, missing signature.
- Re-verification on every load is the intended behaviour but not confirmed in the scoped audit — tracked in
AUDIT-NOTES.md §5.
crates/springtale-sentinel/ + crates/springtale-runtime/src/dispatch.rs.
The behavioural monitor is always initialised at runtime. If no config is provided, it runs with the defaults in §9 of the configuration reference. There is no "disable sentinel" mode.
Every action flows through dispatch_action(&action, ®istry, &sentinel). The first step of the inner dispatch function calls sentinel.evaluate(action, connector_name), which returns one of:
Action (from rule match or bot handler)
│
▼
┌───────────────────┐
│ sentinel.evaluate │
└─────────┬─────────┘
│
┌────────────────┼────────────────┬────────────────┐
▼ ▼ ▼ ▼
Go Throttle(d) Pause(reason) Quarantine(reason)
│ │ │ │
▼ ▼ ▼ ▼
per-action sleep d, dispatch dispatch
branch then retry error + error +
│ audit row audit row
▼ │
RunConnector → registry → CapabilityChecker │
WriteFile → path validation, ≤10 MiB │
RunShell → logged, deferred to approval flow │
Chain → recursive dispatch, depth ≤15 │
… │
│ │
└──────────────┬─────────────────────────────────────┘
▼
sentinel.report(action, outcome)
Fig. 4. Dispatch flow. The sentinel gate runs before the per-action branch and again on completion to record the outcome.
| Verdict | Effect |
|---|---|
Go |
Proceed to the per-action branch |
Throttle(duration) |
Delay then retry |
Pause(reason) |
Return a dispatch error |
Quarantine(reason) |
Return a dispatch error and write an audit row |
Sentinel checks run in this order per action:
- Circuit breaker (per-connector failure threshold)
- Rate limiter (per-connector actions/minute)
- Dead-man switch (global action count without user interaction)
- Destructive action gate — classifies the action via
impact::classify_impact; ifDestructive, the verdict routes through anApprovalGatebefore dispatch
ApprovalGate is a trait (crates/springtale-sentinel/src/approval.rs) implemented per surface:
- CLI / headless wire
DefaultDenyApprovalGate. Safe default for the most vulnerable user — a destructive action with no human surface is refused, not silently approved. - Desktop / web wire their own gate that prompts the human operator (the colony-canvas safety panel surfaces the request and the audit trail records the response).
The gate sees an ApprovalRequest with the action, target, and reason. Its async decision is one of Approved / Denied / Escalated (route to another surface, e.g. push notification).
ShellExec is gated harder still: crates/springtale-runtime/src/approval/ parks every ShellExec grant in a pending queue regardless of capability policy. The requestor blocks until a decision lands via GET /approvals + POST /approvals/{id} (bearer auth), the desktop approval card, or the in-app chat panel (ChatApprovalGate); the deny-fallback timeout (default 60s) means a dropped connection never silently grants. Decisions are recorded as ApprovalRequested / ApprovalResolved audit rows, and tool loops paused behind an approval are checkpointed (approvals.sql: tool_loop_checkpoints) and resumed after the verdict — replaying exactly the persisted bound calls, never a re-derived action.
When a bot has an AI adapter configured, the adapter is additionally wrapped in GuardrailAdapter (crates/springtale-ai/src/guardrail/): wall-clock timeout, output size cap, refusal-rate counters, and a per-bot daily token quota ([sentinel] daily_token_limit, persisted in ai_token_usage). The tool surface defaults to a zero-tool allow-list ([bot] tool_policy, OWASP LLM06).
After the action completes, sentinel.report(action, outcome) records the result.
Sentinel::check_toxic_pairs() runs at manifest install time, not dispatch time. It rejects capability combinations that are safe individually but dangerous in combination:
KeychainRead+NetworkOutbound→ credential exfiltrationFilesystemRead+NetworkOutbound→ file exfiltrationShellExec+NetworkOutbound→ command execution + exfiltrationFilesystemWrite+ShellExec→ write-then-executeBrowserNavigate+KeychainRead→ credential theft via browser
No override. If a manifest declares a toxic pair, the install fails.
Every sentinel verdict is written to the audit_trail table (schema/sql/audit.sql) with timestamp, connector, action summary, verdict, and reason. Append-only. Retention is governed by [sentinel] audit_retention_days (default 90 days) and a background purge task.
Cargo.toml:
[profile.release]
overflow-checks = trueDisables silent integer wrapping in release builds. Critical for fuel metering, size limits, bounded queries, and retention windows — all places where a silent overflow would be a resource-accounting exploit. CPU cost is negligible.
rand is pinned at 0.8 with default-features = false in the workspace root. Only std, std_rng, and getrandom are enabled.
The unsoundness in RUSTSEC-2026-0097 requires rand's log feature. With default-features = false, that feature is not compiled into the binary, so the UB code path is absent.
The workspace cannot upgrade to rand 0.9 until RustCrypto ships stable releases of ed25519-dalek 3.x and chacha20poly1305 0.11.x against rand_core 0.9.
Five RUSTSEC IDs are explicitly ignored with written rationale:
| ID | Reason |
|---|---|
| RUSTSEC-2023-0071 | RSA Marvin attack. Signature verification only, never decryption. Public key only. Not exploitable. |
| RUSTSEC-2023-0089 | atomic-polyfill unmaintained. Transitive via garde → phonenumber → postcard → heapless. No-op on x86_64/aarch64. |
| RUSTSEC-2025-0119 | number_prefix unmaintained. CLI table formatting only, cosmetic. |
| RUSTSEC-2025-0134 | rustls-pemfile unmaintained. Merged into rustls core; still used for boot-time cert loading. |
| RUSTSEC-2026-0097 | rand 0.8.5. Mitigated by default-features = false — see §12.1. |
Bans native-tls, openssl, openssl-sys at compile time. License allow-list: MIT, Apache-2.0, BSD-2/3, ISC, Unicode.
Scans commits for credentials. Allowlist: stopwords ("abc123", "XChaCha20-Poly1305") and docs/ paths. Custom rule for the Discord token example in connector-discord config.
Claims from .claude/rules/backend/security.md matched against code:
| Claim | Observed |
|---|---|
9 crates forbid(unsafe_code) |
core, transport, scheduler, store, ai, mcp, bot, sentinel, runtime |
2 crates deny(unsafe_code) |
crypto (3 audited blocks in mlock.rs), connector |
| Ed25519 manifest signing | manifest/verify.rs via springtale_crypto::signature |
| 10 M fuel per WASM invocation | wasm/connector.rs per-invocation store |
| 64 MB memory per WASM instance | StoreLimitsBuilder, 1024 pages |
| 30 s wall-clock timeout | engine epoch interruption + 1 s ticker |
Exact-match NetworkOutbound hosts |
manifest/verify.rs rejects wildcards |
ShellExec holds pending user approval |
capability/grant.rs interactive policy |
| Argon2id with 3 iter, 64 MiB, 4 parallelism | vault/kdf.rs |
| XChaCha20-Poly1305 vault encryption | vault/store/, vault/duress.rs |
| HMAC-SHA256 bearer tokens + constant-time compare | api/auth.rs via subtle |
Default bind 127.0.0.1 |
ApiConfig::default |
tower-http rate limiting |
100 req/s via RateLimitLayer |
rustls exclusively, native-tls banned via patch |
vendor/native-tls-stub compile-error shim |
Every expose_secret site annotated |
// SECURITY: expose needed for X |
| Duress passphrase yields constant file size | 131,152 bytes, verified by duress.rs tests |
| Panic wipe completes in < 3 s on 1 MB target | wipe.rs single-pass random overwrite |
| SQLite encryption at rest | sqlite3mc ChaCha20 via libsqlite3-sys-mc patch |
overflow-checks = true in release profile |
Cargo.toml |
| 5 response security headers | X-Frame-Options, CSP, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
| Sentinel evaluates every action | dispatch.rs calls sentinel.evaluate before the per-action branch |
See AUDIT-NOTES.md for tracked gaps (manifest re-verification on restart, partial cooperation-module wiring, OpenAI streaming, job queue persistence).
No critical or high-severity gaps identified.