Document the second pass and what is still open

This commit is contained in:
bigpickle 2026-10-05 21:51:25 +08:00 committed by leora
parent 53136cc38f
commit 557f98e1f9

View File

@ -111,6 +111,29 @@ capability.
Codes are single-use, stored as a hash rather than plaintext, use an alphabet 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. 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 <agent>` with no end time is permanent: the identity is dead until
someone re-enrols it. `revoke <agent> --until <unix>` 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 ### Existing controls stay
Per the decision taken: **existing human controls are unchanged.** Quill and 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. revocation, with actor, subject and key fingerprint.
Each row folds in the previous row's digest, so editing or deleting an event 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 breaks every digest after it, and `verify_chain` reports the first bad id.
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 That alone makes tampering *detectable* but not *impossible*: an attacker with
unkeyed. To commit to a log you need the head digest anchored outside the write access to the database can recompute every digest, because they are
database's reach, or signed. Worth doing when the attestations start mattering, unkeyed. The tested version of that attack is a database attacker who deletes
not before. 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 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 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. because the review looked for it.
## Status ## 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 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 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 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, twelve threads. Session tokens are stored hashed, keys can be encrypted,
challenge issuance is rate limited, rekey revokes old sessions, and the log is 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: **Still not production ready**, and the reason is not a to-do list:
- **No independent review.** Everything above is self-review. Self-review finds - **No independent review.** Everything above is self-review, twice over.
the bugs you already suspected. This needs someone else. Self-review finds the bugs you already suspected. This needs someone else,
- **The chain is unkeyed** — detectable, not tamper-proof (see *Audit trail*). 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. - **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 - **Humans remain unattributable** to themselves, which is accepted and is a
cost, not an oversight. cost, not an oversight.
@ -193,9 +239,11 @@ The tests show the logic is correct. They do not make it safe.
## Next ## Next
1. Independent security review before this guards anything real. The hardening 1. Independent security review before this guards anything real. Two hardening
pass made it reviewable; it did not substitute for one. passes made it reviewable; neither substituted for one.
2. Secret broker on top — scoped, short-lived leases bound to a key fingerprint. 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 Its issuance and redemption logs *are* the work-attestation substrate, so it
is the natural second piece. is the natural second piece.
3. Deprecate passcode login across the platform, then rotate live credentials. 4. Deprecate passcode login across the platform, then rotate live credentials.