Skip to main content

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