Skip to content

Commit ec89c21

Browse files
release: 0.2.0-alpha.2 — rekey-aware fingerprint verify + violation vectors + fuzz + MIGRATION
Receiver-side hardening release. No wire-format change, no API break. Four additions: Tier 1 — correctness fix + spec hardening 1. Rekey-aware verify_consent_{request,response,revocation}. The helpers now probe BOTH current and previous session keys when comparing a consent message's session_fingerprint. A consent message signed moments before a rekey, AEAD-verified under the previous key during the grace window, now also passes the fingerprint check. Previously such in-flight messages false-rejected because the verifier only derived the local fingerprint from the current key. Factored out `Session::session_fingerprint_from_key(req_id, key)` as a private helper and added `verify_fingerprint_either_epoch` as the constant-time both-epochs probe. Regression test at `integration_consent::verify_probes_prev_key_during_rekey_grace`. 2. SPEC §12.8 timing-channel requirement. Spells out that the consent-verification pipeline (bincode deserialize, Ed25519 verify, fingerprint compare) MUST NOT branch on secret- dependent bytes. Reference impl ships a constant-time compare (`ct_eq_32`); alternate-language implementers are on the hook for auditing their bincode + Ed25519 equivalents. Flags bincode v1 fixed-size-struct deserialization as best-effort- but-not-guaranteed constant-time, with a fallback strategy. Tier 2 — defense-in-depth 3. Interop test vectors 10/11/12 for the three ConsentViolation variants. Event-sequence fixtures in a new line-oriented DSL (grammar documented in 10_revocation_before_approval.txt, separated from doc prose by `---BEGIN---` marker). Reference runner at `tests/violation_vectors.rs`. Alternate-language implementations can write a parallel runner purely from the fixture format — no dependency on this crate's types. Vectors exercise: - 10: RevocationBeforeApproval from both AwaitingRequest and Requested. - 11: ContradictoryResponse in both directions (Approved→Denied and Denied→Approved). - 12: StaleResponseForUnknownRequest from AwaitingRequest, Requested, and Approved. SPEC Appendix A + test-vectors/README.md updated. 4. cargo-fuzz target fuzz_observe_consent. Feeds arbitrary `Vec<ConsentEvent>` (via `arbitrary::Arbitrary`) into a ceremony-mode session and asserts four invariants per step: no panic; state is always a valid variant; seal-gate matches state per SPEC §12.7; violations never mutate state. The existing fuzz_replay_window target was also brought up-to- date with the post-alpha.4 four-argument accept() signature. Tier 3 — developer ergonomics 5. MIGRATION.md. Dedicated migration guide with before/after Rust snippets + minimal TypeScript sketches covering every API break between published versions. Primary content is the 0.1.x → 0.2.0-alpha.1 break; shorter entries for the other transitions. Paired with the per-release CHANGELOG entries. Tests 106 green (up from 101 at 0.2.0-alpha.1): - 44 lib unit - 14 integration_consent (+1: verify_probes_prev_key_during_rekey_grace) - 3 violation_vectors (new) - 11 test_vector_validation, 9 proptest_replay_window, 5 proptest_consent, 5 integration_rekey_replay, 10 integration_rekey, 4 smoke_fuzz, 1 doctest cargo clippy --all-features --all-targets -- -D warnings : clean cargo clippy --no-default-features --all-targets -- -D warnings : clean Fuzz crate compiles; fuzz_replay_window + fuzz_observe_consent both ready for `cargo +nightly fuzz run`.
1 parent 57761bd commit ec89c21

15 files changed

Lines changed: 1058 additions & 29 deletions

CHANGELOG.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.0-alpha.2] — 2026-04-18
11+
12+
Tracks **SPEC draft-03**. No wire-format change from 0.2.0-alpha.1;
13+
receiver-side hardening + developer-experience additions. All
14+
draft-03 peers on any mix of 0.2.0-alpha.1 / alpha.2 continue to
15+
interoperate at the wire level.
16+
17+
### Added
18+
19+
- **Rekey-aware fingerprint verification**
20+
([SPEC §12.3.1 rekey interaction]) — `Session::verify_consent_request`,
21+
`_response`, and `_revocation` now transparently probe BOTH the
22+
current and the previous session keys when comparing a consent
23+
message's embedded `session_fingerprint`. A consent message
24+
signed moments before a rekey — AEAD-verified under the previous
25+
key during the rekey grace window — now also passes the
26+
fingerprint check. Previously, such in-flight messages would
27+
false-reject because the verifier derived the local fingerprint
28+
only from the current key, while the sender had derived from
29+
the (now-previous) key.
30+
- Internal helper `Session::session_fingerprint_from_key(request_id,
31+
key)` factored out of the public `session_fingerprint`.
32+
- Internal probe `verify_fingerprint_either_epoch` tries current
33+
then previous; returns `false` iff neither matches.
34+
- Regression test:
35+
`integration_consent::verify_probes_prev_key_during_rekey_grace`.
36+
37+
- **Interop test vectors 10 / 11 / 12** for the three
38+
`ConsentViolation` variants. Event-sequence line-oriented
39+
fixtures (new format; grammar documented in
40+
`test-vectors/10_revocation_before_approval.txt`) exercise
41+
`RevocationBeforeApproval`, `ContradictoryResponse`, and
42+
`StaleResponseForUnknownRequest` under adversarial ordering.
43+
Ref-impl runner in `tests/violation_vectors.rs`; alternate-
44+
language implementations can write a parallel runner from the
45+
vector format alone. Format separates doc prose from the
46+
machine-parseable script via an explicit `---BEGIN---` marker.
47+
48+
- **`cargo-fuzz` target `fuzz_observe_consent`** — coverage-guided
49+
fuzzer for the consent state machine. Feeds arbitrary
50+
`Vec<ConsentEvent>` (via `arbitrary::Arbitrary`) into a
51+
ceremony-mode session and asserts four invariants on every
52+
step: no panic, state always a valid variant, seal-gate matches
53+
state per SPEC §12.7, and violations never mutate state.
54+
Enable via `cargo +nightly fuzz run fuzz_observe_consent`. The
55+
existing `fuzz_replay_window` target was also updated to the
56+
post-alpha.4 four-argument `ReplayWindow::accept` signature.
57+
58+
- **`MIGRATION.md`** — a dedicated migration guide with
59+
before/after Rust snippets + TypeScript sketches for every
60+
API break between published versions. Primary content: the
61+
0.1.x → 0.2.0-alpha.1 break. Paired with the per-release
62+
`CHANGELOG` entries.
63+
64+
- **SPEC §12.8 timing-channel requirement** — added a
65+
load-bearing assumption paragraph specifying that the
66+
consent-verification pipeline (bincode deserialization,
67+
Ed25519 verify, fingerprint compare) MUST NOT branch on
68+
secret-dependent bytes. The reference implementation ships a
69+
constant-time compare (`ct_eq_32`); alternate-language
70+
implementers are on the hook for auditing their bincode +
71+
Ed25519 equivalents. Also enumerates the caveat that
72+
fixed-size bincode v1 structs are best-effort constant-time
73+
but not guaranteed — implementations that cannot assert the
74+
property SHOULD fall back to comparing the re-serialized
75+
bytes against the wire slice before invoking Ed25519 verify.
76+
77+
### Changed
78+
79+
- Test vector manifest in SPEC Appendix A + `test-vectors/README.md`
80+
gained rows 10/11/12 for the new event-sequence fixtures.
81+
82+
### Not changed
83+
84+
- Wire format (envelope + signed canonical bodies) unchanged.
85+
- No API break.
86+
- `consent` feature dependency set unchanged.
87+
1088
## [0.2.0-alpha.1] — 2026-04-18
1189

1290
Tracks **SPEC draft-03**. **Breaking wire change at the signed-

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "xenia-wire"
3-
version = "0.2.0-alpha.1"
3+
version = "0.2.0-alpha.2"
44
edition = "2021"
55
rust-version = "1.85"
66
authors = ["Tristan Stoltz <tristan.stoltz@evolvingresonantcocreationism.com>"]

MIGRATION.md

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
# Migration guide
2+
3+
Worked examples for every API break between published
4+
`xenia-wire` versions. Paired with the per-release entries in
5+
`CHANGELOG.md` — this file focuses on the concrete *before*
6+
and *after* of integrator code, not the rationale for each
7+
change.
8+
9+
Rust is the reference language; a concise TypeScript sketch
10+
follows each Rust example to help alternate-language
11+
implementers validate their own migration.
12+
13+
---
14+
15+
## 0.1.x → 0.2.0-alpha.1 (SPEC draft-03)
16+
17+
**Breaking at the signed-consent-body layer.** Envelope layout
18+
(§1–§11) is unchanged; FRAME / INPUT / FRAME_LZ4 traffic is
19+
still wire-compatible with any 0.1.x peer. Migration effort is
20+
concentrated in four places:
21+
22+
1. Struct-literal constructions of the three signed `Core` types.
23+
2. Pattern matches on `ConsentEvent`.
24+
3. Call sites for `Session::observe_consent`.
25+
4. Verify paths that previously called `ConsentRequest::verify`
26+
(etc.) directly.
27+
28+
You do NOT need to change anything at seal-path call sites that
29+
don't touch consent, nor at `Session::new` / `Session::builder`
30+
call sites.
31+
32+
### 1. Struct-literal `Core` constructions
33+
34+
The three signed bodies each gained a mandatory 32-byte
35+
`session_fingerprint`. The canonical field order is normative
36+
(SPEC §12.3) — do not reorder.
37+
38+
**Before (0.1.x):**
39+
40+
```rust
41+
use xenia_wire::consent::{ConsentRequest, ConsentRequestCore, ConsentScope};
42+
43+
let core = ConsentRequestCore {
44+
request_id: 7,
45+
requester_pubkey: tech_sk.verifying_key().to_bytes(),
46+
valid_until: 1_700_000_300,
47+
scope: ConsentScope::ScreenAndInput,
48+
reason: "ticket #1234".into(),
49+
causal_binding: None,
50+
};
51+
let request = ConsentRequest::sign(core, &tech_sk);
52+
```
53+
54+
**After (0.2.0-alpha.1) — recommended via the Session helper:**
55+
56+
```rust
57+
use xenia_wire::consent::{ConsentRequestCore, ConsentScope};
58+
59+
let core = ConsentRequestCore {
60+
request_id: 7,
61+
requester_pubkey: tech_sk.verifying_key().to_bytes(),
62+
session_fingerprint: [0; 32], // placeholder, overwritten below
63+
valid_until: 1_700_000_300,
64+
scope: ConsentScope::ScreenAndInput,
65+
reason: "ticket #1234".into(),
66+
causal_binding: None,
67+
};
68+
let request = session
69+
.sign_consent_request(core, &tech_sk)
70+
.expect("session has a key installed");
71+
```
72+
73+
The `Session::sign_consent_*` helpers derive the fingerprint
74+
from the session's current key + source_id + epoch + the core's
75+
`request_id`, overwrite the placeholder, and sign. Do NOT hand-
76+
fill the placeholder with a meaningful value and then call the
77+
raw `ConsentRequest::sign` — the receiver's fingerprint check
78+
will fail unless your value matches the HKDF derivation bit-for-
79+
bit.
80+
81+
**After — manual derivation (if you can't use the helper):**
82+
83+
```rust
84+
let fp = session
85+
.session_fingerprint(7)
86+
.expect("session has a key");
87+
let core = ConsentRequestCore {
88+
request_id: 7,
89+
requester_pubkey: tech_sk.verifying_key().to_bytes(),
90+
session_fingerprint: fp,
91+
valid_until: 1_700_000_300,
92+
scope: ConsentScope::ScreenAndInput,
93+
reason: "ticket #1234".into(),
94+
causal_binding: None,
95+
};
96+
let request = ConsentRequest::sign(core, &tech_sk);
97+
```
98+
99+
The same pattern applies to `ConsentResponseCore` (→
100+
`Session::sign_consent_response`) and `ConsentRevocationCore`
101+
(→ `Session::sign_consent_revocation`).
102+
103+
**TypeScript sketch (for alternate-language implementers):**
104+
105+
```ts
106+
// HKDF-SHA-256 per SPEC §12.3.1.
107+
const info = new Uint8Array(17);
108+
info.set(sourceId /* 8 bytes */, 0);
109+
info[8] = epoch;
110+
const be = new DataView(info.buffer);
111+
be.setBigUint64(9, BigInt(request_id), /* littleEndian = */ false);
112+
113+
const fingerprint = await hkdfSha256({
114+
salt: new TextEncoder().encode("xenia-session-fingerprint-v1"),
115+
ikm: sessionKey, // 32 bytes
116+
info,
117+
length: 32,
118+
});
119+
120+
const core = {
121+
request_id,
122+
requester_pubkey,
123+
session_fingerprint: fingerprint, // 32 bytes, field order matters
124+
valid_until,
125+
scope,
126+
reason,
127+
causal_binding: null,
128+
};
129+
const signature = await ed25519.sign(bincodeV1Encode(core), signingKey);
130+
```
131+
132+
### 2. `ConsentEvent` variants carry `{ request_id }`
133+
134+
Every event is now a struct-shape carrying the `request_id` of
135+
the message it describes. The state machine uses it to
136+
distinguish legitimate ceremony progression from protocol
137+
violations (SPEC §12.6.1).
138+
139+
**Before:**
140+
141+
```rust
142+
use xenia_wire::consent::ConsentEvent;
143+
144+
session.observe_consent(ConsentEvent::Request);
145+
session.observe_consent(ConsentEvent::ResponseApproved);
146+
```
147+
148+
**After:**
149+
150+
```rust
151+
use xenia_wire::consent::ConsentEvent;
152+
153+
session
154+
.observe_consent(ConsentEvent::Request { request_id: 7 })?;
155+
session
156+
.observe_consent(ConsentEvent::ResponseApproved { request_id: 7 })?;
157+
```
158+
159+
On the send side, you know `request_id` because you just picked
160+
it. On the receive side, pull it from the deserialized core:
161+
`ConsentEvent::Request { request_id: received_req.core.request_id }`.
162+
163+
### 3. `observe_consent` now returns `Result`
164+
165+
Legal transitions and benign no-ops return `Ok(state)`; protocol
166+
violations (RevocationBeforeApproval, ContradictoryResponse,
167+
StaleResponseForUnknownRequest) return
168+
`Err(ConsentViolation)`. On violation the session state is NOT
169+
mutated; the caller's contract is to tear down the session.
170+
171+
**Before:**
172+
173+
```rust
174+
let state = session.observe_consent(ConsentEvent::Request);
175+
```
176+
177+
**After:**
178+
179+
```rust
180+
use xenia_wire::consent::ConsentViolation;
181+
182+
match session.observe_consent(ConsentEvent::Request { request_id: 7 }) {
183+
Ok(state) => { /* continue */ }
184+
Err(ConsentViolation::RevocationBeforeApproval { request_id }) => {
185+
// Hard fault — peer is broken or compromised. Tear down.
186+
return Err(MyAppError::PeerMisbehaved(request_id));
187+
}
188+
Err(ConsentViolation::ContradictoryResponse { request_id, prior_approved, new_approved }) => {
189+
// User tried to change their mind via a contradictory
190+
// Response; the correct primitive is Revocation. Log +
191+
// tear down; the peer's state machine is broken.
192+
return Err(MyAppError::ContradictoryConsent { request_id });
193+
}
194+
Err(ConsentViolation::StaleResponseForUnknownRequest { request_id }) => {
195+
return Err(MyAppError::StaleConsent(request_id));
196+
}
197+
}
198+
```
199+
200+
`ConsentViolation` implements `std::error::Error` via
201+
`thiserror`, so `?`-propagation works if your error type
202+
implements `From<ConsentViolation>` (or just map it).
203+
204+
### 4. Prefer `Session::verify_consent_*` over raw `.verify()`
205+
206+
`ConsentRequest::verify` (etc.) still exists and still checks
207+
the Ed25519 signature. But it does NOT check the session
208+
fingerprint — that's specific to the receiver's session state,
209+
which the raw method doesn't have access to. The draft-03
210+
verification contract (SPEC §12.3) requires BOTH checks.
211+
212+
**Before:**
213+
214+
```rust
215+
if received_req.verify(Some(&expected_pubkey)) {
216+
// OK
217+
}
218+
```
219+
220+
**After:**
221+
222+
```rust
223+
if session.verify_consent_request(&received_req, Some(&expected_pubkey)) {
224+
// OK — signature + fingerprint + pubkey all check out
225+
}
226+
```
227+
228+
The `Session::verify_consent_*` helpers transparently probe
229+
both the current AND previous session keys for the fingerprint
230+
compare, so a consent message signed just before rekey still
231+
verifies during the grace window (added in 0.2.0-alpha.2; if
232+
you're targeting alpha.1, the probe is current-key-only).
233+
234+
If you need only signature verification for an external audit
235+
log — and you don't have access to the signing session — the
236+
raw `.verify()` is still the right call. The draft-03
237+
fingerprint is only checkable with the session key; an auditor
238+
without it can still validate the pubkey-to-signature binding.
239+
240+
### 5. Test vector regeneration
241+
242+
If you pinned against vectors 07/08/09 from 0.1.x, their bytes
243+
changed — the signed body's canonical encoding gained
244+
`session_fingerprint`. Regenerate with:
245+
246+
```console
247+
$ cargo run --example gen_test_vectors --all-features
248+
```
249+
250+
The fingerprint value for vectors 07/08/09 under the fixture
251+
key is `5b94fb75dd4d499825c7f26f32dea7dce067d59a5200584a2c9d7d9e18dfd7d4`
252+
— same across all three (shared session + same `request_id=7`).
253+
Alternate-language implementations that regenerate the
254+
fingerprint locally under the fixture key MUST match that hex.
255+
256+
Vectors 10 / 11 / 12 are new event-sequence fixtures for the
257+
three `ConsentViolation` variants. Format documented in
258+
`test-vectors/10_revocation_before_approval.txt`.
259+
260+
---
261+
262+
## 0.2.0-alpha.1 → 0.2.0-alpha.2
263+
264+
No API break. Two receiver-side improvements:
265+
266+
1. `Session::verify_consent_*` now transparently probes the
267+
**previous** session key for the fingerprint compare. A
268+
consent message signed moments before a rekey (in flight
269+
during the grace window) now verifies correctly; previously
270+
it would false-reject. No code change required on integrator
271+
side — the behavior change is internal to the verify path.
272+
2. SPEC §12.8 documents the timing-channel assumption on the
273+
verify pipeline (bincode deserialize + Ed25519 verify +
274+
constant-time fingerprint compare). Alternate-language
275+
implementers SHOULD audit their equivalents.
276+
3. Event-sequence test vectors 10/11/12 ship for the three
277+
`ConsentViolation` variants.
278+
4. A cargo-fuzz target `fuzz_observe_consent` is added to
279+
exercise the transition table under adversarial input.
280+
281+
No migration needed; just bump the dep.
282+
283+
---
284+
285+
## Older transitions (reference)
286+
287+
### 0.1.0-alpha.4 → 0.1.0-alpha.5 (SPEC draft-02r2)
288+
289+
Covered in full in `CHANGELOG.md`. Summary:
290+
291+
- `ConsentState::Pending` split into `LegacyBypass` (default
292+
for `Session::new`, sticky) and `AwaitingRequest` (opt-in via
293+
`SessionBuilder::require_consent(true)`). Exhaustive matches
294+
on `ConsentState` gain two new arms.
295+
- New `SessionBuilder` at `Session::builder()`.
296+
- Replay window now parameterized via
297+
`SessionBuilder::with_replay_window_bits(bits)` — valid
298+
values 64, 128, 256, 512, 1024. Default remains 64.
299+
300+
### 0.1.0-alpha.3 → 0.1.0-alpha.4
301+
302+
Pure reference-impl bug fix (per-key-epoch replay window,
303+
issue #5). No API change. See `CHANGELOG.md` 0.1.0-alpha.4
304+
entry for the bug details.

0 commit comments

Comments
 (0)