diff --git a/DESIGN.md b/DESIGN.md index 040948d..4b52f97 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -111,6 +111,29 @@ capability. Codes are single-use, stored as a hash rather than plaintext, use an alphabet with no `0/O` or `1/I`, and tolerate the dash being dropped when retyped. +### A human session is not constructible by hand + +`issue_view_session` takes a `GrantedCode`, not an agent name. `GrantedCode`'s +constructor raises; the only way to hold one is to have redeemed a code, so the +scopes on a human session are always the ones the issuer allowed and always the +ones a redemption path was walked to get. There is no in-process shortcut, which +means a future route cannot pass a string where a proof belongs. + +### Suspension is not death + +`revoke ` with no end time is permanent: the identity is dead until +someone re-enrols it. `revoke --until ` is a pause, and it lapses +on its own. + +The distinction matters because the two failure modes are different. A key +compromise wants death — a lapsing revocation would hand the attacker back in +when the timer ran out, which is a revocation that silently fails at exactly the +wrong moment. A noisy neighbour or a holiday wants a pause, and re-enrolling for +that would destroy the audit history that says it was deliberate. + +Revoking kills live sessions and drops outstanding challenges, so a revocation +that races an in-flight authentication cannot be overtaken by it. + ### Existing controls stay Per the decision taken: **existing human controls are unchanged.** Quill and @@ -157,35 +180,58 @@ enrolment, challenge, verification, code issuance, redemption, refusal and revocation, with actor, subject and key fingerprint. Each row folds in the previous row's digest, so editing or deleting an event -breaks every digest after it, and `verify_chain` reports the first bad id. That -makes tampering *detectable*. It does not make it *impossible*: an attacker with -write access to the database can recompute the chain, because the digests are -unkeyed. To commit to a log you need the head digest anchored outside the -database's reach, or signed. Worth doing when the attestations start mattering, -not before. +breaks every digest after it, and `verify_chain` reports the first bad id. + +That alone makes tampering *detectable* but not *impossible*: an attacker with +write access to the database can recompute every digest, because they are +unkeyed. The tested version of that attack is a database attacker who deletes +history, forges replacements, and rebuilds the chain — `verify_chain` passes. + +So the head digest also gets **signed checkpoints**, by an enrolled agent whose +private key the database does not contain. `verify-chain` runs both checks, and +the rewrite is caught by the signature the attacker cannot produce. A forged +checkpoint fails signature verification, a checkpoint from a stranger is refused +at signing time, and a rekey invalidates every earlier checkpoint — which is +correct, and says so rather than failing quietly. + +What this still does not do is survive an attacker who can also delete the +checkpoints, because they live in the same database. Real deployment anchors the +head digest somewhere the database cannot reach. This narrows the attack from +"rewrite anything" to "erase the record that you did", and leaves that second +half open. Secrets are not in the log and not in the database in the clear. Codes and session tokens are stored as digests, enrolment secrets are hashed and spent -once. So a stolen database yields no live credential — which is only true +once. The log refers to credentials by truncated digest too, never by prefix: a +code is 8 characters from a 32-character alphabet, so the first four left about +20 bits, and the audit log is the artifact most likely to be exported or shared. So a stolen database yields no live credential — which is only true because the review looked for it. ## Status -80 passing tests, and a hardening pass that closed real defects. The worst were +106 passing tests, and two hardening passes that closed real defects. The worst were concurrency: one-time codes were redeemable repeatedly (753 sessions from 200 codes under load), and a bootstrap secret could enrol more than one key. Those are now atomic compare-and-set operations, each covered by a test that races twelve threads. Session tokens are stored hashed, keys can be encrypted, challenge issuance is rate limited, rekey revokes old sessions, and the log is -hash-chained. +hash-chained with signed checkpoints. + +The second pass closed: credentials leaking into the log as prefixes, human +sessions mintable from a bare agent name, no revocation operation at all, an +unkeyed chain that a database-level attacker could rebuild, and SQLite without +a busy timeout. **Still not production ready**, and the reason is not a to-do list: -- **No independent review.** Everything above is self-review. Self-review finds - the bugs you already suspected. This needs someone else. -- **The chain is unkeyed** — detectable, not tamper-proof (see *Audit trail*). +- **No independent review.** Everything above is self-review, twice over. + Self-review finds the bugs you already suspected. This needs someone else, + ideally a cryptographer, and it is the one blocker code cannot close. +- **Checkpoints are not anchored off-box.** Signed, yes; deleteable by the same + attacker along with everything else, yes. See *Audit trail*. - **The service is unwritten.** TLS, CSRF, transport, secret storage, routes. -- **Revocation has no expiry semantics**, and there is no key recovery. +- **Rate limits are per-process**, so N processes means N times the quota. +- **No key recovery.** Rotate and old sessions die. Deliberate. - **Humans remain unattributable** to themselves, which is accepted and is a cost, not an oversight. @@ -193,9 +239,11 @@ The tests show the logic is correct. They do not make it safe. ## Next -1. Independent security review before this guards anything real. The hardening - pass made it reviewable; it did not substitute for one. -2. Secret broker on top — scoped, short-lived leases bound to a key fingerprint. +1. Independent security review before this guards anything real. Two hardening + passes made it reviewable; neither substituted for one. +2. Anchor the checkpoint head digest outside the database, so deleting the + checkpoints stops being an option for an attacker who already has the file. +3. Secret broker on top — scoped, short-lived leases bound to a key fingerprint. Its issuance and redemption logs *are* the work-attestation substrate, so it is the natural second piece. -3. Deprecate passcode login across the platform, then rotate live credentials. \ No newline at end of file +4. Deprecate passcode login across the platform, then rotate live credentials. \ No newline at end of file