Skip to main content

cpop_protocol/war/
encoding.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use super::types::{Block, Seal, Version};
4use chrono::{DateTime, Utc};
5use hex;
6
7impl Block {
8    /// Encode the WAR block as ASCII-armored text.
9    pub fn encode_ascii(&self) -> String {
10        let mut output = String::new();
11
12        output.push_str("-----BEGIN CPOP WAR-----\n");
13        output.push_str(&format!("Version: {}\n", self.version.as_str()));
14        output.push_str(&format!("Author: {}\n", self.author));
15        output.push_str(&format!("Document-ID: {}\n", hex::encode(self.document_id)));
16        output.push_str(&format!("Timestamp: {}\n", self.timestamp.to_rfc3339()));
17        if let Some(nonce) = &self.verifier_nonce {
18            output.push_str(&format!("Verifier-Nonce: {}\n", hex::encode(nonce)));
19        }
20        output.push('\n');
21
22        for line in word_wrap(&self.statement, 72) {
23            output.push_str(&line);
24            output.push('\n');
25        }
26
27        output.push('\n');
28        output.push_str("-----BEGIN SEAL-----\n");
29
30        let seal_hex = self.seal.encode_hex();
31        for chunk in seal_hex.as_bytes().chunks(64) {
32            output.push_str(std::str::from_utf8(chunk).unwrap_or(""));
33            output.push('\n');
34        }
35
36        output.push_str("-----END SEAL-----\n");
37        output.push_str("-----END CPOP WAR-----\n");
38
39        output
40    }
41
42    /// Decode a WAR block from ASCII-armored text.
43    pub fn decode_ascii(text: &str) -> Result<Self, String> {
44        let lines: Vec<&str> = text.lines().collect();
45
46        // Accept CPOP WAR (current), POP WAR (previous), and legacy format
47        let start = lines
48            .iter()
49            .position(|l| {
50                l.contains("BEGIN CPOP WAR")
51                    || l.contains("BEGIN POP WAR")
52                    || l.contains("BEGIN WITNESSD AUTHORSHIP RECORD")
53            })
54            .ok_or("missing WAR block header")?;
55        let end = lines
56            .iter()
57            .position(|l| {
58                l.contains("END CPOP WAR")
59                    || l.contains("END POP WAR")
60                    || l.contains("END WITNESSD AUTHORSHIP RECORD")
61            })
62            .ok_or("missing WAR block footer")?;
63
64        if start >= end {
65            return Err("invalid block structure".to_string());
66        }
67
68        let mut version = Version::V1_0;
69        let mut author = String::new();
70        let mut document_id = [0u8; 32];
71        let mut timestamp = Utc::now();
72        let mut verifier_nonce: Option<[u8; 32]> = None;
73        let mut header_end = start + 1;
74
75        for (i, line) in lines[start + 1..end].iter().enumerate() {
76            if line.is_empty() {
77                header_end = start + 1 + i;
78                break;
79            }
80
81            if let Some(val) = line.strip_prefix("Version: ") {
82                version =
83                    Version::parse(val.trim()).ok_or_else(|| format!("unknown version: {val}"))?;
84            } else if let Some(val) = line.strip_prefix("Author: ") {
85                author = val.trim().to_string();
86            } else if let Some(val) = line.strip_prefix("Document-ID: ") {
87                let bytes =
88                    hex::decode(val.trim()).map_err(|e| format!("invalid document ID: {e}"))?;
89                if bytes.len() != 32 {
90                    return Err("document ID must be 32 bytes".to_string());
91                }
92                document_id.copy_from_slice(&bytes);
93            } else if let Some(val) = line.strip_prefix("Timestamp: ") {
94                timestamp = DateTime::parse_from_rfc3339(val.trim())
95                    .map_err(|e| format!("invalid timestamp: {e}"))?
96                    .with_timezone(&Utc);
97            } else if let Some(val) = line.strip_prefix("Verifier-Nonce: ") {
98                let bytes =
99                    hex::decode(val.trim()).map_err(|e| format!("invalid verifier nonce: {e}"))?;
100                if bytes.len() != 32 {
101                    return Err("verifier nonce must be 32 bytes".to_string());
102                }
103                let mut nonce = [0u8; 32];
104                nonce.copy_from_slice(&bytes);
105                verifier_nonce = Some(nonce);
106            }
107        }
108
109        let seal_start = lines[start..end]
110            .iter()
111            .position(|l| l.contains("BEGIN SEAL"))
112            .map(|pos| start + pos)
113            .ok_or("missing seal header")?;
114        let seal_end = lines[start..end]
115            .iter()
116            .position(|l| l.contains("END SEAL"))
117            .map(|pos| start + pos)
118            .ok_or("missing seal footer")?;
119
120        let statement_lines: Vec<&str> = lines[header_end + 1..seal_start]
121            .iter()
122            .filter(|l| !l.is_empty())
123            .copied()
124            .collect();
125        let statement = statement_lines.join(" ");
126
127        let seal_hex: String = lines[seal_start + 1..seal_end]
128            .iter()
129            .map(|l| l.trim())
130            .collect();
131        let seal = Seal::decode_hex(&seal_hex)?;
132        let signed = seal.signature != [0u8; 64];
133
134        Ok(Self {
135            version,
136            author,
137            document_id,
138            timestamp,
139            statement,
140            seal,
141            signed,
142            verifier_nonce,
143            ear: None,
144        })
145    }
146}
147
148impl Seal {
149    /// Encode the seal as a hex string.
150    pub fn encode_hex(&self) -> String {
151        let mut data = Vec::with_capacity(32 * 3 + 64 + 32);
152        data.extend_from_slice(&self.h1);
153        data.extend_from_slice(&self.h2);
154        data.extend_from_slice(&self.h3);
155        data.extend_from_slice(&self.signature);
156        data.extend_from_slice(&self.public_key);
157        hex::encode(data)
158    }
159
160    /// Decode the seal from a hex string.
161    pub fn decode_hex(hex_str: &str) -> Result<Self, String> {
162        let data = hex::decode(hex_str).map_err(|e| format!("invalid seal hex: {e}"))?;
163        if data.len() != 32 * 3 + 64 + 32 {
164            return Err(format!(
165                "invalid seal length: expected {}, got {}",
166                32 * 3 + 64 + 32,
167                data.len()
168            ));
169        }
170
171        let mut h1 = [0u8; 32];
172        let mut h2 = [0u8; 32];
173        let mut h3 = [0u8; 32];
174        let mut signature = [0u8; 64];
175        let mut public_key = [0u8; 32];
176
177        h1.copy_from_slice(&data[0..32]);
178        h2.copy_from_slice(&data[32..64]);
179        h3.copy_from_slice(&data[64..96]);
180        signature.copy_from_slice(&data[96..160]);
181        public_key.copy_from_slice(&data[160..192]);
182
183        Ok(Self {
184            h1,
185            h2,
186            h3,
187            signature,
188            public_key,
189        })
190    }
191}
192
193/// Word wrap text at specified width.
194pub fn word_wrap(text: &str, width: usize) -> Vec<String> {
195    let mut lines = Vec::new();
196    let mut current_line = String::new();
197
198    for word in text.split_whitespace() {
199        if current_line.is_empty() {
200            current_line = word.to_string();
201        } else if current_line.len() + 1 + word.len() <= width {
202            current_line.push(' ');
203            current_line.push_str(word);
204        } else {
205            lines.push(current_line);
206            current_line = word.to_string();
207        }
208    }
209
210    if !current_line.is_empty() {
211        lines.push(current_line);
212    }
213
214    lines
215}