Skip to content

Commit 0702afa

Browse files
release: 0.2.0-alpha.3 — round-3 reviewer feedback
Material change: verify_fingerprint_either_epoch now derives fingerprints against BOTH current AND previous session keys unconditionally during the rekey grace window, combining the constant-time compares with bitwise `|` on bool (not short-circuiting ||). Closes a timing distinguisher the reviewer flagged: the alpha.2 "try current, fall back on mismatch" logic leaked which key-epoch signed the consent via verify-path latency (one HKDF on match, two on mismatch). The extra HKDF call is only paid during the grace window. SPEC §12.3.1 rekey interaction now requires this behavior normatively. Same subsection gains a "design-evolution" note surfacing the shift from an earlier "bind to initial key" plan to the shipped "bind to current key, both-key probe on verify" — rationale: no wire-level representation of "initial"; preserving it fights Zeroize; grace window bounds probe cost the same way it bounds AEAD-verify. Documentation tightening: - §12.6 LegacyBypass bluntly flagged as intentional compatibility mode. A LegacyBypass session silently discards valid consent ceremonies by design; security-sensitive deployments MUST NOT use it. Deployments landing on it by accident are strictly less secure than deployments that opt into ceremony mode. - Appendix A vectors 07/08 relabeled draft-03 (stale "draft-02" caption — they were regenerated at draft-03 canonical bytes in 0.2.0-alpha.1 already). - plans/REVIEW_DELTA_DRAFT_03.md updated per reviewer feedback: - "No other wire changes" reassurance near the top. - 2.2 BE-request_id defense rewritten from "mixed-convention mistake-reduction" to "domain-separated deterministic encoding, not a semantic property of big-endian." - 2.3 marked RESOLVED in alpha.3; narrowed to a reviewer- confirmation question. - 2.6 narrowed from open-ended coherence check to specific "are there missing timing-channel sinks?" question. - One-line rekey-design-evolution callout in §1. No wire-format change. No API break. Peers on alpha.2 and alpha.3 interoperate at the wire level. 106 tests green. Clippy clean (--all-features and --no-default-features). `cargo package` unchanged in structure.
1 parent f030ed7 commit 0702afa

5 files changed

Lines changed: 224 additions & 65 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.2.0-alpha.3] — 2026-04-18
11+
12+
Tracks **SPEC draft-03**. Round-3-reviewer-prompted hardening
13+
pass. No wire-format change, no API break. Receivers on
14+
0.2.0-alpha.2 and 0.2.0-alpha.3 interoperate at the wire level.
15+
16+
### Security / Correctness
17+
18+
- **Constant-time `verify_fingerprint_either_epoch`** (SPEC
19+
§12.3.1 rekey interaction, now normative). The alpha.2
20+
implementation derived the fingerprint against the current
21+
session key, compared, and — on mismatch — derived against the
22+
previous session key. One HKDF call on match, two on mismatch
23+
— a timing distinguisher observable by an on-path attacker
24+
that leaks which key-epoch the sender signed under. alpha.3
25+
removes the distinguisher: when `prev_session_key` is present,
26+
the verifier derives fingerprints against BOTH keys
27+
unconditionally and ORs the constant-time compares with
28+
bitwise `|` (not short-circuiting `||`). The extra HKDF call
29+
is only paid during the rekey grace window.
30+
31+
The existing `verify_probes_prev_key_during_rekey_grace`
32+
integration test covers functional correctness; the
33+
constant-time property is a code-review assertion documented
34+
in the updated `src/session.rs::verify_fingerprint_either_epoch`
35+
and SPEC §12.3.1.
36+
37+
### Documentation
38+
39+
- **SPEC §12.3.1 rekey interaction**: now MANDATES the constant-
40+
time both-key derivation. Adds a "design-evolution" note
41+
surfacing the shift from an earlier "bind to initial key"
42+
design to the shipped "bind to current key, both-key probe on
43+
verify" design. Rationale captured: no wire-level
44+
representation of "initial key"; preserving an initial key
45+
fights zeroize; grace window bounds the probe cost
46+
symmetrically with AEAD-verify.
47+
- **SPEC §12.6 `LegacyBypass`** — now documented bluntly as
48+
intentional compatibility mode. A LegacyBypass session silently
49+
discards valid, cryptographically authenticated consent
50+
ceremonies **by design**. Security-sensitive deployments MUST
51+
NOT use `LegacyBypass`; deployments that land on it by
52+
accident are strictly less secure than deployments that opt
53+
into ceremony mode.
54+
- **SPEC Appendix A** test-vector labeling: 07/08 now correctly
55+
labeled as draft-03 (they were regenerated at the draft-03
56+
canonical bytes in `0.2.0-alpha.1`; the "draft-02" caption
57+
was stale).
58+
- **`plans/REVIEW_DELTA_DRAFT_03.md`** updated for round-3
59+
reviewer: prominent "no other wire changes" reassurance near
60+
the top; 2.2 BE-request_id defense rewritten as "domain-
61+
separated deterministic encoding, not semantic meaning"; 2.3
62+
timing side-channel marked RESOLVED (pointing at this
63+
release) and narrowed to a reviewer-confirmation question;
64+
2.6 narrowed from open-ended coherence check to a specific
65+
"are there missing timing-channel sinks?" question. Adds a
66+
one-line rekey-design-evolution callout.
67+
68+
### Not changed
69+
70+
- Wire format (envelope + signed canonical bodies) unchanged.
71+
- No API break.
72+
- No new dependencies.
73+
1074
## [0.2.0-alpha.2] — 2026-04-18
1175

1276
Tracks **SPEC draft-03**. No wire-format change from 0.2.0-alpha.1;

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.2"
3+
version = "0.2.0-alpha.3"
44
edition = "2021"
55
rust-version = "1.85"
66
authors = ["Tristan Stoltz <tristan.stoltz@evolvingresonantcocreationism.com>"]

SPEC.md

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -886,10 +886,37 @@ The reference implementation ships such a loop in
886886
session key. On rekey the fingerprint for the same `request_id`
887887
changes. Consent messages signed under the old key remain
888888
verifiable ONLY during the previous-key grace window (§6.2), and
889-
ONLY against the receiver's prev-key-derived fingerprint. A
890-
receiver implementing fingerprint verification MUST therefore
891-
re-derive against both the current and previous keys when the
892-
AEAD tag verified under the previous key, or reject the message.
889+
ONLY against the receiver's prev-key-derived fingerprint.
890+
891+
A receiver implementing fingerprint verification during the rekey
892+
grace window MUST derive fingerprints against **both** the current
893+
and the previous session keys unconditionally, and MUST combine
894+
the constant-time compares with a non-short-circuiting bitwise OR.
895+
Naïve "derive against current; if no match, derive against prev"
896+
logic creates a timing distinguisher observable by an on-path
897+
attacker: latency reveals which key-epoch the sender signed under,
898+
which is sensitive metadata about session state near rekey. The
899+
extra HKDF-SHA-256 call is cheap (microseconds on commodity
900+
hardware) and only paid during the grace window; outside grace
901+
there is only one key and only one derivation. The reference
902+
implementation's `Session::verify_fingerprint_either_epoch`
903+
realizes this requirement.
904+
905+
> **Note on design evolution (draft-03).** An earlier draft of the
906+
> fingerprint design bound to the *initial* session key so the
907+
> fingerprint would be stable across rekeys. The shipped design
908+
> instead binds to the *current* key and handles rekey at the
909+
> verifier via both-key derivation. Rationale: (a) "initial key"
910+
> has no wire-level representation (a peer that joined mid-session
911+
> has no notion of "initial"), whereas "current + previous" is
912+
> already maintained by every receiver for AEAD verification;
913+
> (b) binding to the initial key would require callers to
914+
> preserve a key they are otherwise encouraged to zeroize;
915+
> (c) the rekey grace window is bounded (§6.2 default 5s), so the
916+
> cost of the verifier's both-key probe is bounded in exactly the
917+
> same way. This is a real change from the initial design
918+
> rationale; it is documented here so reviewers can distinguish
919+
> evolution from inconsistency.
893920
894921
**Why MANDATORY, not OPTIONAL.** draft-02r1 documented loose
895922
binding as a known limitation. draft-03 closes it by making the
@@ -1015,6 +1042,20 @@ Every session SHALL track consent state, one of six variants. Two
10151042
auto-promote a LegacyBypass session into `Requested` — that
10161043
would let a malicious peer force a NoConsent block on a session
10171044
that opted out of the ceremony.
1045+
1046+
**This is intentional compatibility behavior, not an emergent
1047+
property of the state machine.** A LegacyBypass session
1048+
silently discards valid, cryptographically authenticated
1049+
consent ceremonies by design. Security-sensitive deployments
1050+
MUST NOT use `LegacyBypass`; those deployments construct
1051+
sessions via `SessionBuilder::require_consent(true)` so they
1052+
start in `AwaitingRequest`. `LegacyBypass` exists to preserve
1053+
draft-02 "consent handled out-of-band" behavior for
1054+
backward-compatibility with deployments whose authorization
1055+
lives entirely above the wire (MSP pre-authorization,
1056+
deployment-level trust anchors, etc.). Deployments that land
1057+
on `LegacyBypass` by accident are strictly less secure than
1058+
deployments that opt into ceremony mode.
10181059
- **`AwaitingRequest`** — the consent system IS in use; no
10191060
`ConsentRequest` has been observed yet. Application payloads are
10201061
blocked until a ceremony completes (interpretation (2) of the
@@ -1267,8 +1308,8 @@ an alternate implementation can reproduce every byte:
12671308
| 04 | `long_payload` | 256 bytes covering every byte value. |
12681309
| 05 | `nonce_structure` | Three sequential seals, seq counter increments. |
12691310
| 06 | `lz4_frame` | LZ4-before-AEAD pipeline. |
1270-
| 07 | `consent_request` | draft-02 ConsentRequest signed with deterministic Ed25519 seed. |
1271-
| 08 | `consent_response` | draft-02 ConsentResponse approving vector 07. |
1311+
| 07 | `consent_request` | draft-03 ConsentRequest signed with deterministic Ed25519 seed; includes the mandatory `session_fingerprint`. |
1312+
| 08 | `consent_response` | draft-03 ConsentResponse approving vector 07; distinct responder seed; shares the same `session_fingerprint` as 07. |
12721313
| 09 | `consent_revocation` | draft-03 ConsentRevocation signed by vector 08's responder; shares the session_fingerprint of 07 + 08. |
12731314
| 10 | `revocation_before_approval` | draft-03 event-sequence fixture: `ConsentViolation::RevocationBeforeApproval` from `AwaitingRequest` AND from `Requested`. |
12741315
| 11 | `contradictory_response` | draft-03 event-sequence fixture: `ConsentViolation::ContradictoryResponse` in both directions (prior=true→new=false and prior=false→new=true). |

plans/REVIEW_DELTA_DRAFT_03.md

Lines changed: 75 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Round-3 review — delta since draft-02r1
22

3-
**Target version**: `xenia-wire 0.2.0-alpha.2` / SPEC **draft-03**.
3+
**Target version**: `xenia-wire 0.2.0-alpha.3` / SPEC **draft-03**.
44
**Assumed prior context**: you last reviewed **draft-02r1**
55
(`xenia-wire 0.1.0-alpha.3` / `alpha.4`, April 2026), which flagged
66
four open design items: session-binding (loose), split-Pending,
@@ -19,6 +19,12 @@ Section-number references (e.g., §12.3.1) are to SPEC.md at
1919
`v0.2.0-alpha.2`. Source-file references are to the reference
2020
implementation at the same tag.
2121

22+
**No other wire changes.** §1–§11 (envelope layout, nonce
23+
construction, replay window, AEAD, payload type registry) are
24+
byte-for-byte identical to draft-02r1. All deltas live in the
25+
signed-body layer (§12), and — within the signed-body layer —
26+
the receive-side state machine and fingerprint verification.
27+
2228
---
2329

2430
## 1. Deltas since draft-02r1
@@ -89,6 +95,17 @@ a hazard.
8995
AEAD-verified under the previous key during the grace window
9096
now also passes the fingerprint check. §12.3.1 rekey
9197
interaction spells this out.
98+
99+
> **Design-evolution note (surfacing an earlier divergence).**
100+
> An earlier internal plan bound the fingerprint to the
101+
> *initial* session key so it would be stable across rekeys.
102+
> The shipped design binds to the *current* key and handles
103+
> rekey via the verifier's both-key probe. Rationale is
104+
> captured in §12.3.1 (no wire-level representation of
105+
> "initial"; preserving an initial key fights zeroization;
106+
> the grace window bounds the probe cost the same way it
107+
> bounds AEAD-verify). Flagged here so it reads as evolution
108+
> rather than inconsistency.
92109
- **Timing-channel assumption (§12.8)** — new paragraph. Asserts
93110
the verify pipeline (bincode deserialize, Ed25519 verify,
94111
fingerprint compare) MUST NOT branch on secret-dependent bytes.
@@ -134,35 +151,48 @@ replay threat model, or should `pld_type` be mixed into `info`?
134151
**The question.** SPEC §12.3.1 specifies `request_id_be` — the u64
135152
in big-endian. Every other integer on the Xenia wire is
136153
little-endian (per bincode v1 default; nonce sequence bytes are
137-
explicitly LE per §3). The mixed convention is intentional — we
138-
wanted the `info` byte-order to differ from the ambient wire so
139-
that an implementation reaching for a "byte-encode this u64 the
140-
usual way" path would produce the wrong fingerprint and fail
141-
loudly rather than silently.
142-
143-
**Specific ask.** Is this mistake-reduction smart, or is it a
144-
footgun that buys nothing because the endianness is already
145-
specified normatively either way?
146-
147-
### 2.3 Timing side-channel in `verify_fingerprint_either_epoch`
148-
149-
**The question.** On the receive path, `verify_consent_*` derives
150-
the fingerprint against the **current** key, compares, and — only
151-
if that fails — derives again against the **previous** key.
152-
That's one HKDF call on match, two on mismatch. Relevant source:
153-
`src/session.rs::verify_fingerprint_either_epoch` (around line
154-
340-360 in the 0.2.0-alpha.2 tree).
155-
156-
An attacker observing verify-path timing could distinguish
157-
"accepted under current key" from "accepted under prev key" from
158-
"rejected". The latency delta is ~one HKDF-SHA-256 derivation
159-
(~a few microseconds on commodity hardware). Is this exploitable?
160-
161-
**Mitigation considered.** Always derive both fingerprints
162-
regardless of first-match outcome; compare each; OR the
163-
constant-time compare results. Constant-time verify path at the
164-
cost of one extra HKDF call per verify. Open to your opinion on
165-
whether this is worth the complexity.
154+
explicitly LE per §3).
155+
156+
**Framing (rewritten for clarity).** The choice is intentionally
157+
domain-separated deterministic encoding — not a claim that
158+
big-endian is semantically meaningful for `request_id`. HKDF's
159+
`info` parameter needs fixed, unambiguous bytes; any normative
160+
byte order works. Picking BE here means a careless implementer
161+
who reuses the ambient LE encoding path produces an obviously
162+
wrong fingerprint and fails at the verify step, instead of
163+
silently producing a wrong-but-plausible output. The defensive
164+
effect is the whole reason; it's not a cryptographic property.
165+
166+
**Specific ask.** Is this defensive asymmetry worth it, or
167+
does the interop cost of the mixed convention outweigh the
168+
mistake-reduction gain? We're open to normalizing to
169+
little-endian in a future breaking draft if the reviewer
170+
recommends it — but that's a future-draft decision, not a
171+
draft-03 one.
172+
173+
### 2.3 Timing side-channel in `verify_fingerprint_either_epoch` (RESOLVED in 0.2.0-alpha.3)
174+
175+
**Original question.** The 0.2.0-alpha.2 implementation of
176+
`verify_fingerprint_either_epoch` derived the fingerprint against
177+
the current key, compared, and — on mismatch — derived against
178+
the previous key. That's one HKDF call on match, two on
179+
mismatch, which leaks which key-epoch signed the consent via
180+
verify-path latency.
181+
182+
**Resolution (0.2.0-alpha.3).** The reference implementation now
183+
derives fingerprints against BOTH keys unconditionally whenever
184+
`prev_session_key` is present, and combines the constant-time
185+
compares with a non-short-circuiting bitwise OR (`bool` `|`, not
186+
`||`). The extra HKDF-SHA-256 call is only incurred during the
187+
grace window. SPEC §12.3.1 rekey interaction now states this as
188+
a normative MUST for receivers.
189+
190+
**Ask becomes:** review confirmation that the fix is sufficient.
191+
If an attacker can still distinguish "grace window active" from
192+
"no grace" via the absence of the second derivation, that's a
193+
known protocol-visible state (the presence of a prev key is not
194+
secret), so we don't think further masking is needed. Please
195+
confirm this reasoning.
166196

167197
### 2.4 Transition-table completeness (§12.6.1)
168198

@@ -198,17 +228,21 @@ just *that* they did. Currently a replayed (same-value)
198228
`ConsentResponse` would be indistinguishable from the original in
199229
a frozen transcript.
200230

201-
### 2.6 General coherence of §12.8 security properties + §12.10 out-of-scope
202-
203-
**The question.** §12.8 now enumerates: third-party-verifiable
204-
signed consent, TIGHT session binding, protocol-violation
205-
detection, no human-identity binding, and the timing-channel
206-
assumption. §12.10 lists the explicit out-of-scope items (coerced
207-
consent, human-identity fraud, clock-skew attacks).
208-
209-
Is there a property draft-03 implicitly offers that §12.8 should
210-
claim explicitly? Or a threat draft-03 implicitly tolerates that
211-
§12.10 should flag? Open-ended reality-check question.
231+
### 2.6 §12.8 timing-channel scope — any missing sinks?
232+
233+
**The question.** §12.8 enumerates three operations on the verify
234+
path that MUST NOT branch on secret-dependent bytes: bincode
235+
deserialization of the signed body, Ed25519 signature verify, and
236+
the 32-byte constant-time fingerprint compare. Did we miss a
237+
fourth timing-observable? Two candidates we considered and
238+
rejected as not-a-sink: the Ed25519 `from_slice` signature-length
239+
check (the signature length is 64 bytes and public), and
240+
bincode's length-prefix parse for the `reason: String` field
241+
(the length byte is part of the attacker-controlled input
242+
rather than a secret).
243+
244+
**Specific ask.** Confirm the three-sink list is exhaustive, or
245+
point at a sink we missed.
212246

213247
---
214248

src/session.rs

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -305,32 +305,52 @@ impl Session {
305305
out
306306
}
307307

308-
/// Probe both the current and (if present) previous session
309-
/// keys for a fingerprint match against `claimed` (SPEC
310-
/// draft-03 §12.3.1 rekey interaction). Constant-time compare
311-
/// on each candidate.
308+
/// Probe the current and (if present) previous session keys for
309+
/// a fingerprint match against `claimed` (SPEC draft-03 §12.3.1
310+
/// rekey interaction).
312311
///
313-
/// Returns `true` iff either derivation matches. Returns
314-
/// `false` if no key is installed.
312+
/// When the previous session key is present (i.e. we are within
313+
/// the rekey grace window), this function derives fingerprints
314+
/// from BOTH keys unconditionally and combines the constant-
315+
/// time compares with a non-short-circuiting bitwise OR (`|`
316+
/// on `bool`, not `||`). This removes the timing distinguisher
317+
/// that a naive "try current first, fall back to prev on
318+
/// mismatch" implementation would leak — a remote observer of
319+
/// verify-path latency otherwise learns which key-epoch the
320+
/// counterparty signed the consent under, which is sensitive
321+
/// metadata about session state near rekey.
322+
///
323+
/// The extra HKDF-SHA-256 call is cheap (~microseconds on
324+
/// commodity hardware) and only incurred while `prev_session_key`
325+
/// is Some — i.e. during the grace window. Outside the grace
326+
/// window there is only one key and only one derivation.
327+
///
328+
/// Returns `true` iff either derivation matches. Returns `false`
329+
/// if no key is installed.
315330
#[cfg(feature = "consent")]
316331
fn verify_fingerprint_either_epoch(
317332
&self,
318333
request_id: u64,
319334
claimed: &[u8; 32],
320335
) -> bool {
321-
if let Some(key) = self.session_key.as_ref() {
322-
let fp = self.session_fingerprint_from_key(request_id, key);
323-
if ct_eq_32(&fp, claimed) {
324-
return true;
336+
let current_match = match self.session_key.as_ref() {
337+
Some(key) => {
338+
let fp = self.session_fingerprint_from_key(request_id, key);
339+
ct_eq_32(&fp, claimed)
325340
}
326-
}
327-
if let Some(prev) = self.prev_session_key.as_ref() {
328-
let fp = self.session_fingerprint_from_key(request_id, prev);
329-
if ct_eq_32(&fp, claimed) {
330-
return true;
341+
None => false,
342+
};
343+
let prev_match = match self.prev_session_key.as_ref() {
344+
Some(prev) => {
345+
let fp = self.session_fingerprint_from_key(request_id, prev);
346+
ct_eq_32(&fp, claimed)
331347
}
332-
}
333-
false
348+
None => false,
349+
};
350+
// Bitwise OR on `bool` — NOT short-circuiting `||`. The `|`
351+
// variant forces both operands to be evaluated and combined
352+
// without control-flow branches on the intermediate values.
353+
current_match | prev_match
334354
}
335355

336356
/// Sign a [`ConsentRequestCore`] after injecting the session

0 commit comments

Comments
 (0)