auths_verifier/authorization_summary.rs
1//! A legible "what you are authorizing" summary derived from the exact bytes being signed.
2//!
3//! A signer should be able to see what a signature authorizes, not just a digest. For the paths
4//! whose signed bytes are legible — a signed action request, a git commit — this derives a
5//! human-readable summary deterministically from those exact bytes, so the summary cannot drift
6//! from what is signed. For bytes whose content is not legible from the signature (an artifact
7//! attestation binds a digest, not the file content), the summary names the digest rather than
8//! silently showing nothing.
9
10use serde_json::Value;
11use sha2::{Digest, Sha256};
12use std::fmt;
13
14/// What a signer is authorizing, derived from the exact bytes being signed.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum AuthorizationSummary {
17 /// A signed action request: the action, the signing identity, and the legible payload
18 /// fields (e.g. an agent capability call's capability, budget, and target).
19 Action {
20 /// The action being authorized.
21 action_type: String,
22 /// The identity signing the action.
23 identity: String,
24 /// The action payload's top-level fields, rendered as key/value pairs.
25 details: Vec<(String, String)>,
26 },
27 /// A git commit's message — the legible content being authorized. The commit's git `author`
28 /// line is deliberately excluded: it is an unverified self-claim, and the authorizing identity
29 /// is always the verdict's cryptographically-verified signer, never the git author.
30 Commit {
31 /// The commit message.
32 message: String,
33 },
34 /// Signed bytes whose content is not legible from the signature (e.g. an artifact
35 /// attestation binds a digest, not content). The digest is named so consent is never
36 /// silently blank; content consent must be established out of band.
37 Opaque {
38 /// Lowercase hex SHA-256 of the signed bytes.
39 digest_hex: String,
40 },
41}
42
43impl AuthorizationSummary {
44 /// Derive a legible summary from the exact bytes a signature will cover.
45 ///
46 /// Recognizes a signed action request (canonical JSON with `type`/`identity`/`payload`) and
47 /// a git commit object; anything else is summarized by its SHA-256 digest rather than
48 /// silently shown as nothing.
49 ///
50 /// Args:
51 /// * `signed_bytes`: the bytes the signature covers.
52 ///
53 /// Usage:
54 /// ```ignore
55 /// let summary = AuthorizationSummary::from_signed_bytes(&payload);
56 /// println!("Authorizing: {summary}");
57 /// ```
58 pub fn from_signed_bytes(signed_bytes: &[u8]) -> Self {
59 if let Some(action) = parse_action(signed_bytes) {
60 return action;
61 }
62 if let Some(commit) = parse_commit(signed_bytes) {
63 return commit;
64 }
65 AuthorizationSummary::Opaque {
66 digest_hex: hex::encode(Sha256::digest(signed_bytes)),
67 }
68 }
69}
70
71/// Parse signed action-request bytes (canonical JSON carrying `type`, `identity`, `payload`).
72fn parse_action(bytes: &[u8]) -> Option<AuthorizationSummary> {
73 let value: Value = serde_json::from_slice(bytes).ok()?;
74 let obj = value.as_object()?;
75 let action_type = obj.get("type")?.as_str()?.to_string();
76 let identity = obj.get("identity")?.as_str()?.to_string();
77 let payload = obj.get("payload")?;
78 let details = payload
79 .as_object()
80 .map(|m| {
81 m.iter()
82 .map(|(k, v)| (k.clone(), render_scalar(v)))
83 .collect()
84 })
85 .unwrap_or_default();
86 Some(AuthorizationSummary::Action {
87 action_type,
88 identity,
89 details,
90 })
91}
92
93/// Parse a git commit object (`tree …` header, a blank line, then the message). The `author`
94/// line is intentionally not read — it is an unverified self-claim, not the authorizing identity.
95fn parse_commit(bytes: &[u8]) -> Option<AuthorizationSummary> {
96 let text = std::str::from_utf8(bytes).ok()?;
97 if !text.starts_with("tree ") {
98 return None;
99 }
100 let message = text
101 .split_once("\n\n")
102 .map(|(_, m)| m.trim_end().to_string())
103 .unwrap_or_default();
104 Some(AuthorizationSummary::Commit { message })
105}
106
107/// Render a JSON value as a compact display string (strings unquoted, others as JSON).
108fn render_scalar(v: &Value) -> String {
109 match v {
110 Value::String(s) => s.clone(),
111 other => other.to_string(),
112 }
113}
114
115impl fmt::Display for AuthorizationSummary {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 match self {
118 AuthorizationSummary::Action {
119 action_type,
120 identity,
121 details,
122 } => {
123 write!(f, "action {action_type} by {identity}")?;
124 for (k, v) in details {
125 write!(f, "; {k}={v}")?;
126 }
127 Ok(())
128 }
129 AuthorizationSummary::Commit { message } => {
130 let subject = message.lines().next().unwrap_or("");
131 write!(f, "commit: {subject}")
132 }
133 AuthorizationSummary::Opaque { digest_hex } => {
134 write!(f, "opaque payload sha256:{digest_hex}")
135 }
136 }
137 }
138}