solid_pod_rs_git/
identity.rs1use std::path::Path;
40
41use solid_pod_rs::did_nostr_types::{
42 render_did_document, render_did_document_published, NostrPubkey,
43};
44use zeroize::Zeroize;
45
46use crate::config::{find_git_dir, run_git_config};
47use crate::error::GitError;
48
49pub const AGENT_DID_FILE: &str = "agent.did.json";
51
52pub const NOSTR_PRIVKEY_KEY: &str = "nostr.privkey";
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct AgentIdentityWritten {
59 pub did_path: std::path::PathBuf,
61 pub did: String,
63 pub privkey_configured: bool,
67}
68
69pub async fn write_agent_identity(
104 pod_root: &Path,
105 pubkey_hex: &str,
106 privkey_hex: Option<&str>,
107) -> Result<AgentIdentityWritten, GitError> {
108 let pk = NostrPubkey::from_hex(pubkey_hex)
111 .map_err(|e| GitError::PathTraversal(format!("agent.did.json: invalid pubkey: {e}")))?;
112 let doc = match privkey_hex {
113 Some(sk_hex) => {
115 let full = controller_public_key(sk_hex)?;
116 if NostrPubkey::from_public_key(&full) != pk {
117 return Err(GitError::PathTraversal(
118 "agent.did.json: privkey does not match pubkey".into(),
119 ));
120 }
121 render_did_document_published(&full)
122 }
123 None => render_did_document(&pk),
125 };
126 let did = doc["id"].as_str().unwrap_or_default().to_string();
127
128 let body = serde_json::to_string_pretty(&doc)
129 .map_err(|e| GitError::MalformedCgi(format!("agent.did.json serialise: {e}")))?;
130
131 let did_path = pod_root.join(AGENT_DID_FILE);
132 tokio::fs::write(&did_path, body.as_bytes())
133 .await
134 .map_err(GitError::Io)?;
135
136 let mut privkey_configured = false;
138 if let Some(sk_hex) = privkey_hex {
139 match find_git_dir(pod_root) {
140 Ok(Some(git_dir)) => {
141 match run_git_config(pod_root, &git_dir.git_dir, NOSTR_PRIVKEY_KEY, sk_hex).await {
142 Ok(()) => privkey_configured = true,
143 Err(e) => tracing::debug!(
144 target: "solid_pod_rs_git::identity",
145 "git config {NOSTR_PRIVKEY_KEY} skipped (non-fatal): {e}"
146 ),
147 }
148 }
149 _ => tracing::debug!(
150 target: "solid_pod_rs_git::identity",
151 "pod_root {} is not a git repo; nostr.privkey not configured",
152 pod_root.display()
153 ),
154 }
155 }
156
157 Ok(AgentIdentityWritten {
158 did_path,
159 did,
160 privkey_configured,
161 })
162}
163
164fn controller_public_key(sk_hex: &str) -> Result<k256::PublicKey, GitError> {
167 let invalid = || GitError::PathTraversal("agent.did.json: invalid privkey".into());
168 let mut bytes = [0u8; 32];
169 let decoded = hex::decode_to_slice(sk_hex, &mut bytes);
170 let sk = decoded
171 .ok()
172 .and_then(|()| k256::SecretKey::from_slice(&bytes).ok());
173 bytes.zeroize();
174 Ok(sk.ok_or_else(invalid)?.public_key())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use tempfile::TempDir;
181 use tokio::process::Command;
182
183 const PK_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
184
185 fn x_only_hex(pk: &k256::PublicKey) -> String {
186 NostrPubkey::from_public_key(pk).to_hex()
187 }
188
189 fn secret_hex_with_parity(tag: u8) -> String {
191 use k256::elliptic_curve::sec1::ToSec1Point;
192 (1u8..=255)
193 .map(|b| hex::encode([b; 32]))
194 .find(|h| {
195 let pk = controller_public_key(h).unwrap();
196 pk.to_sec1_point(true).as_bytes()[0] == tag
197 })
198 .unwrap()
199 }
200
201 fn written_multibase(path: &std::path::Path) -> String {
202 let v: serde_json::Value =
203 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
204 v["verificationMethod"][0]["publicKeyMultibase"]
205 .as_str()
206 .unwrap()
207 .to_string()
208 }
209
210 #[tokio::test]
211 async fn odd_y_controller_key_publishes_0x03() {
212 let td = TempDir::new().unwrap();
215 let sk_hex = secret_hex_with_parity(0x03);
216 let pk_hex = x_only_hex(&controller_public_key(&sk_hex).unwrap());
217 let out = write_agent_identity(td.path(), &pk_hex, Some(&sk_hex))
218 .await
219 .unwrap();
220 assert_eq!(out.did, format!("did:nostr:{pk_hex}"));
221 assert_eq!(written_multibase(&out.did_path), format!("fe70103{pk_hex}"));
222 }
223
224 #[tokio::test]
225 async fn even_y_controller_key_publishes_0x02() {
226 let td = TempDir::new().unwrap();
227 let sk_hex = secret_hex_with_parity(0x02);
228 let pk_hex = x_only_hex(&controller_public_key(&sk_hex).unwrap());
229 let out = write_agent_identity(td.path(), &pk_hex, Some(&sk_hex))
230 .await
231 .unwrap();
232 assert_eq!(written_multibase(&out.did_path), format!("fe70102{pk_hex}"));
233 }
234
235 #[tokio::test]
236 async fn rejects_privkey_that_does_not_match_pubkey() {
237 let td = TempDir::new().unwrap();
238 let sk_hex = secret_hex_with_parity(0x02);
239 let res = write_agent_identity(td.path(), PK_HEX, Some(&sk_hex)).await;
240 assert!(res.is_err(), "mismatched key pair must be refused");
241 assert!(!td.path().join(AGENT_DID_FILE).exists());
242 }
243
244 #[tokio::test]
245 async fn rejects_malformed_privkey() {
246 let td = TempDir::new().unwrap();
247 for bad in ["zz", &"0".repeat(64), &"f".repeat(64)] {
248 let res = write_agent_identity(td.path(), PK_HEX, Some(bad)).await;
249 assert!(res.is_err(), "privkey {bad:?} must be refused");
250 }
251 assert!(!td.path().join(AGENT_DID_FILE).exists());
252 }
253
254 #[tokio::test]
255 async fn writes_canonical_agent_did_json() {
256 let td = TempDir::new().unwrap();
257 let out = write_agent_identity(td.path(), PK_HEX, None).await.unwrap();
258
259 assert_eq!(out.did, format!("did:nostr:{PK_HEX}"));
261 assert!(!out.privkey_configured, "no privkey supplied");
262
263 let written = std::fs::read_to_string(out.did_path).unwrap();
265 let v: serde_json::Value = serde_json::from_str(&written).unwrap();
266 assert_eq!(v["type"], "DIDNostr");
267 assert_eq!(v["@context"][0], "https://www.w3.org/ns/did/v1");
268 assert_eq!(v["@context"][1], "https://www.w3.org/ns/cid/v1");
269 assert_eq!(v["@context"][2], "https://w3id.org/nostr/context");
270 let vm = &v["verificationMethod"][0];
271 assert_eq!(vm["type"], "Multikey");
272 assert_eq!(vm["publicKeyMultibase"], format!("fe70102{PK_HEX}"));
274 assert!(
275 vm.get("publicKeyHex").is_none(),
276 "2019 publicKeyHex dropped"
277 );
278 assert_eq!(v["authentication"][0], "#key1");
279 assert!(v.get("service").is_none());
281 }
282
283 #[tokio::test]
284 async fn rejects_malformed_pubkey() {
285 let td = TempDir::new().unwrap();
286 let res = write_agent_identity(td.path(), "not-hex", None).await;
287 assert!(
288 res.is_err(),
289 "malformed pubkey must be refused, not written"
290 );
291 assert!(!td.path().join(AGENT_DID_FILE).exists());
293 }
294
295 #[tokio::test]
298 async fn configures_nostr_privkey_in_repo() {
299 let td = TempDir::new().unwrap();
300 let repo = td.path();
301 let status = Command::new("git").arg("init").arg(repo).output().await;
302 let status = match status {
303 Ok(o) => o.status,
304 Err(_) => return, };
306 assert!(status.success());
307
308 let sk_hex = "1111111111111111111111111111111111111111111111111111111111111111";
309 let pk_hex = x_only_hex(&controller_public_key(sk_hex).unwrap());
310 let out = write_agent_identity(repo, &pk_hex, Some(sk_hex))
311 .await
312 .unwrap();
313 assert!(
314 out.privkey_configured,
315 "privkey must be git-configured in a repo"
316 );
317
318 let gd = find_git_dir(repo).unwrap().unwrap();
320 let read = Command::new("git")
321 .arg("config")
322 .arg("--local")
323 .arg(NOSTR_PRIVKEY_KEY)
324 .current_dir(repo)
325 .env("GIT_DIR", &gd.git_dir)
326 .output()
327 .await
328 .unwrap();
329 assert!(read.status.success());
330 assert_eq!(String::from_utf8_lossy(&read.stdout).trim(), sk_hex);
331
332 let doc = std::fs::read_to_string(out.did_path).unwrap();
333 assert!(
334 !doc.contains(sk_hex),
335 "privkey must NEVER appear in agent.did.json"
336 );
337 }
338}