Skip to main content

xenia_wire/
consent.rs

1// Copyright (c) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Consent ceremony for Xenia sessions (SPEC draft-03 §12).
5//!
6//! Before any application payload flows on a session, the technician's
7//! side sends a [`ConsentRequest`] — a signed, time-limited, scoped
8//! description of what access is being asked for. The end-user's side
9//! returns a [`ConsentResponse`] that either approves or denies; only
10//! after a valid approval does the session accept `FRAME` payloads.
11//! Either side may send a [`ConsentRevocation`] at any time to
12//! asymmetrically terminate the session; subsequent application frames
13//! return [`crate::WireError::ConsentRevoked`].
14//!
15//! ## Wire-level integration
16//!
17//! The three consent payload types are:
18//!
19//! - `0x20` [`PAYLOAD_TYPE_CONSENT_REQUEST`][crate::PAYLOAD_TYPE_CONSENT_REQUEST]
20//! - `0x21` [`PAYLOAD_TYPE_CONSENT_RESPONSE`][crate::PAYLOAD_TYPE_CONSENT_RESPONSE]
21//! - `0x22` [`PAYLOAD_TYPE_CONSENT_REVOCATION`][crate::PAYLOAD_TYPE_CONSENT_REVOCATION]
22//!
23//! They seal through the same [`Session::seal`][crate::Session::seal]
24//! path as everything else; what distinguishes them is the session-level
25//! state machine in [`crate::Session::observe_consent`] that tracks
26//! whether the ceremony has completed.
27//!
28//! ## Signing
29//!
30//! Each consent message carries a device-key Ed25519 signature. The
31//! signing is over a canonical byte representation of the message
32//! fields — NOT over the sealed envelope. A receiver that wants to
33//! verify the consent independently of the AEAD channel (for example,
34//! to log the consent in an external audit system) can do so using
35//! only the plaintext + the peer's public key.
36//!
37//! ## Session binding (draft-03)
38//!
39//! Every signed consent body carries a 32-byte `session_fingerprint`
40//! derived via HKDF-SHA-256 from the current AEAD session key with
41//! `info = source_id || epoch || request_id_be` (see
42//! [`Session::session_fingerprint`][crate::Session::session_fingerprint]).
43//! The fingerprint cryptographically binds the consent message to the
44//! specific session AND ceremony in which it was signed, preventing
45//! replay of a signed `ConsentResponse` across sessions or across
46//! request_ids with the same participants.
47//!
48//! Both peers derive the same fingerprint from their own copy of the
49//! key; receivers reject signatures whose embedded fingerprint does
50//! not match local derivation. Use
51//! [`Session::sign_consent_request`][crate::Session::sign_consent_request]
52//! (and siblings) on the send path, and
53//! [`Session::verify_consent_request`][crate::Session::verify_consent_request]
54//! on the receive path to avoid manual fingerprint handling.
55//!
56//! ## Forward-compatibility
57//!
58//! [`ConsentRequestCore::causal_binding`] is reserved for a future
59//! Ricardian-contract extension (ticket-state-bound authority). In
60//! draft-03 it MUST be `None`; the wire slot is reserved so that
61//! v1.1-aware receivers can honor it without breaking v1 peers.
62//!
63//! ## Threat model
64//!
65//! The consent ceremony assumes:
66//!
67//! - Each peer holds an Ed25519 signing key on their device. The
68//!   binding of a device key to a human identity is out of scope here
69//!   (that's the MSP attestation chain in SPEC draft-03 §12.5).
70//! - The session's AEAD key has already been established via the outer
71//!   handshake. The consent messages flow INSIDE the sealed channel —
72//!   the signature adds a second layer of authentication specifically
73//!   for third-party-verifiable consent records, and the
74//!   `session_fingerprint` binds each record to a specific session.
75//! - `valid_until` uses the Unix epoch in seconds. Clock skew between
76//!   peers matters for this field; callers SHOULD grant a small grace
77//!   window (the reference implementation accepts +/- 30 s).
78
79#![cfg(feature = "consent")]
80
81use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
82use serde::{Deserialize, Serialize};
83use serde_big_array::BigArray;
84
85use crate::{Sealable, WireError};
86
87/// Length in bytes of an Ed25519 signature. Exposed as a constant so
88/// alternate-language implementations can size buffers without depending
89/// on the Rust `ed25519-dalek` crate.
90pub const SIGNATURE_LEN: usize = 64;
91
92/// Length in bytes of an Ed25519 public key.
93pub const PUBLIC_KEY_LEN: usize = 32;
94
95/// Scope of access being requested.
96///
97/// The scope is advisory — the wire does not enforce what the technician
98/// actually sends. An application-level check against the active
99/// [`ConsentRequest`] is the caller's responsibility.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[repr(u8)]
102pub enum ConsentScope {
103    /// View the screen only. Input forwarding SHOULD be ignored.
104    ScreenOnly = 0,
105    /// View the screen and send input events (mouse, keyboard, touch).
106    ScreenAndInput = 1,
107    /// Screen + input + file transfer.
108    ScreenInputFiles = 2,
109    /// Full interactive session: screen + input + files + shell.
110    Interactive = 3,
111}
112
113/// Reserved for a future Ricardian-contract extension that binds the
114/// consent to external causal state (e.g., "authority valid while
115/// ticket #1234 is In-Progress"). Always `None` in draft-02.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct CausalPredicate {
118    /// Human-readable description of the binding condition.
119    pub description: String,
120    /// Implementation-defined machine-readable predicate. Opaque to
121    /// `xenia-wire`; evaluated by higher layers.
122    pub opaque: Vec<u8>,
123}
124
125/// Canonical on-the-wire form of a consent request before signing.
126///
127/// The signature in [`ConsentRequest`] is computed over
128/// `bincode::serialize(&ConsentRequestCore { ... })`. Canonical encoding
129/// is bincode v1 with default little-endian fixint, matching the other
130/// payloads in this crate.
131///
132/// **Field order is load-bearing** — the bincode serialization is the
133/// signed payload, so reordering fields breaks signature verification
134/// across implementations. The draft-03 canonical order is:
135/// `request_id`, `requester_pubkey`, `session_fingerprint`,
136/// `valid_until`, `scope`, `reason`, `causal_binding`.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ConsentRequestCore {
139    /// Monotonic request identifier chosen by the requester. Used by the
140    /// responder to correlate `ConsentResponse.request_id`.
141    pub request_id: u64,
142    /// Technician's device public key (raw 32-byte Ed25519).
143    pub requester_pubkey: [u8; PUBLIC_KEY_LEN],
144    /// Session binding (draft-03): HKDF-SHA-256 of the session key,
145    /// derived by the requester at sign time. See
146    /// [`Session::session_fingerprint`][crate::Session::session_fingerprint].
147    /// Prevents replay of this signed request in a different session.
148    #[serde(with = "BigArray")]
149    pub session_fingerprint: [u8; 32],
150    /// Unix epoch seconds after which the request expires. Callers SHOULD
151    /// grant ±30 s clock skew.
152    pub valid_until: u64,
153    /// Scope of access requested.
154    pub scope: ConsentScope,
155    /// Free-text justification (ticket reference, reason).
156    pub reason: String,
157    /// Reserved for v1.1 Ricardian binding. MUST be `None` in draft-03.
158    pub causal_binding: Option<CausalPredicate>,
159}
160
161/// Request for session consent. Sealed with
162/// [`crate::PAYLOAD_TYPE_CONSENT_REQUEST`] (0x20).
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct ConsentRequest {
165    /// Request body.
166    pub core: ConsentRequestCore,
167    /// Ed25519 signature over `bincode::serialize(&core)` by
168    /// `core.requester_pubkey`.
169    #[serde(with = "BigArray")]
170    pub signature: [u8; SIGNATURE_LEN],
171}
172
173impl ConsentRequest {
174    /// Construct and sign a consent request.
175    pub fn sign(core: ConsentRequestCore, signing_key: &SigningKey) -> Self {
176        let bytes = bincode::serialize(&core).expect("consent core serializes");
177        let signature = signing_key.sign(&bytes);
178        Self {
179            core,
180            signature: signature.to_bytes(),
181        }
182    }
183
184    /// Verify the signature against the embedded public key.
185    ///
186    /// Returns `true` if the signature is valid AND the embedded public
187    /// key matches the caller-supplied `expected_pubkey`. A caller who
188    /// accepts any public key can pass `None`.
189    ///
190    /// Does NOT check `valid_until` — expiry is a policy decision for
191    /// the caller, not a wire property.
192    pub fn verify(&self, expected_pubkey: Option<&[u8; PUBLIC_KEY_LEN]>) -> bool {
193        if let Some(exp) = expected_pubkey {
194            if exp != &self.core.requester_pubkey {
195                return false;
196            }
197        }
198        let Ok(pk) = VerifyingKey::from_bytes(&self.core.requester_pubkey) else {
199            return false;
200        };
201        let Ok(sig) = Signature::from_slice(&self.signature) else {
202            return false;
203        };
204        let bytes = match bincode::serialize(&self.core) {
205            Ok(b) => b,
206            Err(_) => return false,
207        };
208        pk.verify(&bytes, &sig).is_ok()
209    }
210}
211
212impl Sealable for ConsentRequest {
213    fn to_bin(&self) -> Result<Vec<u8>, WireError> {
214        bincode::serialize(self).map_err(WireError::encode)
215    }
216    fn from_bin(bytes: &[u8]) -> Result<Self, WireError> {
217        bincode::deserialize(bytes).map_err(WireError::decode)
218    }
219}
220
221/// End-user's response to a consent request. Carries an approval or
222/// denial plus a signature. Sealed with
223/// [`crate::PAYLOAD_TYPE_CONSENT_RESPONSE`] (0x21).
224///
225/// **Canonical field order (draft-03):** `request_id`,
226/// `responder_pubkey`, `session_fingerprint`, `approved`, `reason`.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct ConsentResponseCore {
229    /// Matches `ConsentRequestCore::request_id` to correlate.
230    pub request_id: u64,
231    /// End-user's device public key (raw 32-byte Ed25519).
232    pub responder_pubkey: [u8; PUBLIC_KEY_LEN],
233    /// Session binding (draft-03). Same derivation as on
234    /// [`ConsentRequestCore::session_fingerprint`]. Prevents replay
235    /// of this signed response in a different session or across
236    /// different `request_id`s.
237    #[serde(with = "BigArray")]
238    pub session_fingerprint: [u8; 32],
239    /// Whether the consent is approved.
240    pub approved: bool,
241    /// Optional free-text denial reason (empty when approved).
242    pub reason: String,
243}
244
245/// Response to a consent request.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ConsentResponse {
248    /// Response body.
249    pub core: ConsentResponseCore,
250    /// Ed25519 signature over `bincode::serialize(&core)` by
251    /// `core.responder_pubkey`.
252    #[serde(with = "BigArray")]
253    pub signature: [u8; SIGNATURE_LEN],
254}
255
256impl ConsentResponse {
257    /// Construct and sign a consent response.
258    pub fn sign(core: ConsentResponseCore, signing_key: &SigningKey) -> Self {
259        let bytes = bincode::serialize(&core).expect("consent response core serializes");
260        let signature = signing_key.sign(&bytes);
261        Self {
262            core,
263            signature: signature.to_bytes(),
264        }
265    }
266
267    /// Verify the signature against the embedded public key, optionally
268    /// requiring the embedded public key to match `expected_pubkey`.
269    pub fn verify(&self, expected_pubkey: Option<&[u8; PUBLIC_KEY_LEN]>) -> bool {
270        if let Some(exp) = expected_pubkey {
271            if exp != &self.core.responder_pubkey {
272                return false;
273            }
274        }
275        let Ok(pk) = VerifyingKey::from_bytes(&self.core.responder_pubkey) else {
276            return false;
277        };
278        let Ok(sig) = Signature::from_slice(&self.signature) else {
279            return false;
280        };
281        let bytes = match bincode::serialize(&self.core) {
282            Ok(b) => b,
283            Err(_) => return false,
284        };
285        pk.verify(&bytes, &sig).is_ok()
286    }
287}
288
289impl Sealable for ConsentResponse {
290    fn to_bin(&self) -> Result<Vec<u8>, WireError> {
291        bincode::serialize(self).map_err(WireError::encode)
292    }
293    fn from_bin(bytes: &[u8]) -> Result<Self, WireError> {
294        bincode::deserialize(bytes).map_err(WireError::decode)
295    }
296}
297
298/// Asymmetric session termination. Either peer may send this at any time
299/// after a successful consent; subsequent `FRAME` payloads on the session
300/// return [`crate::WireError::ConsentRevoked`]. Sealed with
301/// [`crate::PAYLOAD_TYPE_CONSENT_REVOCATION`] (0x22).
302///
303/// **Canonical field order (draft-03):** `request_id`, `revoker_pubkey`,
304/// `session_fingerprint`, `issued_at`, `reason`.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct ConsentRevocationCore {
307    /// References the `request_id` being revoked.
308    pub request_id: u64,
309    /// Public key of the revoker (either party may revoke).
310    pub revoker_pubkey: [u8; PUBLIC_KEY_LEN],
311    /// Session binding (draft-03). Same derivation as on
312    /// [`ConsentRequestCore::session_fingerprint`].
313    #[serde(with = "BigArray")]
314    pub session_fingerprint: [u8; 32],
315    /// Unix epoch seconds at which the revocation was issued.
316    pub issued_at: u64,
317    /// Free-text reason (displayed to the counterparty).
318    pub reason: String,
319}
320
321/// Asymmetric session revocation message. Wraps a signed revocation
322/// body; sealed with [`crate::PAYLOAD_TYPE_CONSENT_REVOCATION`] (0x22).
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct ConsentRevocation {
325    /// Revocation body.
326    pub core: ConsentRevocationCore,
327    /// Ed25519 signature over `bincode::serialize(&core)` by
328    /// `core.revoker_pubkey`.
329    #[serde(with = "BigArray")]
330    pub signature: [u8; SIGNATURE_LEN],
331}
332
333impl ConsentRevocation {
334    /// Construct and sign a consent revocation.
335    pub fn sign(core: ConsentRevocationCore, signing_key: &SigningKey) -> Self {
336        let bytes = bincode::serialize(&core).expect("consent revocation core serializes");
337        let signature = signing_key.sign(&bytes);
338        Self {
339            core,
340            signature: signature.to_bytes(),
341        }
342    }
343
344    /// Verify the signature against the embedded public key.
345    pub fn verify(&self, expected_pubkey: Option<&[u8; PUBLIC_KEY_LEN]>) -> bool {
346        if let Some(exp) = expected_pubkey {
347            if exp != &self.core.revoker_pubkey {
348                return false;
349            }
350        }
351        let Ok(pk) = VerifyingKey::from_bytes(&self.core.revoker_pubkey) else {
352            return false;
353        };
354        let Ok(sig) = Signature::from_slice(&self.signature) else {
355            return false;
356        };
357        let bytes = match bincode::serialize(&self.core) {
358            Ok(b) => b,
359            Err(_) => return false,
360        };
361        pk.verify(&bytes, &sig).is_ok()
362    }
363}
364
365impl Sealable for ConsentRevocation {
366    fn to_bin(&self) -> Result<Vec<u8>, WireError> {
367        bincode::serialize(self).map_err(WireError::encode)
368    }
369    fn from_bin(bytes: &[u8]) -> Result<Self, WireError> {
370        bincode::deserialize(bytes).map_err(WireError::decode)
371    }
372}
373
374/// Session-level consent state machine (draft-02r2).
375///
376/// Two start states disambiguate the pre-draft-02r2 `Pending` variant:
377///
378/// - [`Session::new`](crate::Session::new) →
379///   [`ConsentState::LegacyBypass`] (sticky; FRAME flows out-of-band).
380/// - [`SessionBuilder::require_consent(true)`](crate::SessionBuilder::require_consent)
381///   → [`ConsentState::AwaitingRequest`] (FRAME blocked until ceremony
382///   completes).
383///
384/// ```text
385///                      (any event)
386/// LegacyBypass ◀──────────────────────── LegacyBypass   (sticky)
387///
388///                       ConsentRequest opened
389/// AwaitingRequest ─────────────────────────▶ Requested
390///                                            │
391///                                            │ ConsentResponse{approved=true}
392///                                            ▼
393///                                         Approved ────┐
394///                                            │         │ ConsentRevocation
395///                                            │         ▼
396///                                            │      Revoked   (terminal)
397///                                            │
398///                                            │ ConsentResponse{approved=false}
399///                                            ▼
400///                                          Denied    (terminal)
401/// ```
402///
403/// `Session::observe_consent` drives these transitions. Application
404/// `FRAME` payloads are accepted in `LegacyBypass` and `Approved`; any
405/// other state blocks the seal / open path. See [`crate::Session::seal`]
406/// and [`crate::Session::open`] for the enforcement points.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum ConsentState {
409    /// Consent system not in use for this session. Application
410    /// payloads flow unimpeded. Default for [`crate::Session::new`];
411    /// preserves the draft-02 "Pending allows traffic" behavior.
412    /// Use this when consent is handled out-of-band (e.g. via an
413    /// MSP pre-authorization mechanism above the wire).
414    ///
415    /// Added in draft-02r2.
416    LegacyBypass,
417    /// Consent system IS in use; no `ConsentRequest` observed yet.
418    /// Application `FRAME` / `INPUT` / `FRAME_LZ4` payloads are
419    /// blocked until a ceremony completes. Opt in via
420    /// [`crate::SessionBuilder::require_consent`].
421    ///
422    /// Added in draft-02r2 to disambiguate the dual meaning of
423    /// the pre-draft-02r2 `Pending` state. See SPEC §12.7.
424    AwaitingRequest,
425    /// A `ConsentRequest` was sent / received but not yet answered.
426    Requested,
427    /// Consent was approved by the responder. FRAME payloads flow.
428    Approved,
429    /// The responder denied the request. Terminal for this ceremony.
430    Denied,
431    /// A revocation was received after approval. Terminal for this
432    /// ceremony.
433    Revoked,
434}
435
436/// Observed consent-ceremony events that drive [`crate::Session`]'s
437/// state machine. The caller constructs one of these AFTER verifying
438/// the underlying signed message, and passes it to
439/// [`crate::Session::observe_consent`].
440///
441/// Every event carries the `request_id` of the consent message it
442/// describes (SPEC draft-03 §12.6). The session's transition table
443/// uses `request_id` to distinguish legitimate ceremony progression
444/// (e.g., a fresh `Request` with a higher id starting a new ceremony
445/// after a terminal state) from protocol violations (e.g., a
446/// `Denied` contradicting a prior `Approved` for the *same* id).
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum ConsentEvent {
449    /// A `ConsentRequest` was observed (either sent or received).
450    Request {
451        /// `ConsentRequestCore::request_id` of the observed request.
452        request_id: u64,
453    },
454    /// A `ConsentResponse` with `approved = true` was observed.
455    ResponseApproved {
456        /// `ConsentResponseCore::request_id` of the observed response.
457        request_id: u64,
458    },
459    /// A `ConsentResponse` with `approved = false` was observed.
460    ResponseDenied {
461        /// `ConsentResponseCore::request_id` of the observed response.
462        request_id: u64,
463    },
464    /// A `ConsentRevocation` was observed.
465    Revocation {
466        /// `ConsentRevocationCore::request_id` of the observed revocation.
467        request_id: u64,
468    },
469}
470
471impl ConsentEvent {
472    /// Returns the `request_id` carried by this event.
473    pub fn request_id(&self) -> u64 {
474        match self {
475            ConsentEvent::Request { request_id }
476            | ConsentEvent::ResponseApproved { request_id }
477            | ConsentEvent::ResponseDenied { request_id }
478            | ConsentEvent::Revocation { request_id } => *request_id,
479        }
480    }
481}
482
483/// A consent-state-machine transition that is a protocol violation
484/// (SPEC draft-03 §12.6). Returned in the `Err` arm of
485/// [`crate::Session::observe_consent`] and wrapped by
486/// [`crate::WireError::ConsentProtocolViolation`].
487///
488/// The wire layer returns these values without side effects on the
489/// session state — once a violation is raised, the caller's contract
490/// is to terminate the session. The wire cannot tear down the
491/// underlying transport; that's the application's job.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
493pub enum ConsentViolation {
494    /// A `Revocation` was observed while the session state is
495    /// `AwaitingRequest` or `Requested` — i.e., the peer is trying
496    /// to revoke consent that was never approved. A correct peer in
497    /// that situation would either do nothing (no consent to revoke)
498    /// or emit a `ResponseDenied`.
499    #[error("revocation observed before any approval (request_id={request_id})")]
500    RevocationBeforeApproval {
501        /// `request_id` carried by the offending `Revocation` event.
502        request_id: u64,
503    },
504    /// A `Response` was observed whose `approved` field contradicts a
505    /// prior `Response` for the same `request_id` — e.g. a
506    /// `ResponseDenied` after a `ResponseApproved`, or vice-versa.
507    ///
508    /// SPEC §12.6 REQUIRES rejecting this rather than accepting
509    /// "later wins." The correct UX primitive for "the user changed
510    /// their mind after approving" is a fresh [`ConsentRevocation`],
511    /// which has its own signature, timestamp, and wire type.
512    #[error(
513        "contradictory response for request_id={request_id}: prior approved={prior_approved}, new approved={new_approved}"
514    )]
515    ContradictoryResponse {
516        /// `request_id` of both responses (they share it by
517        /// definition of "contradictory").
518        request_id: u64,
519        /// The `approved` field recorded first for this `request_id`.
520        prior_approved: bool,
521        /// The `approved` field on the contradictory response.
522        new_approved: bool,
523    },
524    /// A `Response` was observed for a `request_id` that was never
525    /// `Requested` on this session. A correct peer would have
526    /// observed the `Request` first.
527    #[error("response for unknown request_id={request_id} (no prior Request)")]
528    StaleResponseForUnknownRequest {
529        /// `request_id` of the orphan response.
530        request_id: u64,
531    },
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use ed25519_dalek::SigningKey;
538    use rand::rngs::OsRng;
539
540    fn test_key_pair() -> (SigningKey, [u8; 32]) {
541        let sk = SigningKey::generate(&mut OsRng);
542        let pk_bytes = sk.verifying_key().to_bytes();
543        (sk, pk_bytes)
544    }
545
546    // A placeholder fingerprint for unit-level sign/verify tests that
547    // don't drive a full Session. Real callers derive this from
548    // `Session::session_fingerprint`.
549    const TEST_FP: [u8; 32] = [0xAA; 32];
550
551    #[test]
552    fn consent_request_signs_and_verifies() {
553        let (sk, pk) = test_key_pair();
554        let core = ConsentRequestCore {
555            request_id: 42,
556            requester_pubkey: pk,
557            session_fingerprint: TEST_FP,
558            valid_until: 1_700_000_000,
559            scope: ConsentScope::ScreenAndInput,
560            reason: "ticket #1234 password reset".to_string(),
561            causal_binding: None,
562        };
563        let req = ConsentRequest::sign(core, &sk);
564        assert!(req.verify(None));
565        assert!(req.verify(Some(&pk)));
566    }
567
568    #[test]
569    fn consent_request_rejects_wrong_pubkey() {
570        let (sk, _) = test_key_pair();
571        let (_, other_pk) = test_key_pair();
572        let core = ConsentRequestCore {
573            request_id: 1,
574            requester_pubkey: sk.verifying_key().to_bytes(),
575            session_fingerprint: TEST_FP,
576            valid_until: 1,
577            scope: ConsentScope::ScreenOnly,
578            reason: "".into(),
579            causal_binding: None,
580        };
581        let req = ConsentRequest::sign(core, &sk);
582        assert!(!req.verify(Some(&other_pk)));
583    }
584
585    #[test]
586    fn consent_request_rejects_tampered_body() {
587        let (sk, pk) = test_key_pair();
588        let core = ConsentRequestCore {
589            request_id: 1,
590            requester_pubkey: pk,
591            session_fingerprint: TEST_FP,
592            valid_until: 100,
593            scope: ConsentScope::ScreenOnly,
594            reason: "original".into(),
595            causal_binding: None,
596        };
597        let mut req = ConsentRequest::sign(core, &sk);
598        req.core.reason = "tampered".into();
599        assert!(!req.verify(None));
600    }
601
602    #[test]
603    fn consent_request_rejects_tampered_fingerprint() {
604        // A fingerprint byte-flip after signing must invalidate the
605        // signature — session-binding is load-bearing at the
606        // signed-body level.
607        let (sk, pk) = test_key_pair();
608        let core = ConsentRequestCore {
609            request_id: 1,
610            requester_pubkey: pk,
611            session_fingerprint: TEST_FP,
612            valid_until: 100,
613            scope: ConsentScope::ScreenOnly,
614            reason: "".into(),
615            causal_binding: None,
616        };
617        let mut req = ConsentRequest::sign(core, &sk);
618        req.core.session_fingerprint[0] ^= 0x01;
619        assert!(!req.verify(None));
620    }
621
622    #[test]
623    fn consent_response_signs_and_verifies() {
624        let (sk, pk) = test_key_pair();
625        let core = ConsentResponseCore {
626            request_id: 42,
627            responder_pubkey: pk,
628            session_fingerprint: TEST_FP,
629            approved: true,
630            reason: "".into(),
631        };
632        let resp = ConsentResponse::sign(core, &sk);
633        assert!(resp.verify(Some(&pk)));
634    }
635
636    #[test]
637    fn consent_revocation_signs_and_verifies() {
638        let (sk, pk) = test_key_pair();
639        let core = ConsentRevocationCore {
640            request_id: 42,
641            revoker_pubkey: pk,
642            session_fingerprint: TEST_FP,
643            issued_at: 1_700_000_500,
644            reason: "session complete".into(),
645        };
646        let rev = ConsentRevocation::sign(core, &sk);
647        assert!(rev.verify(Some(&pk)));
648    }
649
650    #[test]
651    fn consent_messages_are_sealable() {
652        let (sk, pk) = test_key_pair();
653        let req = ConsentRequest::sign(
654            ConsentRequestCore {
655                request_id: 1,
656                requester_pubkey: pk,
657                session_fingerprint: TEST_FP,
658                valid_until: 1,
659                scope: ConsentScope::ScreenOnly,
660                reason: "".into(),
661                causal_binding: None,
662            },
663            &sk,
664        );
665        let bytes = req.to_bin().unwrap();
666        let decoded = ConsentRequest::from_bin(&bytes).unwrap();
667        assert_eq!(decoded, req);
668    }
669}