Skip to main content

hypha/auth/
mod.rs

1use crate::site::SiteDir;
2use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
3use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
4use rand::RngExt;
5use serde::Serialize;
6use std::fs;
7use substrate::{
8    compute_signature, format_key, format_signature, KeyAlgorithm, SignatureAlgorithm,
9};
10use zeroize::Zeroize;
11
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14
15#[derive(serde::Serialize)]
16pub struct IdentityInfo {
17    pub domain: String,
18    pub public_key: String,
19    /// True when a new keypair was generated (first time), false when loaded existing.
20    pub newly_created: bool,
21}
22
23#[derive(Debug, thiserror::Error)]
24pub enum JsonSignError {
25    #[error("JCS serialization failed: {0}")]
26    Jcs(String),
27    #[error(transparent)]
28    Sign(#[from] anyhow::Error),
29}
30
31pub fn init_identity_with_site(domain: &str, site: &SiteDir) -> anyhow::Result<IdentityInfo> {
32    // Create directories
33    site.create_dirs()?;
34
35    let newly_created = !site.private_key_path().exists();
36    let verifying_key = if !newly_created {
37        // Identity already exists - load existing keypair and update DNS record
38        let pem_content = fs::read_to_string(site.private_key_path())?;
39        let signing_key = SigningKey::from_pkcs8_pem(&pem_content)
40            .map_err(|e| anyhow::anyhow!("Invalid private key PEM: {}", e))?;
41        signing_key.verifying_key()
42    } else {
43        // Generate new keypair
44        let mut secret_bytes = [0u8; 32];
45        rand::rng().fill(&mut secret_bytes[..]);
46        let signing_key = SigningKey::from_bytes(&secret_bytes);
47        secret_bytes.zeroize(); // Clear secret material from stack
48        let verifying_key = signing_key.verifying_key();
49        // SigningKey implements ZeroizeOnDrop via ed25519-dalek "zeroize" feature
50
51        // Save private key in PEM format (PKCS#8, OpenSSL compatible)
52        let private_key_path = site.private_key_path();
53        let private_pem = signing_key
54            .to_pkcs8_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
55            .map_err(|e| anyhow::anyhow!("Failed to encode private key: {}", e))?;
56
57        // Write with 0o600 from creation to avoid transient world-readable window
58        #[cfg(unix)]
59        {
60            use std::io::Write;
61            use std::os::unix::fs::OpenOptionsExt;
62            let mut f = fs::OpenOptions::new()
63                .write(true)
64                .create(true)
65                .truncate(true)
66                .mode(0o600)
67                .open(&private_key_path)?;
68            f.write_all(private_pem.as_bytes())?;
69        }
70        #[cfg(not(unix))]
71        fs::write(&private_key_path, private_pem.as_bytes())?;
72
73        verifying_key
74    };
75
76    // Always update public key (in case format changed)
77    let public_key = format_key(KeyAlgorithm::Ed25519, &verifying_key.to_bytes());
78    let public_pem = verifying_key
79        .to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
80        .map_err(|e| anyhow::anyhow!("Failed to encode public key: {}", e))?;
81    fs::write(site.public_key_path(), &public_pem)?;
82
83    Ok(IdentityInfo {
84        domain: domain.to_string(),
85        public_key,
86        newly_created,
87    })
88}
89
90pub fn get_identity_with_site(domain: &str, site: &SiteDir) -> anyhow::Result<IdentityInfo> {
91    if !site.public_key_path().exists() {
92        anyhow::bail!("No identity found at {}", site.root.display());
93    }
94
95    // Read PEM-encoded public key
96    let pem_content = fs::read_to_string(site.public_key_path())?;
97    let verifying_key = VerifyingKey::from_public_key_pem(&pem_content)
98        .map_err(|e| anyhow::anyhow!("Invalid public key PEM: {}", e))?;
99
100    let public_key = format_key(KeyAlgorithm::Ed25519, &verifying_key.to_bytes());
101
102    Ok(IdentityInfo {
103        domain: domain.to_string(),
104        public_key,
105        newly_created: false,
106    })
107}
108
109pub fn sign_json_with_site<T: Serialize>(
110    site: &SiteDir,
111    value: &T,
112) -> Result<String, JsonSignError> {
113    let signing_key = load_signing_key_with_site(site)?;
114    compute_signature(value, SignatureAlgorithm::Ed25519, &signing_key.to_bytes())
115        .map_err(|e| JsonSignError::Jcs(e.to_string()))
116}
117
118/// Sign data using the site's private key.
119///
120/// Dispatches to the correct signing algorithm based on `SIGN_ALGORITHM`.
121/// Returns signature in format `{algorithm}.{base58}`.
122pub fn sign_data_with_site(site: &SiteDir, data: &[u8]) -> anyhow::Result<String> {
123    let signing_key = load_signing_key_with_site(site)?;
124    sign_ed25519(&signing_key, data)
125}
126
127fn load_signing_key_with_site(site: &SiteDir) -> anyhow::Result<SigningKey> {
128    let private_key_path = site.private_key_path();
129
130    if !private_key_path.exists() {
131        anyhow::bail!("No private key found at {}", private_key_path.display());
132    }
133
134    #[cfg(unix)]
135    {
136        let metadata = fs::metadata(&private_key_path)?;
137        let mode = metadata.permissions().mode() & 0o777;
138        if mode != 0o600 {
139            anyhow::bail!(
140                "Private key has insecure permissions {:04o} (expected 0600).\n\
141                 Fix with: chmod 600 {}",
142                mode,
143                private_key_path.display()
144            );
145        }
146    }
147
148    let pem_content = fs::read_to_string(private_key_path)?;
149    SigningKey::from_pkcs8_pem(&pem_content)
150        .map_err(|e| anyhow::anyhow!("Invalid private key PEM: {}", e))
151}
152
153fn sign_ed25519(signing_key: &SigningKey, data: &[u8]) -> anyhow::Result<String> {
154    let signature = signing_key.sign(data);
155    Ok(format_signature(
156        SignatureAlgorithm::Ed25519,
157        &signature.to_bytes(),
158    ))
159}