provider_agent/
identity.rs1use 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 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
49pub 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 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 _ => {} }
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 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) {}