Skip to main content

aperion_shield/identity/
proof.rs

1//! Cryptographic proofs of identity verification.
2//!
3//! Every successful [`super::IdentityProvider::exchange`] is turned into
4//! a [`Proof`] -- a small signed record asserting "user U verified at
5//! time T with LOA L, valid for scope S until time E".
6//!
7//! Why sign them?
8//!
9//! The proof file is JSON on disk. Without a signature, anything with
10//! filesystem access (a malicious script, another agent, a compromised
11//! IDE plugin) could append a row claiming "[email protected] verified
12//! seconds ago, LOA 3" and bypass every identity gate. Ed25519 means
13//! Shield is the only process holding the private key, so unsigned or
14//! mis-signed rows are rejected the moment the cache loads.
15//!
16//! Key on disk
17//! -----------
18//!
19//! `~/.aperion-shield/identity-key` is a JSON blob:
20//!
21//! ```json
22//! { "v": 1, "alg": "ed25519",
23//!   "private": "<32 bytes hex>", "public": "<32 bytes hex>" }
24//! ```
25//!
26//! Created with mode 0600 on first use. If the file is missing or
27//! malformed, a new keypair is generated and any previously-cached
28//! proofs become unverifiable (a feature, not a bug -- replacing the
29//! key forces a clean re-verify pass).
30
31use 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/// A signed verification proof. Persisted in the proof cache.
39///
40/// Field order is part of the canonicalisation contract -- the signature
41/// covers `bincode-of(canonical view)` which is just a deterministic
42/// JSON serialization minus the `sig` field itself.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Proof {
45    /// Schema version. Bump if we change canonicalisation.
46    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    /// `ed25519:<base64>`. Empty during construction, filled by [`ProofSigner::sign`].
56    pub sig: String,
57}
58
59impl Proof {
60    /// Compose the bytes the signature covers. Stable across processes
61    /// and OS so a proof signed on macOS is verifiable on Linux with
62    /// the same key.
63    fn canonical_bytes(&self) -> Vec<u8> {
64        // Deterministic JSON without the `sig` field.
65        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
82/// Ed25519 signer + verifier for [`Proof`]s. Owns a keypair stored on
83/// disk at `<state_dir>/identity-key` and provides the only path Shield
84/// uses to mint or validate proofs.
85pub 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    /// Load the existing key from `<state_dir>/identity-key`, or
102    /// generate one and persist it if absent.
103    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            // Fall through: regenerate on any parse failure.
129        }
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    /// Sign a proof in-place, returning the now-fully-populated value.
150    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    /// Verify a previously-minted proof. Returns Ok(()) if the
162    /// signature matches; an error otherwise.
163    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    /// Hex-encoded public key -- useful for surfacing in audit logs so
188    /// a fleet admin can confirm two laptops are running independent
189    /// shields.
190    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; // forge: bump LOA after signing
229        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}