feat(#87)!: K1 — KeyState + pure key-state fold (sans-io KERI core)#134
Merged
Conversation
Design for the sans-io KERI core foundation: a KeyState value type and a pure decide/apply fold in the keri-rs crate (not cesr, for semver isolation). Locked decisions: - validate() does threshold arithmetic only; crypto verify stays upstream at the byte seam (keeps the fold pure + serialization-agnostic). - Serialization-generic via one borrowed KeriEvent (decision A), Cow<[T]> lists; no Event trait until a second backend arrives (YAGNI). - Weighted thresholds included, satisfied in keri-rs over the public Tholder enum (exact rational arithmetic) — zero cesr change. - K1 cesr footprint = remove the vestigial owned cesr::keri::KeyState (breaking). Spawns #129 (zero-copy event input) and #130 (lean Tholder) as follow-up cards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grounding the plan surfaced that rotation's next-key-commitment check requires hashing revealed keys (digest(key.qb64b) vs committed ndigers), which lives behind cesr's `crypto` feature. Corrected the spec: keri-rs enables `crypto` for that hash only (serialization-agnostic, not IO, not a signature verify); signature verification still stays upstream at the seam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phased TDD plan for KeyState + the pure fold: threshold satisfaction first (simple + weighted, exact rational), then Rejection/KeyState types, then validate/apply per ilk (inception, interaction, rotation incl next-key commitment), sequence/boundary/differential tests, no_std/wasm gate. Spec refinements found during plan self-review: - KeyState.prefix is Identifier<'a> (not Prefixer<'a>) — self-addressing AIDs are Saiders. - apply narrows the KeriEvent variant once in fold::apply, so no ilk apply carries an unreachable arm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BREAKING: computed key state moves to the keri-rs crate (folded KeyState<'a>).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Threshold satisfaction is security-critical; the u64::try_from(len) branch returned true on failure (fail-open). Compare distinct-count in usize space and treat a threshold exceeding usize::MAX as unsatisfied — removes the sole fail-open path (was unreachable, but keeps the function fail-closed throughout). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An inception carrying an empty weighted threshold ("kt":[]) was accepted with
no signatures: satisfied_by returned true for Weighted(vec![]) (vacuous), and
inception validation only checked the Simple threshold arm. Fix both layers:
- threshold::satisfied_by returns false for an empty clause-list.
- inception::check_keys_and_threshold rejects a weighted threshold that is
empty, has an empty clause, or whose flattened weight count exceeds the key
count (keripy eventing.py: reject when tholder.size > len(keys)).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…state
Change Accepted from a struct into a #[non_exhaustive] enum whose variants carry
the already-narrowed inner event (and, for later phases, the prior state). Phase
4 defines only Accepted::Inception { event, delegator, resolved_witnesses }.
apply drops its Option<KeyState> parameter and matches the enum, so an
established event with no prior state is no longer representable — deleting the
stub_state fabrication that returned a bogus KeyState (empty keys, Simple(0),
transferable:false). inception::apply now takes the narrowed &InceptionEvent,
Option<&Prefixer>, and the resolved witnesses. The rotation/interaction apply
stubs are removed (re-added with the new variants in Phases 5/6); their validate
stubs remain.
Variants are #[non_exhaustive] so downstream crates can neither construct nor
exhaustively destructure Accepted, preserving the fold-only construction
invariant the struct's pub(crate) fields gave.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the `Accepted::Interaction` variant (carrying the narrowed `InteractionEvent` plus a boxed clone of the prior `KeyState`) and implement the interaction fold step: - `interaction::validate` enforces the keripy `eventing.py` interaction rules: estOnly forbids interactions, sn must be exactly prior.sn+1, prior-event digest must match the state's latest SAID, signer indices must be in range, and the signing threshold must be met. - `interaction::apply` advances only sn/said/ilk; keys, thresholds, next-key commitment, witnesses, config, delegator, transferability, and lastEst all carry over unchanged. API adaptations (called out per active-development discipline): - `validate`'s `state` param is now `Option<&KeyState<'a>>` (was `<'_>`): the returned `Accepted<'a>` carries a clone of the prior state, so the prior must share the event lifetime. - `Accepted::Interaction.prior` is `Box<KeyState<'a>>` (not bare `KeyState<'a>`) to satisfy `clippy::large_enum_variant` without an unauthorized `#[allow]`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 of the K1 KeyState fold. Adds the `Accepted::Rotation` variant and implements `rotation::validate`/`apply`, enforcing the security-critical next-key commitment: revealed keys must hash to the prior state's committed digests and satisfy the prior next-key threshold. The commitment check fails closed on any digest-build error. Validation rules: sequential sn, prior-digest match, well-formed new key set/threshold, next-key commitment, witness cut/add validity + witness threshold bound, and new signing-threshold satisfaction. Rotation is an establishment event, so `apply` rolls keys/next/witnesses forward and advances the last-establishment pointer. This is the STRICT full-rotation form (positional match, revealed count == committed count). keripy's `Kever.exposeds` maps revealed keys to committed digests via the siger dual-index (`ondex`) and is partial-rotation capable; that is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
keripy requires witness removals and additions to be disjoint. Previously an overlapping prefix was removed then silently re-added (a no-op "keep"), diverging from keripy's disjointness rule. Reject the overlap as InvalidEvent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build icp -> ixn@1 -> rot@2 -> ixn@3 in order against the running state (validate + apply to learn each SAID), then drive the same owned events through the public fold() and assert the final state: sn 3, latest ilk Ixn, keys rotated to k1 at sn 2, last establishment at sn 2, next-key commitment to k2. Also adds an inception_with_witnesses fixture to common/mod.rs (used by the boundary sweep in the following commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One test per Rejection path not covered by the per-ilk fold tests, each asserting the exact RejectionReason: - duplicate inception (validate(Some, Inception)) -> InvalidEvent - interaction with stale prior digest -> PriorDigestMismatch - interaction with no signatures -> MissingSignatures - signer index out of range -> InvalidEvent - witness threshold (TOAD) > witness count -> InvalidEvent The empty-weighted-threshold path is already covered in fold_inception and is not duplicated (noted in a comment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 8 of K1 KeyState: prove the keri-rs fold agrees with the keripy reference Kever on a happy-path single-sig transferable KEL (inception -> rotation -> interaction). - scripts/keripy_keystate_gen.py: keripy-driven generator. Builds and signs the KEL with real Ed25519 signers, folds it through keri.core.eventing.Kever, and emits keri/tests/corpus/keystate.jsonl — event bytes (base64 of serder.raw) plus keripy's authoritative folded state. Pin v2.0.0.dev5-1030-gde59bc7d, V1 JSON. - keri/tests/differential.rs: base64-decodes each event, parses via cesr's public serder deserialize_event, folds via the real keri::fold, and asserts the resulting KeyState (prefix/sn/keys/next_keys/toad/witnesses) matches the keripy-derived expected values with exact assert_eq!. Expected values come from keripy's Kever, never from this crate's fold (non-circular). - keri dev-deps: serde, serde_json, base64 (test-only). Finding: cesr::serder::deserialize_event parses keripy's real V1 JSON KEL bytes, including a basic (Ed25519 `D`-code) prefix — keripy's default single-key derivation, which cesr's own InceptionBuilder does not emit (it always produces self-addressing prefixes). No serder gap found. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- taplo: reorder dev-deps in keri/Cargo.toml (base64 alphabetized) - rustfmt: reflow next_keys/witnesses collect chains in keri/tests/differential.rs - typos: allowlist keripy's `ser` serialization kwarg in _typos.toml - clippy: .err().expect() -> .expect_err() in cesr matter builder tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delegated inception/rotation (`dip`/`drt`) folding requires verifying the delegator's authorizing seal against the delegator's KEL — that is K4 (delegation) scope, which K1 has neither the KEL nor an escrow for. The prior dispatch routed `dip`/`drt` through the base-event path, whose `apply` hardcoded `latest_ilk = Icp`/`Rot` (wrong for delegated events) and, worse, accepted them without any delegation authorization. `validate` now fails closed: `DelegatedInception`/`DelegatedRotation` are rejected as the new `RejectionReason::DelegationUnsupported` regardless of prior state. The now-unreachable delegated arms in the `narrow` helpers are dropped, and the `delegator` param/field is removed from `Accepted::Inception` and `inception::apply` (K1's fold always yields `KeyState.delegator = None`; K4 will populate it). BREAKING CHANGE: adds `RejectionReason::DelegationUnsupported` (enum is `#[non_exhaustive]`); removes the `delegator` field from `Accepted::Inception`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The crate-level doc still described the K0 skeleton ("no KERI types yet").
Replace it with an accurate summary of the pure decide/apply key-state fold
(validate -> Accepted -> apply, plus fold) and state the two trust
boundaries: signatures are verified upstream (the fold reads indices only),
and delegation authorization is deferred to K4 (delegated events rejected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add fold_delegation.rs: build real `dip`/`drt` events via serder's DelegatedInception/DelegatedRotationBuilder, deserialize, and assert `validate` rejects them as `DelegationUnsupported` in every state case. Add fold_props.rs: three proptest cases exercising the real validate/apply/fold — sn advances by exactly one per event, interactions preserve establishment state, and the inception signing-threshold boundary (accept iff >= t distinct in-range indices, else MissingSignatures). Add `inception_multi`, `delegated_inception`, `delegated_rotation` fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… their data) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lone Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…apply Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ocations Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #87.
Summary
keri-rscrate: aKeyStatevalue type and a pure decide/apply fold (validate→Accepted→apply, plusfold) — keripy'sKever, minus the database. Consumes only cesr's public API.validateenforces KERI structural rules + threshold arithmetic over an already-verified signer index-set for inception, rotation (incl. the next-key commitment hash), and interaction. It performs no signature verification — an explicit upstream trust boundary documented on the fold. Delegated events (dip/drt) are rejected fail-closed and deferred to K4 (K4 · Full delegation validation over KelProvider (completes #83 under sans-io) #90).applyis an infallible fold; theAcceptedtypestate makes "an established event with no prior state" unrepresentable — no panic/unreachable arm.Validated against keripy
A KEL generated by keripy's own
Keveris folded bykeri-rsand matches keripy's state field-for-field (differential.rs+ keripy-generated corpus) — a true cross-implementation check, incl. a BasicD-prefix path cesr's own builders never emit.Breaking / notable
cesr::keri::KeyState; computed key state now lives inkeri-rs.Debug/PartialEq/Eq;CloneonSeqner/Number) on core primitives.keri-rsenables cesr'scryptofeature only for the next-key-commitment hash, not signature verification.Quality
Each phase spec- and adversarially reviewed (soundness probes on threshold arithmetic, inception, rotation commitment). A principal-level audit (two independent auditors, every finding backed by a runnable artifact) drove a behavior-preserving refactor: shared
fold/rules.rs(dedup security checks);applyconsumesAccepted(removes a redundantKeyStateclone/transition); dropped a compiler-proven-dead lifetime; iterator-basedsatisfied_by; added witness cut/add tests.Follow-ups
#129 · #130 · #132 · #133; notes on #88 (K2) and #90 (K4).
Test plan
nix flake checkgreen locally (clippy, fmt, taplo, audit, deny, nextest across feature combos, doctest, wasm32, no_std)keri-rstests + keripy differential pass🤖 Generated with Claude Code