solid_pod_rs_git/identity.rs
1//! Pod-git-root agent identity writer (ADR-124 §5.3 / Defect-3 NEW).
2//!
3//! Melvin Carvalho's `create-agent` design treats each per-user pod as a
4//! **full git repository** whose ROOT carries two identity artefacts:
5//!
6//! 1. `agent.did.json` — the canonical `did:nostr` DID document
7//! (ADR-125 / §1: two-context, top-level `DIDNostr`, single `Multikey`
8//! verification method with `publicKeyMultibase: "fe70102<hex>"`,
9//! `#key1`, `service: []`).
10//! 2. `git config nostr.privkey <hex>` — the agent's BIP-340 secret key,
11//! stored in the pod-repo's *local* git config (never committed).
12//!
13//! This module is the solid-pod-rs analogue of create-agent's identity
14//! bootstrap. It is **net-new** (Defect-3): the pre-pivot bootstrap wrote a
15//! 2019-shape `did-nostr.json` and an `identity.env`; neither
16//! `agent.did.json` nor `git config nostr.privkey` existed. The canonical
17//! DID doc is rendered by [`solid_pod_rs::did_nostr_types::render_did_document`],
18//! so the on-disk doc is byte-identical to every other emitter in the
19//! ecosystem.
20//!
21//! ## Invariants
22//!
23//! - **I1** — the `did:nostr:<hex>` identity string is unchanged; the
24//! pubkey is the canonical x-only hex.
25//! - **I2** — `publicKeyMultibase == "fe70102" + <x-only-hex>`, produced by
26//! the canonical renderer; no key bytes change.
27//! - **I3** — this writer is *provisioning only*. It never participates in
28//! the NIP-98 auth path; the privkey it git-configs is the signing key, not
29//! an authentication oracle.
30
31use std::path::Path;
32
33use solid_pod_rs::did_nostr_types::{render_did_document, NostrPubkey};
34
35use crate::config::{find_git_dir, run_git_config};
36use crate::error::GitError;
37
38/// Filename of the canonical DID document at the pod-git root.
39pub const AGENT_DID_FILE: &str = "agent.did.json";
40
41/// The git-config key under which the agent's BIP-340 secret key (hex) is
42/// stored in the pod-repo's local config (create-agent parity).
43pub const NOSTR_PRIVKEY_KEY: &str = "nostr.privkey";
44
45/// Result of writing the pod-git-root identity artefacts.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AgentIdentityWritten {
48 /// Absolute path of the `agent.did.json` written.
49 pub did_path: std::path::PathBuf,
50 /// The `did:nostr:<hex>` identity string (I1 — unchanged).
51 pub did: String,
52 /// `true` if `git config nostr.privkey` succeeded; `false` if it was
53 /// skipped (no privkey supplied) or the git binary was unavailable
54 /// (best-effort, mirroring [`crate::config::apply_write_config`]).
55 pub privkey_configured: bool,
56}
57
58/// Write the canonical `agent.did.json` to `pod_root` and, when a secret
59/// key is supplied, run `git config --local nostr.privkey <privkey_hex>`
60/// in the pod repo.
61///
62/// `pubkey_hex` is the 32-byte BIP-340 x-only public key in lowercase hex
63/// (the canonical identity, I1). `privkey_hex`, when `Some`, is the matching
64/// 32-byte secret key in hex; it is stored ONLY in the repo-local git config
65/// (never written to `agent.did.json`, never committed).
66///
67/// The DID document is rendered by the canonical
68/// [`render_did_document`] — identical bytes to every other ecosystem
69/// emitter. `agent.did.json` is written to the repo root so it is a
70/// first-class, committable file (the deploy ritual: edit → validate →
71/// commit → git-mark → push).
72///
73/// # Errors
74///
75/// - [`GitError::PathTraversal`] if `pubkey_hex` is not a valid 32-byte
76/// x-only hex pubkey (cannot render the canonical doc — refuse rather than
77/// emit a keyless / malformed document, mirroring the interop D-1 rule).
78/// - [`GitError::Io`] if the doc cannot be written to disk.
79///
80/// The `git config nostr.privkey` step is **best-effort**: a missing git
81/// binary or a non-repo `pod_root` is logged and reported as
82/// `privkey_configured: false`, never an error — identity provisioning must
83/// not be blocked by a transient git-config failure (the privkey can be
84/// re-set on the next provisioning pass).
85pub async fn write_agent_identity(
86 pod_root: &Path,
87 pubkey_hex: &str,
88 privkey_hex: Option<&str>,
89) -> Result<AgentIdentityWritten, GitError> {
90 // I2/I1: render the canonical DID doc from the parsed x-only pubkey.
91 // Refuse malformed input rather than emit a non-conformant document.
92 let pk = NostrPubkey::from_hex(pubkey_hex)
93 .map_err(|e| GitError::PathTraversal(format!("agent.did.json: invalid pubkey: {e}")))?;
94 let doc = render_did_document(&pk);
95 let did = doc["id"].as_str().unwrap_or_default().to_string();
96
97 let body = serde_json::to_string_pretty(&doc)
98 .map_err(|e| GitError::MalformedCgi(format!("agent.did.json serialise: {e}")))?;
99
100 let did_path = pod_root.join(AGENT_DID_FILE);
101 tokio::fs::write(&did_path, body.as_bytes())
102 .await
103 .map_err(GitError::Io)?;
104
105 // git config nostr.privkey <hex> — best-effort, repo-local.
106 let mut privkey_configured = false;
107 if let Some(sk_hex) = privkey_hex {
108 match find_git_dir(pod_root) {
109 Ok(Some(git_dir)) => {
110 match run_git_config(pod_root, &git_dir.git_dir, NOSTR_PRIVKEY_KEY, sk_hex).await {
111 Ok(()) => privkey_configured = true,
112 Err(e) => tracing::debug!(
113 target: "solid_pod_rs_git::identity",
114 "git config {NOSTR_PRIVKEY_KEY} skipped (non-fatal): {e}"
115 ),
116 }
117 }
118 _ => tracing::debug!(
119 target: "solid_pod_rs_git::identity",
120 "pod_root {} is not a git repo; nostr.privkey not configured",
121 pod_root.display()
122 ),
123 }
124 }
125
126 Ok(AgentIdentityWritten {
127 did_path,
128 did,
129 privkey_configured,
130 })
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use tempfile::TempDir;
137 use tokio::process::Command;
138
139 const PK_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
140
141 #[tokio::test]
142 async fn writes_canonical_agent_did_json() {
143 let td = TempDir::new().unwrap();
144 let out = write_agent_identity(td.path(), PK_HEX, None)
145 .await
146 .unwrap();
147
148 // I1: did string unchanged.
149 assert_eq!(out.did, format!("did:nostr:{PK_HEX}"));
150 assert!(!out.privkey_configured, "no privkey supplied");
151
152 // On-disk doc is the canonical §1 shape.
153 let written = std::fs::read_to_string(out.did_path).unwrap();
154 let v: serde_json::Value = serde_json::from_str(&written).unwrap();
155 assert_eq!(v["type"], "DIDNostr");
156 assert_eq!(v["@context"][0], "https://www.w3.org/ns/cid/v1");
157 assert_eq!(v["@context"][1], "https://w3id.org/nostr/context");
158 let vm = &v["verificationMethod"][0];
159 assert_eq!(vm["type"], "Multikey");
160 // I2: publicKeyMultibase == fe70102 + same x-only hex.
161 assert_eq!(vm["publicKeyMultibase"], format!("fe70102{PK_HEX}"));
162 assert!(vm.get("publicKeyHex").is_none(), "2019 publicKeyHex dropped");
163 assert_eq!(v["authentication"][0], "#key1");
164 assert!(v["service"].as_array().unwrap().is_empty());
165 }
166
167 #[tokio::test]
168 async fn rejects_malformed_pubkey() {
169 let td = TempDir::new().unwrap();
170 let res = write_agent_identity(td.path(), "not-hex", None).await;
171 assert!(res.is_err(), "malformed pubkey must be refused, not written");
172 // No file leaked.
173 assert!(!td.path().join(AGENT_DID_FILE).exists());
174 }
175
176 /// Only runs when the git binary is available; git-configs the privkey
177 /// into a real pod repo and reads it back.
178 #[tokio::test]
179 async fn configures_nostr_privkey_in_repo() {
180 let td = TempDir::new().unwrap();
181 let repo = td.path();
182 let status = Command::new("git")
183 .arg("init")
184 .arg(repo)
185 .output()
186 .await;
187 let status = match status {
188 Ok(o) => o.status,
189 Err(_) => return, // no git binary — skip.
190 };
191 assert!(status.success());
192
193 let sk_hex = "1111111111111111111111111111111111111111111111111111111111111111";
194 let out = write_agent_identity(repo, PK_HEX, Some(sk_hex))
195 .await
196 .unwrap();
197 assert!(out.privkey_configured, "privkey must be git-configured in a repo");
198
199 // Read it back — and confirm it is NOT in the committed doc.
200 let gd = find_git_dir(repo).unwrap().unwrap();
201 let read = Command::new("git")
202 .arg("config")
203 .arg("--local")
204 .arg(NOSTR_PRIVKEY_KEY)
205 .current_dir(repo)
206 .env("GIT_DIR", &gd.git_dir)
207 .output()
208 .await
209 .unwrap();
210 assert!(read.status.success());
211 assert_eq!(String::from_utf8_lossy(&read.stdout).trim(), sk_hex);
212
213 let doc = std::fs::read_to_string(out.did_path).unwrap();
214 assert!(
215 !doc.contains(sk_hex),
216 "privkey must NEVER appear in agent.did.json"
217 );
218 }
219}