Skip to content

Commit 57761bd

Browse files
release: 0.2.0-alpha.1 (SPEC draft-03) — mandatory session binding + normative transition table
Phase B of the open-issues plan. Closes the remaining two items from the round-2 cryptographic review. Breaking wire change at the signed- consent-body layer; the envelope (SPEC §1-§11) is unchanged, so FRAME / INPUT / FRAME_LZ4 traffic is still interoperable with any 0.1.x peer. Issue #1 — Mandatory session_fingerprint (SPEC §12.3.1) Every signed consent body now carries a 32-byte session_fingerprint derived locally per-peer via HKDF-SHA-256: salt = b"xenia-session-fingerprint-v1" ikm = current session_key info = source_id || epoch || request_id_be (17 bytes) L = 32 Both peers derive the same value; receivers re-derive and compare in constant time. Closes the "loose session binding" replay-across- sessions gap that draft-02r1 documented as a known limitation. The big-endian request_id in `info` also buys per-ceremony replay protection: the fingerprint for request_id=7 is unrelated to the one for request_id=8, so a captured response for one ceremony cannot replay as a response to the other. API additions: - Session::session_fingerprint(request_id) -> [u8; 32] - Session::sign_consent_{request,response,revocation}(core, sk) - Session::verify_consent_{request,response,revocation}(msg, expected_pk) All three Cores gain `session_fingerprint: [u8; 32]` at a fixed position in canonical field order. Test vectors 07/08/09 regenerated (shared fingerprint 5b94fb75…d7d4 — same session + same request_id=7). Issue #3 — Normative transition table + ConsentProtocolViolation (SPEC §12.6.1) The consent state machine is fully normative now: every (state, event, request_id) tuple maps to a specific next state or one of three defined protocol violations. WireError::ConsentProtocolViolation(ConsentViolation) ConsentViolation::RevocationBeforeApproval { request_id } ConsentViolation::ContradictoryResponse { request_id, prior_approved, new_approved } ConsentViolation::StaleResponseForUnknownRequest { request_id } Key design decisions baked into the table: - LegacyBypass is sticky; an unsolicited Request does NOT auto- promote (prevents state-hijacking by a malicious peer). - Revocation from AwaitingRequest or Requested is a hard error (nothing to revoke; the peer is broken). - Contradictory Response (Denied-after-Approved, or vice-versa, on the same request_id) is a hard error. SPEC §12.6.2 records the UI guidance: a user "changing their mind" must emit a fresh ConsentRevocation, not a contradictory Response. - A higher request_id from any non-LegacyBypass state starts a fresh ceremony (terminal states are not absolute — you can always renegotiate). - On violation, the session state is NOT mutated. The wire does not own the transport; the caller decides whether to tear down. ConsentEvent variants now carry request_id: Request{request_id}, ResponseApproved{request_id}, ResponseDenied{request_id}, Revocation{request_id} Session::observe_consent now returns Result<ConsentState, ConsentViolation>. SPEC draft-03 - §1.4 version + breaking-change header - §1.4.1 compat matrix (envelope compatible; signed bodies not) - §12.3 three message structures updated with session_fingerprint, canonical field order declared normative - §12.3.1 NEW — session fingerprint derivation (HKDF-SHA-256 parameters, constant-time comparison requirement, rekey interaction, rationale for MANDATORY vs OPTIONAL) - §12.3.2 renumbered from old §12.3.1 (canonical bincode encoding) - §12.6 sticky LegacyBypass + state-hijacking rationale - §12.6.1 NEW — full normative transition table - §12.6.2 NEW — UI guidance for "change of mind" after approval - §12.7 FRAME gating header updated to draft-03 - §12.8 security-properties list: session binding is now TIGHT, new protocol-violation-detection bullet - App B draft-03 row Dependencies + hkdf = "0.12" (consent feature) + sha2 = "0.10" (consent feature) No new runtime deps when the consent feature is disabled. Tests 101 tests green (up from 95 at 0.1.0-alpha.5): - 44 lib unit (+1 new: consent_request_rejects_tampered_fingerprint) - 13 integration_consent (up from 7: +violation/fingerprint tests) - 5 proptest_consent + 9 proptest_replay_window - 5 integration_rekey_replay + 10 integration_rekey - 4 smoke_fuzz - 11 test_vector_validation (+1: vectors_07_08_09_share_session_fingerprint) - 1 doctest cargo clippy --all-features --all-targets -- -D warnings : clean cargo clippy --no-default-features --all-targets -- -D warnings : clean cargo package : 470.8 KiB (145.9 KiB compressed) Plan status All four round-2 review items now closed: - #1 session_binding field : closed (mandatory) - #2 split Pending : closed in 0.1.0-alpha.5 - #3 duplicate/conflict transitions : closed - #4 configurable replay window : closed in 0.1.0-alpha.5 Migration from 0.1.0-alpha.5 1. `Session::new` / `Session::builder` callers: no change. 2. Struct-literal constructions of the three Cores gain `session_fingerprint: [u8; 32]`. Use `Session::sign_consent_*` helpers to have the fingerprint derived+injected automatically. 3. Every `ConsentEvent::*` pattern match gains `{ request_id }`. 4. Every `observe_consent(...)` call returns `Result` — handle `ConsentViolation` as a session-teardown signal. 5. draft-03 consent bodies do not interoperate with 0.1.x — both sides must upgrade.
1 parent cf739dc commit 57761bd

22 files changed

Lines changed: 1623 additions & 504 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.2.0-alpha.1] — 2026-04-18
11+
12+
Tracks **SPEC draft-03**. **Breaking wire change at the signed-
13+
consent-body layer.** The envelope layout (SPEC §1–§11) is unchanged;
14+
FRAME / INPUT / FRAME_LZ4 traffic remains interoperable with any
15+
0.1.x peer. The breaking change is the canonical bytes of the three
16+
signed consent bodies — a 0.1.x peer cannot verify a 0.2.x consent
17+
signature (and vice-versa).
18+
19+
Closes the remaining two open-issue items from the round-2 review:
20+
21+
### Added
22+
23+
- **Mandatory session fingerprint on signed consent bodies**
24+
([SPEC §12.3.1]; closes
25+
[#1](https://github.com/Luminous-Dynamics/xenia-wire/issues/1)) —
26+
every `ConsentRequestCore`, `ConsentResponseCore`, and
27+
`ConsentRevocationCore` now carries a 32-byte
28+
`session_fingerprint`. Derived locally per peer:
29+
```
30+
fingerprint = HKDF-SHA-256(
31+
salt = b"xenia-session-fingerprint-v1",
32+
ikm = current session_key,
33+
info = source_id || epoch || request_id_be,
34+
L = 32,
35+
)
36+
```
37+
Both peers derive the same value from their own copy of the key.
38+
On verify, receivers re-derive locally and compare in constant time;
39+
a mismatch fails verification the same as a bad signature. This
40+
closes the "loose session binding" replay-across-sessions gap that
41+
draft-02r1 documented as a known limitation.
42+
- `Session::session_fingerprint(request_id)` — new public method.
43+
- `Session::sign_consent_request` / `_response` / `_revocation`
44+
— convenience methods that derive the fingerprint AND sign.
45+
- `Session::verify_consent_request` / `_response` / `_revocation`
46+
— convenience methods that check signature + fingerprint +
47+
optional pubkey match.
48+
- Test vectors 07/08/09 regenerated (shared fingerprint
49+
`5b94fb75…d7d4` — same session + same `request_id=7`).
50+
- Pure Rust, constant-time 32-byte compare (no new runtime deps
51+
beyond `hkdf` + `sha2`).
52+
53+
- **Normative consent state-machine transition table + violation
54+
detection** ([SPEC §12.6.1]; closes
55+
[#3](https://github.com/Luminous-Dynamics/xenia-wire/issues/3)) —
56+
the consent state machine is now fully normative: every (state,
57+
event, `request_id`) tuple maps to a specific next state or a
58+
specific protocol violation. Three violation variants surface as
59+
`WireError::ConsentProtocolViolation(ConsentViolation)`:
60+
- `RevocationBeforeApproval` — a `ConsentRevocation` observed
61+
while state is `AwaitingRequest` or `Requested` (revoking
62+
something that was never approved). The peer's state machine
63+
is broken or compromised.
64+
- `ContradictoryResponse{prior_approved, new_approved}` — a
65+
`ConsentResponse` whose `approved` contradicts a prior response
66+
for the same `request_id`. Rejected rather than accepted as
67+
"later wins"; the correct wire-level primitive for mind-changes
68+
is a fresh `ConsentRevocation` (SPEC §12.6.2 records the UI
69+
guidance explicitly).
70+
- `StaleResponseForUnknownRequest` — a `ConsentResponse` whose
71+
`request_id` was never `Requested`.
72+
- The wire does NOT tear down the transport on violation. The
73+
caller receives the error and decides. On a violation the
74+
session state is NOT mutated.
75+
76+
- **`ConsentEvent` carries `request_id`** — each variant is now a
77+
struct-shape:
78+
```rust
79+
pub enum ConsentEvent {
80+
Request { request_id: u64 },
81+
ResponseApproved { request_id: u64 },
82+
ResponseDenied { request_id: u64 },
83+
Revocation { request_id: u64 },
84+
}
85+
```
86+
Enables the request_id-sensitive transition rules above.
87+
88+
- **`ConsentViolation` enum** (in `xenia_wire::consent`) with the
89+
three variants described above. `thiserror::Error` so it formats
90+
cleanly in logs.
91+
92+
- **`WireError::ConsentProtocolViolation(ConsentViolation)`** — new
93+
variant surfaced by `Session::observe_consent`.
94+
95+
### Changed
96+
97+
- **`Session::observe_consent` signature**: now returns
98+
`Result<ConsentState, ConsentViolation>`. Legal transitions and
99+
benign no-ops return `Ok(state)`; protocol violations return
100+
`Err(violation)` with state unmutated. Callers MUST handle the
101+
`Result`.
102+
103+
- **`ConsentState`** no longer has a `Pending` variant (removed in
104+
0.1.0-alpha.5's rename to `LegacyBypass` / `AwaitingRequest`).
105+
0.2.0 retains those names.
106+
107+
- **SPEC draft-03**: §1.4, §1.4.1, §12.3, §12.3.1 (new, mandatory
108+
fingerprint), §12.3.2 (renumbered canonical encoding), §12.6
109+
(normative table), §12.6.2 (new UI-guidance subsection), §12.7
110+
(updated). Appendix B draft-03 row.
111+
112+
- Dependencies added: `hkdf = "0.12"`, `sha2 = "0.10"` (both under
113+
the `consent` feature). No new runtime dependencies when `consent`
114+
is off.
115+
116+
- `xenia-viewer-web` updated to use `Session::sign_consent_*` helpers;
117+
the consent walkthrough demo continues to work end-to-end.
118+
119+
### Migration
120+
121+
**Updating from 0.1.0-alpha.5 → 0.2.0-alpha.1:**
122+
123+
1. Existing `Session::new` / `Session::builder` callers — no change.
124+
`Session::new` still defaults to `LegacyBypass`; the sticky rule
125+
still holds.
126+
2. Code that constructs `ConsentRequestCore` / `ConsentResponseCore`
127+
/ `ConsentRevocationCore` gains a new field
128+
`session_fingerprint: [u8; 32]`. Set it to `[0; 32]` and sign via
129+
`Session::sign_consent_request` (etc.) — the helper overwrites
130+
the placeholder with the HKDF-derived value before signing. Or:
131+
derive manually via `Session::session_fingerprint(request_id)`
132+
and plug the result in before calling `ConsentRequest::sign`.
133+
3. Every `ConsentEvent::*` pattern match gains a `{ request_id }`
134+
struct binding. The event's request_id can be pulled from the
135+
corresponding consent message you just opened.
136+
4. Every `session.observe_consent(...)` call returns a `Result` now.
137+
Handle violations — the most common pattern is to propagate them
138+
up as session-teardown signals.
139+
5. Cross-implementation interop: draft-03 consent bodies will not
140+
verify against 0.1.x peers and vice-versa. Bump both sides.
141+
142+
### Plan status
143+
144+
- `#1 session_binding field`**closed** in this release (mandatory).
145+
- `#3 duplicate/conflict transition table`**closed** in this release.
146+
- All four originally-tracked design items are now closed.
147+
10148
## [0.1.0-alpha.5] — 2026-04-18
11149

12150
Tracks **SPEC draft-02r2**. No wire-format change from alpha.4 /

Cargo.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[package]
22
name = "xenia-wire"
3-
version = "0.1.0-alpha.5"
3+
version = "0.2.0-alpha.1"
44
edition = "2021"
55
rust-version = "1.85"
66
authors = ["Tristan Stoltz <tristan.stoltz@evolvingresonantcocreationism.com>"]
77
license = "Apache-2.0 OR MIT"
8-
description = "PQC-sealed binary wire protocol for remote-control streams: ChaCha20-Poly1305 AEAD with epoch rotation, 64-slot sliding replay window, and optional LZ4-before-seal. Pre-alpha — do not use in production."
8+
description = "PQC-sealed binary wire protocol for remote-control streams: ChaCha20-Poly1305 AEAD with epoch rotation, configurable sliding replay window (64-1024 slots), optional LZ4-before-seal, and signed consent ceremony with mandatory per-session fingerprint (HKDF-SHA-256). Pre-alpha — do not use in production."
99
repository = "https://github.com/Luminous-Dynamics/xenia-wire"
1010
homepage = "https://github.com/Luminous-Dynamics/xenia-wire"
1111
documentation = "https://docs.rs/xenia-wire"
@@ -28,8 +28,9 @@ reference-frame = []
2828
lz4 = ["dep:lz4_flex"]
2929
# Consent ceremony: ConsentRequest/Response/Revocation payloads + Ed25519
3030
# signing + session-level state machine (FRAME refused until consent;
31-
# revocation terminates). Corresponds to SPEC.md draft-02 §12.
32-
consent = ["dep:ed25519-dalek", "dep:serde-big-array"]
31+
# revocation terminates) + HKDF-SHA-256 session fingerprint binding.
32+
# Corresponds to SPEC.md draft-03 §12.
33+
consent = ["dep:ed25519-dalek", "dep:serde-big-array", "dep:hkdf", "dep:sha2"]
3334

3435
[dependencies]
3536
chacha20poly1305 = "0.10"
@@ -41,6 +42,8 @@ rand = "0.8"
4142
lz4_flex = { version = "0.11", default-features = false, features = ["safe-encode", "safe-decode"], optional = true }
4243
ed25519-dalek = { version = "2", default-features = false, features = ["std", "zeroize", "rand_core"], optional = true }
4344
serde-big-array = { version = "0.5", optional = true }
45+
hkdf = { version = "0.12", default-features = false, optional = true }
46+
sha2 = { version = "0.10", default-features = false, optional = true }
4447

4548
# wasm32 needs web-time for `Instant` (the std one panics on wasm32-unknown-unknown)
4649
# and getrandom's "js" feature to source entropy from crypto.getRandomValues.

0 commit comments

Comments
 (0)