1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
//! Verdict-bound challenges for permission answers.
//!
//! A presence gesture proves a human was there. It does not, by itself, prove
//! *what* they agreed to. If the same challenge were issued for every verdict
//! on a request, an assertion collected while the operator was denying could be
//! replayed as an allow — the signature would verify, because it never covered
//! the verdict.
//!
//! So the challenge binds the request digest **and** the verdict tag. A gesture
//! is then usable for exactly one (request, verdict) pair, and swapping either
//! side produces a challenge the signature does not match.
//!
//! Everything here is pure and wall-clock-free. Expiry is evaluated against a
//! caller-supplied `now`, so the decision is testable and the clock stays
//! injected like everywhere else in the store.
use agent_mesh_protocol::Fingerprint;
use crate::wire_framing::push_field;
use crate::Verdict;
/// Domain tag for the permission-challenge preimage.
const DOMAIN: &[u8] = b"newt/permission-challenge/v1";
/// The stable tag a verdict contributes to a challenge.
///
/// Spelled out rather than derived from `Debug` or a discriminant: a rename or
/// a reordering of the enum must not silently change what a signature covers.
#[must_use]
pub fn verdict_tag(verdict: Verdict) -> &'static [u8] {
match verdict {
Verdict::AllowOnce => b"allow_once",
Verdict::AllowSession => b"allow_session",
Verdict::Deny => b"deny",
}
}
/// What a human is being asked to authorize.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionChallenge {
/// The unguessable request id the answer is bound to.
pub request_id: String,
/// The conversation the request belongs to.
pub conversation_id: String,
/// The serialized requests the operator is looking at.
pub requests_json: String,
/// The gate-stamped danger tier.
pub danger_json: String,
/// The tick the request was published at.
pub created_tick: i64,
}
impl PermissionChallenge {
/// The digest of *what is being decided*, independent of the answer.
#[must_use]
pub fn digest(&self) -> Fingerprint {
let mut payload = Vec::with_capacity(256);
payload.extend_from_slice(DOMAIN);
push_field(&mut payload, self.request_id.as_bytes());
push_field(&mut payload, self.conversation_id.as_bytes());
push_field(&mut payload, self.requests_json.as_bytes());
push_field(&mut payload, self.danger_json.as_bytes());
push_field(&mut payload, &self.created_tick.to_be_bytes());
Fingerprint::of_bytes(&payload)
}
/// The 32 bytes an authenticator signs for one specific verdict.
///
/// `digest ‖ verdict_tag`, framed, then hashed — so neither the request nor
/// the verdict can be varied without producing a different challenge.
#[must_use]
pub fn challenge_for(&self, verdict: Verdict) -> [u8; 32] {
let mut payload = Vec::with_capacity(128);
payload.extend_from_slice(DOMAIN);
push_field(&mut payload, &self.digest().0);
push_field(&mut payload, verdict_tag(verdict));
Fingerprint::of_bytes(&payload).0
}
/// Whether this request has aged out, per the same TTL the store enforces.
///
/// Checked explicitly at answer time rather than inferred: an assertion
/// over an expired request must not be honoured just because the signature
/// is good.
#[must_use]
pub fn is_expired(&self, now: i64, ttl_nanos: i64) -> bool {
now.saturating_sub(self.created_tick) >= ttl_nanos
}
}
/// Whether a danger tier demands a terminal echo before the answer is honoured.
///
/// High-danger targets get WYSIWYS at the terminal: the un-proxied plain
/// scroller is authoritative about *what* is being authorized, because a
/// compromised browser can render anything it likes next to the button.
#[must_use]
pub fn requires_terminal_echo(danger_json: &str) -> bool {
serde_json::from_str::<Vec<String>>(danger_json)
.map(|tiers| tiers.iter().any(|t| t.eq_ignore_ascii_case("high")))
// A tier we cannot parse is treated as high: the failure direction that
// asks a human rather than the one that skips them.
.unwrap_or(true)
}