Document the second pass and what is still open
This commit is contained in:
parent
53136cc38f
commit
557f98e1f9
82
DESIGN.md
82
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 <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
|
||||
|
||||
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.
|
||||
4. Deprecate passcode login across the platform, then rotate live credentials.
|
||||
Loading…
x
Reference in New Issue
Block a user