mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
It moved out of the app directory in the previous commit; the principals table still described the old location. Co-Authored-By: Claude Fable 5 <[email protected]>
127 lines
6.3 KiB
Markdown
127 lines
6.3 KiB
Markdown
# Agent Security Model
|
|
|
|
What the SeedPass session agent protects, what it does not, and why. Written
|
|
so a reviewer does not have to infer the intended boundary from the code.
|
|
|
|
Scope: `js/packages/cli/src/agent.ts` and the token surface it serves. The
|
|
vault formats and derivations are covered by
|
|
`docs/typescript_port_compatibility_matrix.md`.
|
|
|
|
## The problem
|
|
|
|
A stateless CLI must hold an unlocked seed somewhere to be usable, and an AI
|
|
agent or automation that drives it should be able to *use* a credential
|
|
without being handed the vault. Those are two different principals sharing
|
|
one machine.
|
|
|
|
## Principals
|
|
|
|
| Principal | Proves itself with | Gets |
|
|
|---|---|---|
|
|
| Owner | The capability secret in a `0600` file under `XDG_RUNTIME_DIR` (deliberately not in the app directory) | Everything: unlock/lock, the parent seed, token issuance, status, shutdown |
|
|
| Token holder | A bearer token issued by the owner | Only what the token's scopes and constraints allow, enforced by the daemon |
|
|
| Anyone else | Nothing | Nothing but a liveness ping |
|
|
|
|
Enforcement lives in the daemon, never in the CLI. The CLI is untrusted from
|
|
the daemon's point of view: any local process can speak the socket protocol,
|
|
so a check that exists only in the client is not a check at all. (This was a
|
|
real defect, found in review: the socket accepted privileged operations from
|
|
any local process because the restrictions lived in the CLI.)
|
|
|
|
## What the capability does and does not do
|
|
|
|
The socket is `0600`, which proves only "a process running as this user".
|
|
That does not distinguish the owner's CLI from a scoped agent that was
|
|
handed a token — both run as you. The capability file raises the bar to "can
|
|
read the owner's files", which is the same bar as the vault itself.
|
|
|
|
It is stored outside the app directory on purpose. A scoped agent has to be
|
|
given that directory to find the socket and the profile registry, so a
|
|
capability kept there would ship the escape hatch with the keys — a reviewer
|
|
demonstrated exactly that escalation.
|
|
|
|
**It does not defend against a same-uid attacker.** Anyone who can read that
|
|
file, read the vault, or `ptrace` the daemon has already won. Defending
|
|
against that needs an OS-level boundary (a separate user, a container, a
|
|
hardware token) that a user-space daemon cannot provide. If your threat
|
|
model includes hostile code running as you, this design does not save you.
|
|
|
|
## Token scopes
|
|
|
|
- `read` — redacted entry metadata only. The daemon filters to entries the
|
|
token's kind and label constraints cover, so out-of-scope entries are not
|
|
disclosed at all, and secret-bearing fields are replaced with `has_*`
|
|
flags. A read token never receives the decrypted index.
|
|
- `use` — the daemon delivers a secret to a sink (clipboard, a child
|
|
process's environment, a child's stdin) and returns a receipt. The
|
|
plaintext does not cross the socket back to the holder.
|
|
- `reveal` — plaintext egress. Distinct from `use` on purpose.
|
|
|
|
Constraints: entry kinds, a label regex, a TTL, a use count, and an optional
|
|
exec allowlist. Denials do not consume uses. Tokens die with a `vault lock`,
|
|
a TTL expiry, or a revoke, and do not come back on the next unlock.
|
|
|
|
### The honest limit of `use`
|
|
|
|
A `use` holder that chooses the sink command can read the secret from inside
|
|
that command. `--exec 'sh -c "cat > /tmp/stolen"'` is a legitimate use as
|
|
far as the daemon is concerned.
|
|
|
|
So `use` is not "can act without learning". It is:
|
|
|
|
- **containment**, when the token carries an exec allowlist — then the
|
|
holder can only hand the secret to commands you named;
|
|
- **accountability**, always — the audit log records the exact argv that
|
|
received each secret.
|
|
|
|
If you need a hard guarantee that an agent cannot learn a credential, do not
|
|
hand it a `use` token for that credential. Have it call something that holds
|
|
the credential itself.
|
|
|
|
## Audit log
|
|
|
|
Append-only, HMAC-chained (`sig = HMAC(key_index, prev_sig || canonical
|
|
payload)`), keyed by the profile's index key, so only the seed holder can
|
|
extend or verify it. A signed head file records the expected record count
|
|
and last signature — without it, deleting the log or a suffix of it leaves a
|
|
shorter file that still verifies perfectly. `agent audit-verify` walks the chain and fails at the
|
|
first break. Writes are serialized so concurrent requests cannot interleave
|
|
and desynchronize it. Recorded: unlocks, token lifecycle, index reads,
|
|
deliveries (with the sink and argv), and every denial with its reason.
|
|
Secret values are never recorded.
|
|
|
|
## Known limits, stated rather than hidden
|
|
|
|
1. **Same-uid attackers win** — see above. Concretely: a scoped agent is
|
|
given the app directory, so the capability is deliberately stored
|
|
*outside* it (in `XDG_RUNTIME_DIR`), but a token holder that can read that
|
|
file can still unset its token and act as the owner. Treat a token as a
|
|
convenience and an audit trail, not as a sandbox. Real containment needs a
|
|
separate uid or a container.
|
|
2. **`use` can be turned into `reveal`** by a holder that picks the command,
|
|
unless an exec allowlist is set.
|
|
3. **Memory is not scrubbed.** Seeds live in the daemon's heap while
|
|
unlocked; JavaScript gives no reliable zeroization. Lock when you are
|
|
done — that drops the seed, its tokens, and the derived audit key.
|
|
4. **The capability file lives on disk** for the daemon's lifetime, so it
|
|
survives a crash of the CLI but also a crash of the daemon; a stale file
|
|
is replaced on the next start.
|
|
5. **Two protocol-level issues are shared with the Python implementation**
|
|
and cannot be fixed in one implementation without breaking convergence:
|
|
tombstone retention allows deletion replay once the cap is exceeded, and
|
|
SSH and PGP entries at the same index derive the same Ed25519 key. Both
|
|
need a versioned change in both implementations; both are tracked in the
|
|
compatibility matrix.
|
|
|
|
## Verifying these claims
|
|
|
|
`js/packages/cli/test/agentSecurity.test.ts` speaks the wire protocol
|
|
directly, bypassing the CLI, and asserts: no seed retrieval, token minting,
|
|
lock, shutdown, status or seed injection without the capability; no
|
|
decrypted index for a read token; no plaintext for a use token; no owner ops
|
|
via a token; no `SEEDPASS_*` inheritance in a sink child; correct rejection
|
|
of non-string credentials; and that tokens do not survive a lock.
|
|
|
|
If you change the daemon, add the attack to that file first and watch it
|
|
fail before you make it pass.
|