Skip to main content

this_me/kernel/
proof.rs

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    // This mirrors TypeScript normalizeProofMessage(payload): object keys sorted
164    // lexicographically, JSON string escaping for string leaves, null challenge
165    // when omitted.
166    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}