1use std::fmt;
2
3use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
4use hkdf::Hkdf;
5use sha2::Sha256;
6use sha3::{Digest, Keccak256};
7
8use super::secret_material::{base64_url_decode, base64_url_encode};
9
10const IDENTITY_HASH_DOMAIN: &str = "this.me/identity:v1::";
11const COMPOUND_SEED_DOMAIN: &str = "me.seed/compound:v1::";
12const PROVE_KDF_INFO: &str = "me.prove.v1";
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ProofInput {
16 pub root_namespace: String,
17 pub challenge: Option<String>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ProofResult {
22 pub identity_hash: String,
23 pub expression: String,
24 pub namespace: String,
25 pub root_namespace: String,
26 pub public_key: String,
27 pub message: String,
28 pub signature: String,
29 pub timestamp: u64,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ProofError {
34 EmptySeed,
35 ActiveExpressionRequired,
36 RootNamespaceRequired,
37 HkdfExpandFailed,
38 InvalidSigningSeed,
39 InvalidPublicKey,
40 InvalidSignature,
41}
42
43impl fmt::Display for ProofError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 Self::EmptySeed => write!(f, "seed material is required"),
47 Self::ActiveExpressionRequired => write!(f, "ACTIVE_EXPRESSION_REQUIRED"),
48 Self::RootNamespaceRequired => write!(f, "ROOT_NAMESPACE_REQUIRED"),
49 Self::HkdfExpandFailed => write!(f, "proof HKDF expansion failed"),
50 Self::InvalidSigningSeed => write!(f, "Ed25519 signing seed must be exactly 32 bytes"),
51 Self::InvalidPublicKey => write!(f, "public key is not a valid Ed25519 key"),
52 Self::InvalidSignature => write!(f, "signature is not a valid Ed25519 signature"),
53 }
54 }
55}
56
57impl std::error::Error for ProofError {}
58
59pub fn derive_compound_seed(who: &str, secret: &str) -> String {
60 keccak256_hex(format!("{COMPOUND_SEED_DOMAIN}{who}::{secret}").as_bytes())
61}
62
63pub fn derive_identity_hash(seed: &str) -> String {
64 keccak256_hex(format!("{IDENTITY_HASH_DOMAIN}{seed}").as_bytes())
65}
66
67pub fn derive_branch_proof_seed(seed: &str, expression: &str) -> Result<[u8; 32], ProofError> {
68 let expression = expression.trim();
69 if expression.is_empty() {
70 return Err(ProofError::ActiveExpressionRequired);
71 }
72 let ikm = decode_seed_material(seed)?;
73 let hkdf = Hkdf::<Sha256>::new(Some(PROVE_KDF_INFO.as_bytes()), &ikm);
74 let mut out = [0_u8; 32];
75 hkdf.expand(expression.as_bytes(), &mut out)
76 .map_err(|_| ProofError::HkdfExpandFailed)?;
77 Ok(out)
78}
79
80pub fn prove_with_timestamp(
81 seed: &str,
82 expression: &str,
83 root_namespace: &str,
84 challenge: Option<&str>,
85 timestamp: u64,
86) -> Result<ProofResult, ProofError> {
87 let expression = expression.trim();
88 if expression.is_empty() {
89 return Err(ProofError::ActiveExpressionRequired);
90 }
91 let root_namespace = normalize_root_namespace(root_namespace);
92 if root_namespace.is_empty() {
93 return Err(ProofError::RootNamespaceRequired);
94 }
95
96 let identity_hash = derive_identity_hash(seed);
97 let namespace = format!("{expression}.{root_namespace}");
98 let branch_seed = derive_branch_proof_seed(seed, expression)?;
99 let signing_key = SigningKey::from_bytes(&branch_seed);
100 let verifying_key = signing_key.verifying_key();
101 let challenge = challenge.map(str::to_string);
102 let message = normalize_proof_payload(
103 &identity_hash,
104 expression,
105 &namespace,
106 &root_namespace,
107 challenge.as_deref(),
108 timestamp,
109 );
110 let signature = signing_key.sign(message.as_bytes());
111
112 Ok(ProofResult {
113 identity_hash,
114 expression: expression.to_string(),
115 namespace,
116 root_namespace,
117 public_key: base64_url_encode(verifying_key.as_bytes()),
118 message,
119 signature: base64_url_encode(&signature.to_bytes()),
120 timestamp,
121 })
122}
123
124pub fn verify_ed25519_signature(public_key: &str, message: &str, signature: &str) -> bool {
125 let Some(public_key) = base64_url_decode(public_key) else {
126 return false;
127 };
128 let Ok(public_key) = <[u8; 32]>::try_from(public_key.as_slice()) else {
129 return false;
130 };
131 let Ok(verifying_key) = VerifyingKey::from_bytes(&public_key) else {
132 return false;
133 };
134 let Some(signature) = base64_url_decode(signature) else {
135 return false;
136 };
137 let Ok(signature) = Signature::from_slice(&signature) else {
138 return false;
139 };
140 verifying_key.verify(message.as_bytes(), &signature).is_ok()
141}
142
143pub fn normalize_root_namespace(root_namespace: &str) -> String {
144 root_namespace
145 .trim()
146 .strip_prefix("http://")
147 .or_else(|| root_namespace.trim().strip_prefix("https://"))
148 .unwrap_or(root_namespace.trim())
149 .trim_end_matches('/')
150 .trim_start_matches('.')
151 .trim_end_matches('.')
152 .to_string()
153}
154
155pub fn normalize_proof_payload(
156 identity_hash: &str,
157 expression: &str,
158 namespace: &str,
159 root_namespace: &str,
160 challenge: Option<&str>,
161 timestamp: u64,
162) -> String {
163 format!(
167 "{{\"challenge\":{},\"expression\":{},\"identityHash\":{},\"namespace\":{},\"rootNamespace\":{},\"timestamp\":{}}}",
168 json_string_or_null(challenge),
169 json_string(expression),
170 json_string(identity_hash),
171 json_string(namespace),
172 json_string(root_namespace),
173 timestamp,
174 )
175}
176
177fn decode_seed_material(seed: &str) -> Result<Vec<u8>, ProofError> {
178 let raw = seed.trim();
179 if raw.is_empty() {
180 return Err(ProofError::EmptySeed);
181 }
182 let normalized = raw.strip_prefix("0x").unwrap_or(raw);
183 if !normalized.is_empty()
184 && normalized.len().is_multiple_of(2)
185 && normalized.chars().all(|ch| ch.is_ascii_hexdigit())
186 {
187 return hex_decode(normalized).ok_or(ProofError::EmptySeed);
188 }
189 Ok(raw.as_bytes().to_vec())
190}
191
192fn keccak256_hex(bytes: &[u8]) -> String {
193 let digest = Keccak256::digest(bytes);
194 digest.iter().map(|byte| format!("{byte:02x}")).collect()
195}
196
197fn hex_decode(input: &str) -> Option<Vec<u8>> {
198 if !input.len().is_multiple_of(2) {
199 return None;
200 }
201 (0..input.len())
202 .step_by(2)
203 .map(|index| u8::from_str_radix(&input[index..index + 2], 16).ok())
204 .collect()
205}
206
207fn json_string_or_null(value: Option<&str>) -> String {
208 value.map(json_string).unwrap_or_else(|| "null".to_string())
209}
210
211fn json_string(value: &str) -> String {
212 let mut out = String::from("\"");
213 for ch in value.chars() {
214 match ch {
215 '"' => out.push_str("\\\""),
216 '\\' => out.push_str("\\\\"),
217 '\u{08}' => out.push_str("\\b"),
218 '\u{0c}' => out.push_str("\\f"),
219 '\n' => out.push_str("\\n"),
220 '\r' => out.push_str("\\r"),
221 '\t' => out.push_str("\\t"),
222 ch if ch <= '\u{1f}' => out.push_str(&format!("\\u{:04x}", ch as u32)),
223 ch => out.push(ch),
224 }
225 }
226 out.push('"');
227 out
228}