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}