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