use std::fs;
use std::path::{Path, PathBuf};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Proof {
pub v: u32,
pub provider: String,
pub subject: String,
pub email: Option<String>,
pub loa: u8,
pub scope: String,
pub verified_at: u64,
pub expires_at: u64,
pub nonce: String,
pub sig: String,
}
impl Proof {
fn canonical_bytes(&self) -> Vec<u8> {
let mut canon = serde_json::Map::new();
canon.insert("v".into(), self.v.into());
canon.insert("provider".into(), self.provider.clone().into());
canon.insert("subject".into(), self.subject.clone().into());
canon.insert(
"email".into(),
self.email.clone().map(serde_json::Value::String).unwrap_or(serde_json::Value::Null),
);
canon.insert("loa".into(), self.loa.into());
canon.insert("scope".into(), self.scope.clone().into());
canon.insert("verified_at".into(), self.verified_at.into());
canon.insert("expires_at".into(), self.expires_at.into());
canon.insert("nonce".into(), self.nonce.clone().into());
serde_json::to_vec(&serde_json::Value::Object(canon)).expect("canonical JSON serialisation must succeed")
}
}
pub struct ProofSigner {
signing: SigningKey,
verifying: VerifyingKey,
#[allow(dead_code)]
key_path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredKey {
v: u32,
alg: String,
private: String,
public: String,
}
impl ProofSigner {
pub fn load_or_create(state_dir: &Path) -> anyhow::Result<Self> {
fs::create_dir_all(state_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(state_dir, fs::Permissions::from_mode(0o700));
}
let key_path = state_dir.join("identity-key");
if key_path.exists() {
if let Ok(raw) = fs::read_to_string(&key_path) {
if let Ok(stored) = serde_json::from_str::<StoredKey>(&raw) {
if stored.alg == "ed25519" {
if let (Ok(priv_bytes), Ok(pub_bytes)) =
(hex::decode(&stored.private), hex::decode(&stored.public))
{
if priv_bytes.len() == 32 && pub_bytes.len() == 32 {
let signing = SigningKey::from_bytes(&priv_bytes.try_into().unwrap());
let verifying = signing.verifying_key();
return Ok(Self { signing, verifying, key_path });
}
}
}
}
}
}
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let stored = StoredKey {
v: 1,
alg: "ed25519".into(),
private: hex::encode(signing.to_bytes()),
public: hex::encode(verifying.to_bytes()),
};
let body = serde_json::to_string_pretty(&stored)? + "\n";
fs::write(&key_path, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
}
Ok(Self { signing, verifying, key_path })
}
pub fn sign(&self, mut proof: Proof) -> anyhow::Result<Proof> {
let bytes = proof.canonical_bytes();
let sig: Signature = self.signing.sign(&bytes);
let b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD_NO_PAD,
sig.to_bytes(),
);
proof.sig = format!("ed25519:{}", b64);
Ok(proof)
}
pub fn verify(&self, proof: &Proof) -> anyhow::Result<()> {
let (alg, b64) = proof
.sig
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("proof.sig missing algorithm prefix"))?;
if alg != "ed25519" {
anyhow::bail!("unsupported proof signature alg '{}'", alg);
}
let sig_bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD_NO_PAD,
b64,
)
.map_err(|e| anyhow::anyhow!("base64 decode of proof.sig: {}", e))?;
if sig_bytes.len() != 64 {
anyhow::bail!("proof.sig wrong length ({} bytes)", sig_bytes.len());
}
let sig = Signature::from_slice(&sig_bytes)
.map_err(|e| anyhow::anyhow!("bad ed25519 signature: {}", e))?;
self.verifying
.verify(&proof.canonical_bytes(), &sig)
.map_err(|e| anyhow::anyhow!("ed25519 verify failed: {}", e))?;
Ok(())
}
pub fn public_key_hex(&self) -> String {
hex::encode(self.verifying.to_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn proof(subject: &str) -> Proof {
Proof {
v: 1,
provider: "mock".into(),
subject: subject.into(),
email: Some("[email protected]".into()),
loa: 2,
scope: "scm.commit".into(),
verified_at: 1_700_000_000,
expires_at: 1_700_000_900,
nonce: "abc".into(),
sig: String::new(),
}
}
#[test]
fn sign_then_verify_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let s = ProofSigner::load_or_create(tmp.path()).unwrap();
let signed = s.sign(proof("sub-a")).unwrap();
assert!(signed.sig.starts_with("ed25519:"));
s.verify(&signed).expect("signature must verify");
}
#[test]
fn tampered_proof_fails_verify() {
let tmp = tempfile::tempdir().unwrap();
let s = ProofSigner::load_or_create(tmp.path()).unwrap();
let mut signed = s.sign(proof("sub-a")).unwrap();
signed.loa = 3; assert!(s.verify(&signed).is_err());
}
#[test]
fn different_keys_cannot_verify_each_other() {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
let a = ProofSigner::load_or_create(tmp1.path()).unwrap();
let b = ProofSigner::load_or_create(tmp2.path()).unwrap();
let signed = a.sign(proof("sub-a")).unwrap();
assert!(a.verify(&signed).is_ok());
assert!(b.verify(&signed).is_err());
}
#[test]
fn key_persists_across_loads() {
let tmp = tempfile::tempdir().unwrap();
let a = ProofSigner::load_or_create(tmp.path()).unwrap();
let signed = a.sign(proof("sub-a")).unwrap();
drop(a);
let b = ProofSigner::load_or_create(tmp.path()).unwrap();
b.verify(&signed).expect("regenerated signer must verify proofs from prior session");
}
}