Skip to main content

provider_agent/
identity.rs

1//! Ed25519 identity keypair: generate-on-first-run, persist with mode 0600,
2//! idempotent reload. See `plan/V2_AGENT_SPEC.md` §4.1.
3
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use base64::Engine as _;
8use base64::engine::general_purpose::STANDARD as B64;
9use chrono::{DateTime, Utc};
10use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
11use rand_core::OsRng;
12
13const FORMAT_VERSION: u32 = 1;
14const HEADER: &str = "-----BEGIN USEPOD AGENT KEY-----";
15const FOOTER: &str = "-----END USEPOD AGENT KEY-----";
16
17#[derive(Debug)]
18pub struct Identity {
19    pub path: PathBuf,
20    pub created: DateTime<Utc>,
21    pub provider_id: Option<String>,
22    signing_key: SigningKey,
23    verifying_key: VerifyingKey,
24}
25
26impl Identity {
27    pub fn public_key_b64(&self) -> String {
28        B64.encode(self.verifying_key.as_bytes())
29    }
30
31    pub fn sign_b64(&self, message: &[u8]) -> String {
32        let sig: Signature = self.signing_key.sign(message);
33        B64.encode(sig.to_bytes())
34    }
35
36    /// Persist `provider_id` to the identity file after a successful enrollment.
37    pub fn set_provider_id(&mut self, provider_id: String) -> Result<()> {
38        self.provider_id = Some(provider_id);
39        let body = serialize(
40            self.created,
41            self.signing_key.to_bytes(),
42            self.verifying_key.to_bytes(),
43            self.provider_id.as_deref(),
44        );
45        write_with_mode_0600(&self.path, &body)
46    }
47}
48
49/// Load the identity from disk, generating + persisting a new one on first run.
50pub fn load_or_create(path: &Path) -> Result<Identity> {
51    if path.exists() {
52        return load(path);
53    }
54    create_new(path)
55}
56
57fn create_new(path: &Path) -> Result<Identity> {
58    if let Some(parent) = path.parent() {
59        std::fs::create_dir_all(parent)
60            .with_context(|| format!("creating identity dir {}", parent.display()))?;
61    }
62    let signing_key = SigningKey::generate(&mut OsRng);
63    let verifying_key = signing_key.verifying_key();
64    let created = Utc::now();
65    let body = serialize(
66        created,
67        signing_key.to_bytes(),
68        verifying_key.to_bytes(),
69        None,
70    );
71    write_with_mode_0600(path, &body)?;
72    tracing::info!(path = %path.display(), "generated new agent identity");
73    Ok(Identity {
74        path: path.to_path_buf(),
75        created,
76        provider_id: None,
77        signing_key,
78        verifying_key,
79    })
80}
81
82fn load(path: &Path) -> Result<Identity> {
83    let raw = std::fs::read_to_string(path)
84        .with_context(|| format!("reading identity {}", path.display()))?;
85    let parsed = parse(&raw).with_context(|| format!("parsing identity {}", path.display()))?;
86
87    let secret_bytes = B64
88        .decode(parsed.secret_b64.as_bytes())
89        .context("decoding base64 secret")?;
90    let secret_arr: [u8; 32] = secret_bytes
91        .as_slice()
92        .try_into()
93        .map_err(|_| anyhow!("secret key must decode to 32 bytes"))?;
94    let signing_key = SigningKey::from_bytes(&secret_arr);
95    let verifying_key = signing_key.verifying_key();
96
97    // Sanity-check the stored public key matches.
98    let recorded_pub = B64
99        .decode(parsed.public_b64.as_bytes())
100        .context("decoding base64 public")?;
101    if recorded_pub.as_slice() != verifying_key.as_bytes() {
102        bail!("identity file public key does not match secret key; refusing to load");
103    }
104
105    warn_if_world_readable(path);
106
107    Ok(Identity {
108        path: path.to_path_buf(),
109        created: parsed.created,
110        provider_id: parsed.provider_id,
111        signing_key,
112        verifying_key,
113    })
114}
115
116struct Parsed {
117    created: DateTime<Utc>,
118    secret_b64: String,
119    public_b64: String,
120    provider_id: Option<String>,
121}
122
123fn parse(raw: &str) -> Result<Parsed> {
124    let lines: Vec<&str> = raw.lines().collect();
125    let start = lines
126        .iter()
127        .position(|l| l.trim() == HEADER)
128        .ok_or_else(|| anyhow!("missing header {HEADER}"))?;
129    let end = lines
130        .iter()
131        .position(|l| l.trim() == FOOTER)
132        .ok_or_else(|| anyhow!("missing footer {FOOTER}"))?;
133    if end <= start {
134        bail!("malformed identity file: footer before header");
135    }
136
137    let mut version: Option<u32> = None;
138    let mut created: Option<DateTime<Utc>> = None;
139    let mut secret: Option<String> = None;
140    let mut public: Option<String> = None;
141    let mut provider_id: Option<String> = None;
142
143    for line in &lines[start + 1..end] {
144        let trimmed = line.trim();
145        if trimmed.is_empty() {
146            continue;
147        }
148        let (k, v) = trimmed
149            .split_once(':')
150            .ok_or_else(|| anyhow!("malformed line: {trimmed}"))?;
151        let v = v.trim().to_string();
152        match k.trim() {
153            "version" => version = Some(v.parse()?),
154            "created" => created = Some(v.parse::<DateTime<Utc>>()?),
155            "secret" => secret = Some(v),
156            "public" => public = Some(v),
157            "provider_id" => provider_id = Some(v),
158            _ => {} // forward-compat
159        }
160    }
161
162    let version = version.ok_or_else(|| anyhow!("missing `version`"))?;
163    if version != FORMAT_VERSION {
164        bail!("unsupported identity format version {version}");
165    }
166    Ok(Parsed {
167        created: created.ok_or_else(|| anyhow!("missing `created`"))?,
168        secret_b64: secret.ok_or_else(|| anyhow!("missing `secret`"))?,
169        public_b64: public.ok_or_else(|| anyhow!("missing `public`"))?,
170        provider_id,
171    })
172}
173
174fn serialize(
175    created: DateTime<Utc>,
176    secret: [u8; 32],
177    public: [u8; 32],
178    provider_id: Option<&str>,
179) -> String {
180    let mut out = String::new();
181    out.push_str(HEADER);
182    out.push('\n');
183    out.push_str(&format!("version: {FORMAT_VERSION}\n"));
184    out.push_str(&format!("created: {}\n", created.to_rfc3339()));
185    out.push_str(&format!("secret: {}\n", B64.encode(secret)));
186    out.push_str(&format!("public: {}\n", B64.encode(public)));
187    if let Some(pid) = provider_id {
188        out.push_str(&format!("provider_id: {pid}\n"));
189    }
190    out.push_str(FOOTER);
191    out.push('\n');
192    out
193}
194
195fn write_with_mode_0600(path: &Path, body: &str) -> Result<()> {
196    std::fs::write(path, body)
197        .with_context(|| format!("writing identity {}", path.display()))?;
198    set_owner_only_perms(path)?;
199    Ok(())
200}
201
202#[cfg(unix)]
203fn set_owner_only_perms(path: &Path) -> Result<()> {
204    use std::os::unix::fs::PermissionsExt;
205    let perms = std::fs::Permissions::from_mode(0o600);
206    std::fs::set_permissions(path, perms)
207        .with_context(|| format!("setting 0600 on {}", path.display()))?;
208    Ok(())
209}
210
211#[cfg(not(unix))]
212fn set_owner_only_perms(_path: &Path) -> Result<()> {
213    // On Windows the file inherits ACLs from the parent (typically the user's profile).
214    // No equivalent action needed here.
215    Ok(())
216}
217
218#[cfg(unix)]
219fn warn_if_world_readable(path: &Path) {
220    use std::os::unix::fs::PermissionsExt;
221    if let Ok(meta) = std::fs::metadata(path) {
222        let mode = meta.permissions().mode() & 0o777;
223        if mode & 0o077 != 0 {
224            tracing::warn!(
225                path = %path.display(),
226                mode = format!("{mode:o}"),
227                "identity file is accessible to other users; chmod 600 it"
228            );
229        }
230    }
231}
232
233#[cfg(not(unix))]
234fn warn_if_world_readable(_path: &Path) {}