mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 08:22:03 +00:00
a9e31215eaec773335e9cfec9a4e893d5ce6d328
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a9e31215ea |
Throw on a denied agent request instead of returning a quiet zero
Every owner-op client method checks `ok` and throws, and none of those checks
was tested. Removing one does not produce an error — it produces whatever the
failed response coerces to, and for each of these that is a value which reads
as a legitimate outcome:
lock() -> 0, printed as "0 profiles locked" while the seeds are
still resident and the user has been told the vault is
locked
highRiskLock() -> false, indistinguishable from "there was nothing to
lock"
highRiskStatus() -> { unlocked: false, expires_at: NaN }
A refusal that looks like a successful no-op is worse than an error, because
nobody retries it. Forced by pointing SEEDPASS_AGENT_CAP at the wrong
capability, which is also the shape of the real failure: a caller that has
lost owner authority.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
d188005b10 |
Refuse unrecognised token scopes and malformed sink commands over the wire
Both checks fail closed if removed, and both fail closed SILENTLY, which is the reason they exist. An unknown scope grants nothing downstream — tokenAllows asks for read, use or reveal by name — so a token issued with `["admin"]` simply never authorises anything. The operator gets a token back, believes they issued it, and finds out when the job using it is denied. The likely cases are near-misses: "Read", "reveal " with a trailing space, "write" by analogy with other systems. Naming the bad scope in the refusal is the whole value. The sink `command` shape check is the same shape of argument. Every malformed command already fails closed, because the allowlist is checked with parseCommandSpec against the SAME array that is later spawned — so the check and the spawn cannot disagree. But failing closed there means a TypeError or a nonsense command word, and this daemon's stated posture is that every caller is untrusted. Refuse the shape while it is still describable. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
7a6b0a5f01 |
Refuse to replace a live agent; clean a stale socket
AgentDaemon.start() guards both cases and neither was tested. Without the live check the second daemon deletes the socket and listens in its place — the first keeps running, holding unlocked seeds, with an expiry sweep nobody can reach and an audit log nobody is writing to. Silently orphaning a process that holds seeds is worse than failing to start, which is what makes it a refusal rather than a convenience. The other half matters too: a crashed agent leaves its socket behind, and treating that as "already running" would make the agent unstartable until someone deleted a file by hand. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
e1ea9c2b48 |
Teach the mutation harness two blind spots it had, and close what they found
The harness only mutated block-form guards (`if (cond) {`), so every guard
written as a one-liner was invisible to it — which turned out to be most of
the load-bearing ones. src/highRisk.ts generated ZERO mutants and printed as
a clean sweep. Three changes to the tool:
- Mutate single-statement guards too (`if (cond) return ...;`). This alone
surfaced 27 previously unreachable mutants, including the entire token
validation chain: revoked, expired, exhausted, scope-not-granted.
- Report a target that generates no mutants as NOT MEASURED. Zero killed and
zero survived prints exactly like a clean sweep, and it is the one result
that must never be read as a pass.
- Add `kind`, `mnemonic` and `seed` to the security hints. A token's `kinds`
list is half of what it may reach, and leaving the word out hid the shape
check that stops a string `kinds` becoming a substring match.
Also adds `--kind` so a new mutation rule can be swept across every target
without re-judging the mutants already settled.
What the new mutants found, all of it previously unasserted:
- A token's `kinds` restriction was never tested. This file's own header
claimed kinds and labels were both covered; only labels were. Removing the
check let a token scoped to one kind read every other kind's secrets.
- `token-issue` would mint a token for a profile the agent is not holding.
The seed being resident is the only proof the caller had authority over
that vault.
- `token-issue`'s array shape checks for `scopes` and `kinds`. A string
`kinds` turns tokenMaySee's exact match into String.includes.
- An empty high-risk factor. Refused in both implementations, tested in
neither; it would wrap the partition key under nothing while
`factorConfigured` still answered true.
- POST /api/v1/fingerprint/select accepting a profile that does not exist —
the caller's string becomes ctx.fingerprint, which later requests join onto
a path. AppDir repeats the check, so the guard's real value is answering
404 instead of an unhandled 500.
- The API's requiresPassword flag is redundant for every route today, so
disabling it changed nothing. Its purpose is the route that does not exist
yet, whose author sets the flag and forgets the call — so the test now
registers exactly that route and confirms the server layer stops it before
the handler runs.
- sinks.ts drops SEEDPASS_-prefixed variables from a child's environment.
Nothing on the allowlist starts with SEEDPASS_, so the backstop cannot fire
against the real list. sinkEnv now takes the allowlist as a defaulted
parameter purely so that future-edit case can be exercised.
All six targets now sweep to zero survivors.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
62e577a6e1 |
Collapse the duplicated high-risk expiry rule; close agent mutation gaps
Mutation testing carried agent.ts to zero survivors (37 mutants, all killed or non-compiling). Four gaps, in descending order of how much they mattered: The high-risk session's expiry was decided in two places -- the sweep in expire() and a second comparison inside highRiskTag(). Breaking EITHER left the whole suite green, because each covered for the other. Worse, the copy in highRiskTag() was unreachable: handle() sweeps before it dispatches, so no request could ever arrive with an expired session still in the map. It read like a check and enforced nothing. highRiskTag() now sweeps instead of re-testing expiresAt, so the rule has one home and a test can hold it to account. The remaining three were missing assertions, not missing checks: - `put`'s `!fingerprint || !mnemonic` guard could become `&&` unnoticed. Both fields are re-validated downstream, so nothing is permitted that was not before -- but a blank mnemonic then reports "fingerprint does not match the supplied seed", sending whoever reads it after the wrong field. Tests now pin the message. - Same shape on high-risk-unlock's fingerprint format guard: disabling it entirely still refused, via "profile not unlocked" -- a lie about a string that could never be a profile. It is also the guard between a caller-supplied string and the audit-log path join, so it should be the one that answers. - highRiskStatus's client-side `expires_at === null` decode was never asserted; inverted, it reports 0 for a locked profile and null for a live one. That number is how a caller knows what is left of the second factor. Also hoists FROZEN/TAG/daemonAt to module scope so the new tests share the injectable clock. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
17b94d3e26 |
fix(agent): an empty exec allowlist now fails closed; test the allowlist at all
Mutation slice 2 scored 0% before this, and the reason was blunt: **the exec allowlist had no test whatsoever.** The clipboard restriction could be inverted, weakened, or deleted outright and the suite stayed green. That control matters because the clipboard is readable by every process in the session. A token restricted to `cat` that may still reach the clipboard is not restricted to anything — the holder simply copies the secret out, and the allowlist looks enforced while containing nothing. Now tested: a command-restricted token is refused the clipboard, a token with no allowlist is not (so the rule is conditional rather than a blanket ban passing as one), an allowlisted command is permitted, and one outside the list is refused without the sink ever running. **A fail-open found while triaging one of the survivors.** An empty `exec_allowlist` was DROPPED at issue time, which made the token unrestricted. Automation that computes an allowlist and arrives at zero entries means "permit nothing" and was silently handed "permit everything" — a fail-open in the one field whose entire purpose is restriction. It is now refused at issue time with an error saying which of the two readings the caller probably wanted. Reachable only over the raw socket protocol, since commander requires at least one value. **An audit-accuracy gap.** A denial and a missing entry deliberately return the SAME message, so the response cannot be used to enumerate ids — the audit log is where the difference is supposed to be recorded, and nothing read it. The two reasons could be swapped with every test passing. Testing it needed two corrections of my own: the CLI resolves references locally so the request never reaches the daemon, and the shared token in that file is revoked by an earlier test, so both calls were refused at the token check instead. It also needed a stronger assertion than the one I first wrote — checking that both reasons merely APPEAR passes when they are swapped, which is exactly the mutation. It now asserts the pairing: which id got which reason. Three survivors are recorded as genuinely unkillable with reasons, rather than chased: the two `length > 0` comparisons cannot differ from `>= 0` now that an empty allowlist cannot be stored, and the capability early-return is redundant with the length check below it. agent slices 1-2 (mutants 1-16): 100% of viable mutants caught. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
12b945ca7e |
test(agent): pin token expiry at its exact boundary; give the daemon a clock
Mutation testing found `token.expires_at <= now` could become `<` with every test still passing. The suite checked well before and well after expiry, which cannot tell the two apart — so a token was free to be usable for one more request than it claimed, which is the wrong direction for a credential. The daemon now takes an injectable clock, so the boundary can be asserted exactly rather than raced against wall time: at T-1 the token works, at exactly T it is refused. Every expiry decision AND every expiry assignment goes through that clock — a token issued against real time while checked against test time would never expire, which would have made the test meaningless. The TOTP timestamp-skew window uses it too, since a clock that moved one but not the other is a difference no production path should have either. Also pins the owner capability: an empty or absent capability is refused, and so is one of the right length but wrong content — the latter proving the constant-time comparison is what rejects it rather than the length check. One survivor is now recorded as genuinely unkillable rather than chased. `!presented || !this.capability` cannot change behaviour when flipped to `&&`, because the length comparison below rejects an empty string either way. The harness learned a `mutation-equivalent:` marker that must carry a REASON, so future survivor lists mean "look at this" instead of "this again" — and the judgement stays re-checkable rather than merely trusted. The harness also gained `--offset`, so a long target is measured in foreground-sized slices. That is a safety property, not a convenience: this script writes broken security code into the working tree, and it must never run unattended alongside commits again. agent slice 1 (mutants 1-6): 100% of viable mutants caught. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
8653ee8006 |
fix(agent): validate put at the daemon boundary; state the two scope semantics (L-5, L-6, L-7)
L-7 — `put` is the only way a fingerprint enters `held`, and every later handler joins that string onto a path (the vault it opens, the audit log it writes). The CLI validated the format first, which is exactly why the daemon could not rely on it: agent.ts's own header says the CLI is untrusted, and the browser extension speaks this protocol next. `put` now requires 16 uppercase hex, and additionally that the fingerprint actually belongs to the supplied seed — a mismatch would unlock a vault the caller did not name and file its audit records under the wrong profile. Validation order matters and is now deliberate: cheap format check, then ttl, then the seed match last, because deriving a fingerprint runs the BIP-39 KDF and a malformed ttl should not pay for it. This keeps the existing non-numeric-ttl test exercising the ttl path rather than short-circuiting on the new check. L-5 — `label_regex` is unanchored, so a token issued for `prod` also reaches `not-prod-db`. Left as-is on purpose: Python matches with `re.search`, so anchoring the TypeScript side alone would make one token mean two different things depending on which implementation holds it, and would silently narrow every token already issued. The audit asked for whichever behavior Python has, documented and in the parity matrix — so the semantics are now stated in --label-regex help, reported machine-readably as capabilities().tokens.label_regex_semantics, explained at the enforcement point, recorded in the compatibility matrix, and pinned by tests that assert BOTH labels are visible to an unanchored token. L-6 — the exec allowlist constrains the command word, never its arguments. The token holder writes the argv and the child gets the secret in its environment, so an allowlisted binary with an output-file or network flag hands the secret straight back. Documented in the same three places rather than changed: argument-level containment means allowlisting full argv templates instead of command words, which is a token-format change that has to land in both implementations together. Tests: malformed and mismatched fingerprints refused with nothing held (path traversal, wrong case, wrong length, trailing component); unanchored vs anchored label_regex; capabilities reporting both semantics. Mutation-verified — disabling either put guard turns the matching tests red. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
7f6edf10cc |
fix(agent): close two ways a local caller could subvert the session daemon
Both were found by the 2026-08-17 self-review and confirmed by speaking to the socket directly. The daemon is the enforcement point for the whole token model and treats every caller as untrusted, so neither fix leans on the CLI's own validation. 1. A `put` reported as FAILED could leave the seed resident and unexpiring. State was committed to `held` before the audit append was awaited. A non-numeric ttl made `expiresAt` NaN; the audit write then threw on the non-finite value, so the caller saw ok:false while the seed stayed in memory forever (`NaN <= now` is always false) with no record it was ever unlocked. Now ttl is validated as a positive integer at the boundary, and an audit failure rolls the seed back out — ok:false means genuinely locked. 2. A `use`-scoped token could crash the whole agent. `stdinSink` wrote to the child's stdin with no error listener, so a command that exits before draining raised an unhandled EPIPE; the agent runs sinks in-process and installs no uncaughtException handler, so it exited and dropped every held seed. The least-privileged principal could take down the enforcement point by naming a command that exits early. stdin errors are now swallowed and the child's exit code carries the result, as feedClipboardTool already did. Also hardens token-issue: scopes/kinds are shape-checked before the array methods run against them (a bare-string kinds turned an exact match into a substring match), and ttl/uses go through the same positive-integer boundary so a token can no longer be minted with a NaN, never-expiring lifetime. Three regression tests speak the wire protocol directly. Verified as negative controls: with the fixes stashed, the stdin test reproduces the EPIPE uncaught exception at sinks.ts:143 and two of the three fail; with them restored all 131 CLI tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8 |
||
|
|
dafae50ca4 |
security: fix agent-boundary findings from the fourth reviewer
The reviewer that took longest found the most, including one bug that lies
to the user about a security-relevant outcome.
- `agent stop` never sent the capability and never checked the reply. The
daemon refused the shutdown, the CLI printed {"agent":"stopped"} anyway,
and the seed stayed in memory and still served owner-mnemonic. The command
documents itself as wiping held seeds. It now authenticates and reports
what actually happened.
- The owner capability lived at <appDir>/agent.sock.cap -- inside the very
directory a scoped agent must be given to find fingerprints.json and the
socket. A token holder could `unset SEEDPASS_TOKEN` and the CLI would read
the capability on its behalf. It now lives under XDG_RUNTIME_DIR in a 0700
parent, created with wx so a pre-planted file is never adopted. This raises
the bar; it does not make a token a sandbox, and capabilities.ts no longer
claims otherwise.
- Redaction was a three-key denylist over deliberately loose schemas, so
`custom_fields` -- including values Python masks as is_hidden -- went to
any read-scoped token verbatim. It is now an allowlist that recurses into
custom_fields, reporting labels and presence but never values.
- Authorization ran after the vault was decrypted and after the entry
existence check, giving an unauthenticated caller an entry-id enumeration
oracle, no audit record for the misses, and a full PBKDF2 unlock per probe.
Tokens are now checked first, denials are indistinguishable for missing
and forbidden entries, and every denial is audited.
- The audit chain had no anchor: deleting the log, or a suffix of it, left
something that verified perfectly. A signed head now pins the record count
and tail signature. Separately, `loaded` was latched before the read that
could throw, so one transient EACCES made every later append sign from
genesis and broke the chain permanently.
- The exec allowlist -- documented as the only real containment for a `use`
grant -- was bypassable via the clipboard (a session-wide channel) and had
no CLI flag at all, so no shipped token could carry one. Both fixed.
- Unauthenticated connections could buffer without bound, growing to
gigabytes while each chunk rescanned the accumulated string. Line size and
concurrent connections are now capped.
- A token-supplied TOTP timestamp was unbounded, so one use minted codes
valid decades ahead, outliving the token's TTL. Clamped to a small window.
- Prototype keys ("__proto__", "constructor") resolved to inherited
properties, passed constraint checks against undefined values and burned a
use; now an own-property check. The socket was created with the umask mode
and chmod'd afterwards, leaving a connectable window; it is now created
under a restrictive umask. TTL was enforced only when a request arrived, so
an idle agent held the seed indefinitely past expiry; there is now a timer.
220 core + 78 CLI tests green; 36/36 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
||
|
|
6b862baa71 |
security: reject non-string credentials on the agent protocol
Found by running attacks against the socket rather than reading the code: a token supplied as a single-element array was accepted, because String(["abc"]) is "abc" and the comparison went through String() coercion. No privilege was gained -- the attacker still needed the real token -- but type coercion on a credential path is the kind of sloppiness that becomes a bug the moment the surrounding code changes. The handler now requires op, cap, token, fingerprint, id and sink to be strings, and command to be an array of strings, rejecting anything else outright. The same run confirmed the rest of the boundary behaves: by-id access to entries excluded by a token's label regex or kind constraint is refused server-side with the correct reason, reveal without the reveal scope is refused, denials do not consume uses, concurrent requests cannot overrun a one-use token, and a sink child spawned by the agent sees exactly one SEEDPASS_ variable -- the secret it was given. Co-Authored-By: Claude Fable 5 <[email protected]> |
||
|
|
9926b48e16 |
security: fix agent trust boundary and secret-environment leaks
A security review found the agent's authorization model was enforced in the client, not the server: any local process that could open the 0600 socket could retrieve the parent seed, mint tokens, unlock/lock, or shut the agent down, because the daemon accepted privileged operations unauthenticated. It also found that scoped tokens received far more than their grant, and that sink children inherited the caller's entire environment. Server-side authorization: - Privileged operations (put, owner-mnemonic, token issue/list/revoke, lock, status, shutdown) now require a capability secret written to a 0600 file beside the socket at startup, compared in constant time. Socket permissions alone cannot distinguish the owner's CLI from a scoped agent holding a token. The residual risk is documented in the code: a same-uid attacker who can read that file, or ptrace the daemon, still wins -- that needs an OS-level boundary this process cannot provide. - The mnemonic-returning op is renamed owner-mnemonic and is owner-gated. It is unreachable with a token. Token containment: - vault-index returned the decrypted index to any read-scoped token, handing over every imported TOTP secret, key-value value and document body. It now returns redacted metadata, filtered to entries the token's kind and label constraints actually cover, so out-of-scope entries are not even disclosed. - The `secret` op now requires the reveal scope specifically. A use-scoped token can no longer pull plaintext back across the socket. - `use` is served by a new use-sink op: the agent runs the sink itself and returns a receipt, so plaintext never crosses back to the token holder. Tokens gained an optional exec allowlist, because a holder who chooses the command can always read the secret from inside it -- the allowlist is what contains a `use` grant, the audit record is what makes it accountable. Secret hygiene and state: - Sink children get a minimal allowlisted environment. SEEDPASS_MNEMONIC, SEEDPASS_PASSWORD, SEEDPASS_TOKEN, the agent socket and app dir are never inherited: a helper invoked to receive one password could previously have taken the whole vault. - Locking (and TTL expiry) now clears that profile's tokens and its audit logger, so tokens cannot spring back to life after a later unlock and the seed-derived audit key does not stay resident. - Audit appends are serialized, so concurrent requests cannot interleave and break the HMAC chain. - Numeric CLI options are validated before anything is persisted. A single `--length not-a-number` previously stored NaN as JSON null and made every later vault open fail schema validation. test/agentSecurity.test.ts speaks the wire protocol directly, bypassing the CLI, and asserts each of the review's reproductions now fails: no seed, no token minting, no lock/shutdown/status/put, no raw index for a read token, no plaintext for a use token, no owner ops via a token, and no SEEDPASS_* in a sink child. Also verifies the capability file is 0600 and that tokens die with a lock. 193 core + 60 CLI tests green; 36/36 cross-implementation checks green. Co-Authored-By: Claude Fable 5 <[email protected]> |