mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 00:42:08 +00:00
7b5730910ea2ee5146f7fb121dded10cf61c8f4e
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
73ac93a9f1 |
Deliver the oversize refusal before hanging up, and pin a POSIX-only test
Two left from the platform run, one real.
THE DAEMON HUNG UP WITHOUT SAYING WHY, ON macOS ONLY
The line-length cap did `socket.write(refusal)` and then `socket.destroy()`.
destroy() tears the socket down immediately and discards whatever is still
buffered — on Linux the write usually reaches the kernel first and the peer
sees "request too large", on macOS it does not and the peer sees an
unexplained close. The refusal itself worked on both; only the reason was
lost, and only on one platform, which is why nothing noticed until this suite
ran there.
Now `end(payload, cb)` and destroy from the callback, so the bytes are
flushed before teardown, plus a `refused` flag so a peer still pushing the
rest of its megabyte is ignored rather than re-buffered.
The test that caught it was itself half the problem: it treated its own EPIPE
as fatal, when a client's broken pipe is the expected consequence of a server
correctly hanging up mid-write. Fixed in the previous commit; this one fixes
what that then exposed.
AND ONE TEST ASSERTING POSIX BEHAVIOUR WITHOUT SAYING SO
`honours backslash escapes outside single quotes` called parseCommandSpec with
no platform flag, so it read process.platform and asserted POSIX semantics on
Windows — where backslash is a path separator and deliberately is not an
escape. It now passes `{ windows: false }`, because that is the branch it is
about.
Windows went 15 failures to 1 to 0; macOS 2 to 1 to 0.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
74a5c23dfb |
Clear the linter's built-in findings, keeping the one that was deliberate
Six warnings from oxlint's default rules, five of them genuinely redundant:
`...(x ?? {})` in an object literal, where spreading undefined is already a
no-op, and a `Boolean()` wrapping an `if` condition.
The sixth is kept. `for (const held of [...this.held.keys()]) this.forget(held)`
looks like a pointless array copy, and oxlint is right that for..of accepts an
iterable directly — but forget() deletes from this.held, so the spread is
what makes the loop iterate a snapshot instead of the map it is mutating.
Disabled on that line with the reason written next to it.
That distinction is the whole reason to read lint output rather than apply
it: a rule that is correct in general was wrong about the one site here where
it mattered, and the site it was wrong about locks every held seed.
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 |
||
|
|
c2a6a11472 |
fix: revert an inverted security check I committed by accident, and make it impossible
Commit
|
||
|
|
cf18ab8091 |
test: close the gaps mutation testing found in approvals, sinks, and a flaky test
Approvals scored 58%. Five survivors, all genuine:
- `ttlSeconds` and `uses` validation was unguarded by any test. An approval
issued with ttl 0 is expired the instant it exists; one with uses 0 can
never be consumed. Either would look like a granted authorization to
whoever asked for it and silently authorize nothing — the confusing
failure rather than the safe one.
- The expiry boundary (`<=` vs `<`) was unpinned. It now asserts that an
approval expiring exactly now is refused, and that one millisecond earlier
it still works, so the boundary is where it claims to be rather than
approximately there. Python uses `<=`, so this is parity as well as the
safer direction.
Approvals now 100%.
Sinks scored 67%. The survivor turned `&&` into `||` in the command-spec
parser, which makes a multi-element spec whose FIRST element contains
whitespace get tokenized — silently discarding every element after it. Losing
an argument changes which command runs, and dropping a `--dry-run` or a target
path is exactly the kind of silent change that matters for a sink being handed
a secret. Sinks now 100%.
Separately, the full suite caught a flaky test of my own: the recovery-split
tamper check rewrote the payload's tail and, depending on the payload's
padding, sometimes reproduced the original string exactly — asserting nothing.
It only failed when a randomized share happened to end the wrong way. Now it
flips a character and asserts the result actually differs before expecting the
throw, plus a second case that tampers at four positions rather than trusting
one. Five consecutive runs are clean.
The agent's mapping was also wrong: agent.ts holds the high-risk session, whose
tests live in highRisk.test.ts, so leaving that suite out scored the agent at
38% and blamed the code for a gap that was really a gap in the harness.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
0c48c1f9dc |
feat(agent): port high-risk partitions and approval gates
High-risk partitions move ssh/pgp/seed/nostr/managed_account records out of
the vault index into a separately encrypted file, so those kinds need a SECOND
factor beyond the master password. The index keeps a stub carrying kind, label
and archived — enough to list, nothing more.
Both file formats are byte-compatible with Python and verified bidirectionally:
TS unwraps a Python-written key envelope and reads a Python-written partition,
and Python unwraps a TS-written envelope and reads a TS-written partition. That
matters more than usual here — the entries are SSH keys, PGP keys and seeds, so
a partition only one implementation can open is lost secrets, not an
inconvenience. This required a Fernet encryptor in core, which had been
decrypt-only by design; the header now says why that exception exists and that
no other Fernet payload should ever be written.
**One deliberate divergence, and it is the reason this is not a straight port.**
Python records an unlock session in agent_high_risk_unlock.json, and that record
contains the partition_key_tag — the value the partition file's encryption key
is derived from. So for the life of any Python unlock session the partition is
decryptable from disk alone, with no factor, by anything that can read the app
directory; both files live in the same directory, so "can read one" implies "can
read the other". That voids the guarantee the partition exists to provide.
Verified empirically before writing any of this.
Here the tag lives only in the session agent's memory, beside the parent seeds
it already holds, with the same TTL discipline — and in the API process's memory
for the API. Nothing derived from the factor reaches disk, asserted by a test
that scans every file in the app directory for the tag, and confirmed on a live
install. No file format or interop changes; only where the live session lives.
Other properties the tests pin:
- a locked partition refuses reveal rather than deriving from the stub, which
would return a plausible wrong secret
- locking the vault drops the high-risk grant, which is scoped to it
- the grant expires on its own clock, typically much shorter than the seed's
- unlock is owner-gated: a scoped token cannot reach it
- the tag is absent from the audit log, which records only that an unlock
happened
- the factor comes from the environment, never argv
- factor-set refuses to replace an existing factor, since a new partition key
would make an existing partition permanently unreadable
- migrate writes the partition BEFORE the index, so a crash between them
cannot leave stubs pointing at records that were never stored
Approval gates port straight across: one-shot (or N-use) authorizations for
export / reveal_parent_seed / private_key_retrieval, consumed under the store
lock so two callers cannot both spend the last use, with every failure mode
getting its own reason string matching Python's. A corrupt store reads as empty,
which fails closed.
API: /api/v1/high-risk/{status,unlock,lock} are real rather than 501. Unlock
needs the bearer token, the master password header AND the factor header; a
wrong factor gets one indistinguishable reason so probing cannot tell failures
apart.
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 |
||
|
|
063fae191f |
fix(derive): the profile config's password policy is the base again (M-2)
Python builds its PasswordGenerator with
`policy=self.config_manager.get_password_policy()` and merges an entry's own
`policy` block over that base per derivation. The port merged the entry block
over hardcoded defaults and never read the profile config at all, so every
entry in a profile with a non-default policy derived a *different password*
than Python. Nothing failed anywhere; it reached the user as a wrong password,
which is indistinguishable from data loss at the point of use.
materializeSecret now takes a `basePolicy` and merges the entry block over it.
All three callers supply the profile config's policy:
- program.ts via basePolicyForOptions, resolved lazily so `entry list` and
friends don't pay a second BIP-39 seed derivation for a policy they never
use. `--vault` names an index file rather than a profile, so the config is
looked for beside it and falls back to defaults when absent.
- agent.ts from join(appDir, fingerprint), so a token-scoped reveal returns
what the owner sees.
- the TUI through a new sessionSecret helper that every menu path goes
through, so a future call site cannot silently omit the base.
`util generate-password` is deliberately unchanged: it has no entry and no
profile, so its flags are the whole policy. It now says so in its own help
text rather than leaving the reader to infer it.
Evidence: js/packages/cli/test/passwordPolicy.test.ts covers config-only v2,
an entry overriding exactly one field, config-only v1 and exclude_ambiguous,
against values computed by the Python implementation — each also asserted
unequal to what the pre-fix code produced, so a regression fails loudly rather
than needing someone to notice a changed constant. Mutation-verified twice:
dropping basePolicy from the merge in secrets.ts, and making
basePolicyForOptions return {}, each turn all four red.
Found by the 2026-08-18 independent audit (js/SECURITY_AUDIT.md). The parity
fixture suite could not have caught this: those fixtures call generatePassword
with one already-resolved policy, so they prove the algorithm and say nothing
about where the policy came from.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
977fbbeec9 |
refactor(agent): remove dead code that read as security controls
Two blocks looked load-bearing under review and never executed. authorize() took an optional entry and checked kind/label constraints against it -- but no call site ever passed one; the real constraint check is tokenMaySee, after the vault opens. The parameter and branch are gone, and the split is now documented where it matters: a use is consumed at pre-auth even when the entry turns out missing or uncovered, deliberately, so probing entry ids costs the prober their limited uses instead of handing back a free enumeration budget. sinkEnv() deleted FORBIDDEN_ENV keys from an env built off an allowlist that never contains them -- a loop that could suggest the delete, rather than the allowlist, is what keeps the seed out of sink children. Replaced with a prefix guard inside the allowlist loop (nothing SEEDPASS_-prefixed rides along even if a future edit adds one) and a comment stating plainly that the allowlist is the control and this is the backstop. `extra` stays exempt: the exec sink's whole job is injecting SEEDPASS_SECRET. No behaviour change; 149 CLI + 220 core tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8 |
||
|
|
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 |
||
|
|
b0e2a309a4 |
feat: generate a master seed, and fix three bugs found by using the CLI
Driving the CLI from an empty app directory turned up a gap that no test
covered: there was no way to create a vault. Every path assumed you already
had a seed and told you to export SEEDPASS_MNEMONIC, so a new user could not
get started at all. Python has generate_bip85_seed; the port did not.
fingerprint create fills that in with the same construction Python uses --
32 bytes of OS entropy through BIP-85 app 39 index 0. The entropy call sits
outside any try/catch on purpose, carried over from Python's entropy audit:
nothing may catch a CSPRNG failure and substitute a value.
Where the phrase goes is a deliberate choice rather than a default. This is
the one secret in SeedPass with no other copy, so:
- the destination is validated BEFORE generating, and delivery happens
BEFORE the profile is created -- a failure at either point leaves
nothing behind rather than a vault whose seed nobody has;
- --out writes 0600 with O_EXCL, so an existing file is never clobbered;
- with no destination and a non-TTY stdout it refuses outright, instead of
writing the only copy of a seed into a pipe -- a log, a transcript, an
agent's context.
Also fixed, all found the same way:
- use --exec always exited 0, so chaining a deploy command after it with
&& reported success even when the deploy failed. The sink child's exit
status is now the CLI's own.
- the sink command spec split on whitespace and ignored quotes, so
--exec 'sh -c "..."' -- the most common form there is -- handed sh the
fragments of the script and died on an unterminated string.
- --tags work,dev silently stored a single tag named "work,dev" that no
search would ever match. Commas and spaces both separate now.
Two normal states reported raw errno: a stopped agent said
'connect ENOENT /.../agent.sock', and an occupied --out path said EEXIST.
The capability map claimed ssh/pgp were unported. Both work and are at
parity with Python; automation branches on that list, so it now describes
this build.
Verified against Python on a TS-generated seed: same fingerprint, same
password, same TOTP code. 320 JS tests and 36/36 cross-impl checks pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
||
|
|
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]> |
||
|
|
274bc0e1b8 |
TS port slice 10: scoped tokens, agent-side enforcement, audit chain
The section-9.3 permission model is now working code, enforced where the keys live: - Token model (aligned with the Python agent surface): bearer tokens with read/use/reveal scopes, entry-kind and label-regex constraints, TTL and use counts. Issued and held by the session agent — hash-stored, plaintext shown exactly once at issuance; list/revoke commands. - Token mode: with SEEDPASS_TOKEN set (and no mnemonic), the CLI reads the decrypted index and materializes secrets AGENT-SIDE. The constrained process never receives the mnemonic, and resolveMnemonic refuses owner fallback in token mode — a token can never silently escalate (the test suite caught exactly this hole and it is now closed). - Denials are precise and audited: wrong scope, wrong kind, unmatched label, expired, exhausted, revoked. Reads don't consume uses; secret deliveries do. - Audit chain in Python AuditLogger's scheme: sig = HMAC-SHA256(key_index, prev_sig + canonical_payload), genesis 64 zeros, per-profile audit.log; agent records unlocks, token lifecycle, index reads, deliveries and denials (never secret values). audit-verify/audit-tail commands; a forged record breaks the chain in tests. - 151 core + 41 CLI tests green. Co-Authored-By: Claude Fable 5 <[email protected]> |
||
|
|
841d0984b3 |
TS port slice 8: profiles, config, and the session agent
- Python-compatible ~/.seedpass layout: fingerprints.json (same shape, indent=4), per-profile dirs with parent_seed.enc (kdf/ct wrapper; unlock honors argon2id metadata and falls back across pbkdf2 iteration counts), index-key-encrypted seedpass_config.json.enc with ConfigManager defaults. - CLI: fingerprint list/add/switch/remove (remove requires --yes), config get/set (JSON-parsed values, pin/password hashes redacted on read), profile-aware vault resolution (--vault override, else active profile). - Session agent (the chosen unlock model): unix-socket daemon holding unlocked seeds in memory with TTL, 0600 socket; agent start/status/stop, vault unlock (password -> parent seed -> agent, never argv) and vault lock. Seed resolution order: SEEDPASS_MNEMONIC env, then agent. This daemon is the designed enforcement point for the section-9.3 lease/token layer. - Tests cover the full no-env-mnemonic lifecycle: locked error -> unlock with password only -> provision/reveal through the agent -> status -> lock -> locked again. 148 core + 29 CLI tests green. Co-Authored-By: Claude Fable 5 <[email protected]> |