aperion_shield/identity/
proof.rs1use std::fs;
32use std::path::{Path, PathBuf};
33
34use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
35use rand::rngs::OsRng;
36use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Proof {
45 pub v: u32,
47 pub provider: String,
48 pub subject: String,
49 pub email: Option<String>,
50 pub loa: u8,
51 pub scope: String,
52 pub verified_at: u64,
53 pub expires_at: u64,
54 pub nonce: String,
55 pub sig: String,
57}
58
59impl Proof {
60 fn canonical_bytes(&self) -> Vec<u8> {
64 let mut canon = serde_json::Map::new();
66 canon.insert("v".into(), self.v.into());
67 canon.insert("provider".into(), self.provider.clone().into());
68 canon.insert("subject".into(), self.subject.clone().into());
69 canon.insert(
70 "email".into(),
71 self.email.clone().map(serde_json::Value::String).unwrap_or(serde_json::Value::Null),
72 );
73 canon.insert("loa".into(), self.loa.into());
74 canon.insert("scope".into(), self.scope.clone().into());
75 canon.insert("verified_at".into(), self.verified_at.into());
76 canon.insert("expires_at".into(), self.expires_at.into());
77 canon.insert("nonce".into(), self.nonce.clone().into());
78 serde_json::to_vec(&serde_json::Value::Object(canon)).expect("canonical JSON serialisation must succeed")
79 }
80}
81
82pub struct ProofSigner {
86 signing: SigningKey,
87 verifying: VerifyingKey,
88 #[allow(dead_code)]
89 key_path: PathBuf,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93struct StoredKey {
94 v: u32,
95 alg: String,
96 private: String,
97 public: String,
98}
99
100impl ProofSigner {
101 pub fn load_or_create(state_dir: &Path) -> anyhow::Result<Self> {
104 fs::create_dir_all(state_dir)?;
105 #[cfg(unix)]
106 {
107 use std::os::unix::fs::PermissionsExt;
108 let _ = fs::set_permissions(state_dir, fs::Permissions::from_mode(0o700));
109 }
110
111 let key_path = state_dir.join("identity-key");
112 if key_path.exists() {
113 if let Ok(raw) = fs::read_to_string(&key_path) {
114 if let Ok(stored) = serde_json::from_str::<StoredKey>(&raw) {
115 if stored.alg == "ed25519" {
116 if let (Ok(priv_bytes), Ok(pub_bytes)) =
117 (hex::decode(&stored.private), hex::decode(&stored.public))
118 {
119 if priv_bytes.len() == 32 && pub_bytes.len() == 32 {
120 let signing = SigningKey::from_bytes(&priv_bytes.try_into().unwrap());
121 let verifying = signing.verifying_key();
122 return Ok(Self { signing, verifying, key_path });
123 }
124 }
125 }
126 }
127 }
128 }
130
131 let signing = SigningKey::generate(&mut OsRng);
132 let verifying = signing.verifying_key();
133 let stored = StoredKey {
134 v: 1,
135 alg: "ed25519".into(),
136 private: hex::encode(signing.to_bytes()),
137 public: hex::encode(verifying.to_bytes()),
138 };
139 let body = serde_json::to_string_pretty(&stored)? + "\n";
140 fs::write(&key_path, body)?;
141 #[cfg(unix)]
142 {
143 use std::os::unix::fs::PermissionsExt;
144 let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
145 }
146 Ok(Self { signing, verifying, key_path })
147 }
148
149 pub fn sign(&self, mut proof: Proof) -> anyhow::Result<Proof> {
151 let bytes = proof.canonical_bytes();
152 let sig: Signature = self.signing.sign(&bytes);
153 let b64 = base64::Engine::encode(
154 &base64::engine::general_purpose::STANDARD_NO_PAD,
155 sig.to_bytes(),
156 );
157 proof.sig = format!("ed25519:{}", b64);
158 Ok(proof)
159 }
160
161 pub fn verify(&self, proof: &Proof) -> anyhow::Result<()> {
164 let (alg, b64) = proof
165 .sig
166 .split_once(':')
167 .ok_or_else(|| anyhow::anyhow!("proof.sig missing algorithm prefix"))?;
168 if alg != "ed25519" {
169 anyhow::bail!("unsupported proof signature alg '{}'", alg);
170 }
171 let sig_bytes = base64::Engine::decode(
172 &base64::engine::general_purpose::STANDARD_NO_PAD,
173 b64,
174 )
175 .map_err(|e| anyhow::anyhow!("base64 decode of proof.sig: {}", e))?;
176 if sig_bytes.len() != 64 {
177 anyhow::bail!("proof.sig wrong length ({} bytes)", sig_bytes.len());
178 }
179 let sig = Signature::from_slice(&sig_bytes)
180 .map_err(|e| anyhow::anyhow!("bad ed25519 signature: {}", e))?;
181 self.verifying
182 .verify(&proof.canonical_bytes(), &sig)
183 .map_err(|e| anyhow::anyhow!("ed25519 verify failed: {}", e))?;
184 Ok(())
185 }
186
187 pub fn public_key_hex(&self) -> String {
191 hex::encode(self.verifying.to_bytes())
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn proof(subject: &str) -> Proof {
200 Proof {
201 v: 1,
202 provider: "mock".into(),
203 subject: subject.into(),
204 email: Some("[email protected]".into()),
205 loa: 2,
206 scope: "scm.commit".into(),
207 verified_at: 1_700_000_000,
208 expires_at: 1_700_000_900,
209 nonce: "abc".into(),
210 sig: String::new(),
211 }
212 }
213
214 #[test]
215 fn sign_then_verify_roundtrip() {
216 let tmp = tempfile::tempdir().unwrap();
217 let s = ProofSigner::load_or_create(tmp.path()).unwrap();
218 let signed = s.sign(proof("sub-a")).unwrap();
219 assert!(signed.sig.starts_with("ed25519:"));
220 s.verify(&signed).expect("signature must verify");
221 }
222
223 #[test]
224 fn tampered_proof_fails_verify() {
225 let tmp = tempfile::tempdir().unwrap();
226 let s = ProofSigner::load_or_create(tmp.path()).unwrap();
227 let mut signed = s.sign(proof("sub-a")).unwrap();
228 signed.loa = 3; assert!(s.verify(&signed).is_err());
230 }
231
232 #[test]
233 fn different_keys_cannot_verify_each_other() {
234 let tmp1 = tempfile::tempdir().unwrap();
235 let tmp2 = tempfile::tempdir().unwrap();
236 let a = ProofSigner::load_or_create(tmp1.path()).unwrap();
237 let b = ProofSigner::load_or_create(tmp2.path()).unwrap();
238 let signed = a.sign(proof("sub-a")).unwrap();
239 assert!(a.verify(&signed).is_ok());
240 assert!(b.verify(&signed).is_err());
241 }
242
243 #[test]
244 fn key_persists_across_loads() {
245 let tmp = tempfile::tempdir().unwrap();
246 let a = ProofSigner::load_or_create(tmp.path()).unwrap();
247 let signed = a.sign(proof("sub-a")).unwrap();
248 drop(a);
249 let b = ProofSigner::load_or_create(tmp.path()).unwrap();
250 b.verify(&signed).expect("regenerated signer must verify proofs from prior session");
251 }
252}