Skip to main content

tf_types/
offline_approval.rs

1//! Offline-signed approval packets — Rust mirror of
2//! `tools/tf-types-ts/src/core/offline-approval.ts`.
3
4use base64::engine::general_purpose::STANDARD;
5use base64::Engine;
6use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10
11use crate::canonicalize;
12
13#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
14pub struct OfflineApprovalPacket {
15    pub packet_version: String,
16    pub request: Value,
17    pub decision: String,
18    pub responder: String,
19    pub responded_at: String,
20    pub transport_hint: String,
21    pub signature: SignatureEnvelope,
22}
23
24#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
25pub struct SignatureEnvelope {
26    pub algorithm: String,
27    pub signer: String,
28    pub signature: String,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
32pub struct OfflineApprovalCeremony {
33    pub ceremony_version: String,
34    pub ceremony_id: String,
35    pub kind: String,
36    pub request_id: String,
37    pub responder: String,
38    pub packet_id: String,
39    pub transport_hint: String,
40    pub signature: String,
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct ApprovalResponse {
45    pub response_version: String,
46    pub request_id: String,
47    pub decision: String,
48    pub responder: String,
49    pub signed_at: String,
50    pub signature: SignatureEnvelope,
51}
52
53#[derive(Debug)]
54pub struct VerifyOfflineApprovalResult {
55    pub ok: bool,
56    pub reason: Option<String>,
57    pub response: Option<ApprovalResponse>,
58    pub ceremony: Option<OfflineApprovalCeremony>,
59}
60
61pub fn sign_offline_approval_packet(
62    request: Value,
63    decision: &str,
64    responder: &str,
65    private_key: &[u8; 32],
66    transport_hint: &str,
67    responded_at: Option<&str>,
68) -> OfflineApprovalPacket {
69    let responded_at = responded_at.map(str::to_string).unwrap_or_else(now_iso8601);
70    let payload_value = serde_json::json!({
71        "request": request,
72        "decision": decision,
73        "responder": responder,
74        "responded_at": responded_at,
75    });
76    let canonical = canonicalize(&payload_value).unwrap_or_default();
77    let digest: [u8; 32] = Sha256::digest(canonical.as_bytes()).into();
78    let signing = SigningKey::from_bytes(private_key);
79    let sig: Signature = signing.sign(&digest);
80    OfflineApprovalPacket {
81        packet_version: "1".into(),
82        request,
83        decision: decision.into(),
84        responder: responder.into(),
85        responded_at,
86        transport_hint: transport_hint.into(),
87        signature: SignatureEnvelope {
88            algorithm: "ed25519".into(),
89            signer: responder.into(),
90            signature: STANDARD.encode(sig.to_bytes()),
91        },
92    }
93}
94
95pub fn verify_offline_approval_packet(
96    packet: &OfflineApprovalPacket,
97    public_key: &[u8; 32],
98    now: Option<&str>,
99    max_age_seconds: Option<i64>,
100) -> VerifyOfflineApprovalResult {
101    if packet.packet_version != "1" {
102        return rejected(format!(
103            "unsupported packet_version {}",
104            packet.packet_version
105        ));
106    }
107    if packet.signature.signer != packet.responder {
108        return rejected("signature signer does not match responder".into());
109    }
110    if packet.signature.algorithm != "ed25519" {
111        return rejected(format!(
112            "unsupported signature algorithm {}",
113            packet.signature.algorithm
114        ));
115    }
116    let max = max_age_seconds.unwrap_or(86_400);
117    if let (Some(now_str), Ok(then_secs)) = (now, parse_iso8601(&packet.responded_at)) {
118        if let Ok(now_secs) = parse_iso8601(now_str) {
119            let age = now_secs - then_secs;
120            if age > max {
121                return rejected(format!("packet older than {}s", max));
122            }
123            if age < -300 {
124                return rejected("packet timestamp is in the future".into());
125            }
126        }
127    }
128    let payload_value = serde_json::json!({
129        "request": packet.request,
130        "decision": packet.decision,
131        "responder": packet.responder,
132        "responded_at": packet.responded_at,
133    });
134    let canonical = canonicalize(&payload_value).unwrap_or_default();
135    let digest: [u8; 32] = Sha256::digest(canonical.as_bytes()).into();
136    let sig_bytes = match STANDARD.decode(&packet.signature.signature) {
137        Ok(b) => b,
138        Err(e) => return rejected(format!("signature base64 decode: {}", e)),
139    };
140    let sig = match Signature::from_slice(&sig_bytes) {
141        Ok(s) => s,
142        Err(e) => return rejected(format!("signature parse: {}", e)),
143    };
144    let vk = match VerifyingKey::from_bytes(public_key) {
145        Ok(v) => v,
146        Err(e) => return rejected(format!("verifying key: {}", e)),
147    };
148    if vk.verify(&digest, &sig).is_err() {
149        return rejected("signature verification failed".into());
150    }
151    let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
152    let packet_id = format!("pkt-{}", &hex[..16]);
153    let request_id = packet
154        .request
155        .get("id")
156        .and_then(|v| v.as_str())
157        .unwrap_or_default()
158        .to_string();
159    let response = ApprovalResponse {
160        response_version: "1".into(),
161        request_id: request_id.clone(),
162        decision: packet.decision.clone(),
163        responder: packet.responder.clone(),
164        signed_at: packet.responded_at.clone(),
165        signature: packet.signature.clone(),
166    };
167    let ceremony = OfflineApprovalCeremony {
168        ceremony_version: "1".into(),
169        ceremony_id: format!("cer-{}", packet_id),
170        kind: "offline-signed-packet".into(),
171        request_id,
172        responder: packet.responder.clone(),
173        packet_id,
174        transport_hint: packet.transport_hint.clone(),
175        signature: packet.signature.signature.clone(),
176    };
177    VerifyOfflineApprovalResult {
178        ok: true,
179        reason: None,
180        response: Some(response),
181        ceremony: Some(ceremony),
182    }
183}
184
185fn rejected(reason: String) -> VerifyOfflineApprovalResult {
186    VerifyOfflineApprovalResult {
187        ok: false,
188        reason: Some(reason),
189        response: None,
190        ceremony: None,
191    }
192}
193
194fn now_iso8601() -> String {
195    let secs = std::time::SystemTime::now()
196        .duration_since(std::time::UNIX_EPOCH)
197        .unwrap_or_default()
198        .as_secs() as i64;
199    let (y, m, d, h, mi, s) = secs_to_ymdhms(secs);
200    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, h, mi, s)
201}
202
203fn parse_iso8601(s: &str) -> Result<i64, ()> {
204    if s.len() < 19 || !s.ends_with('Z') {
205        return Err(());
206    }
207    let year: i64 = s[..4].parse().map_err(|_| ())?;
208    let month: u32 = s[5..7].parse().map_err(|_| ())?;
209    let day: u32 = s[8..10].parse().map_err(|_| ())?;
210    let hour: u32 = s[11..13].parse().map_err(|_| ())?;
211    let minute: u32 = s[14..16].parse().map_err(|_| ())?;
212    let second: u32 = s[17..19].parse().map_err(|_| ())?;
213    Ok(unix_from_civil(year, month, day, hour, minute, second))
214}
215
216fn unix_from_civil(year: i64, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 {
217    // Howard Hinnant days_from_civil
218    let y = if month <= 2 { year - 1 } else { year };
219    let era = if y >= 0 { y } else { y - 399 } / 400;
220    let yoe = (y - era * 400) as u64;
221    let m = if month > 2 { month - 3 } else { month + 9 };
222    let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
223    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
224    let days = era * 146_097 + doe as i64 - 719_468;
225    days * 86_400 + (hour as i64) * 3600 + (minute as i64) * 60 + second as i64
226}
227
228fn secs_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
229    let days = secs.div_euclid(86_400);
230    let time = secs.rem_euclid(86_400);
231    let hour = (time / 3600) as u32;
232    let minute = ((time % 3600) / 60) as u32;
233    let second = (time % 60) as u32;
234    let z = days + 719_468;
235    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
236    let doe = (z - era * 146_097) as u64;
237    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
238    let y = yoe as i64 + era * 400;
239    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
240    let mp = (5 * doy + 2) / 153;
241    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
242    let m = if mp < 10 {
243        (mp + 3) as u32
244    } else {
245        (mp - 9) as u32
246    };
247    let year = if m <= 2 { y + 1 } else { y };
248    (year as i32, m, d, hour, minute, second)
249}