Skip to main content

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}