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: three-context, top-level `DIDNostr`, single `Multikey`
8//!    verification method with `publicKeyMultibase: "fe7010{2,3}<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.
18//!
19//! `agent.did.json` is a document the **controller publishes**, so when the
20//! secret key is supplied it is rendered from the full key by
21//! [`solid_pod_rs::did_nostr_types::render_did_document_published`]: the
22//! parity byte follows the key's actual y (`0x02` even, `0x03` odd), as the
23//! did:nostr parity model permits for controller-published documents
24//! (nostrcg/did-nostr#145, closing #144). With only the public identifier it
25//! falls back to [`solid_pod_rs::did_nostr_types::render_did_document`], the
26//! `0x02` form every resolver computes from the identifier alone.
27//!
28//! ## Invariants
29//!
30//! - **I1** — the `did:nostr:<hex>` identity string is unchanged; the
31//!   pubkey is the canonical x-only hex.
32//! - **I2** — `publicKeyMultibase == "fe7010{2,3}" + <x-only-hex>`, produced
33//!   by the canonical renderers; the body is always the x-only identifier, and
34//!   the parity byte is `03` only when the supplied secret's point has odd y.
35//! - **I3** — this writer is *provisioning only*. It never participates in
36//!   the NIP-98 auth path; the privkey it git-configs is the signing key, not
37//!   an authentication oracle.
38
39use 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
49/// Filename of the canonical DID document at the pod-git root.
50pub const AGENT_DID_FILE: &str = "agent.did.json";
51
52/// The git-config key under which the agent's BIP-340 secret key (hex) is
53/// stored in the pod-repo's local config (create-agent parity).
54pub const NOSTR_PRIVKEY_KEY: &str = "nostr.privkey";
55
56/// Result of writing the pod-git-root identity artefacts.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct AgentIdentityWritten {
59    /// Absolute path of the `agent.did.json` written.
60    pub did_path: std::path::PathBuf,
61    /// The `did:nostr:<hex>` identity string (I1 — unchanged).
62    pub did: String,
63    /// `true` if `git config nostr.privkey` succeeded; `false` if it was
64    /// skipped (no privkey supplied) or the git binary was unavailable
65    /// (best-effort, mirroring [`crate::config::apply_write_config`]).
66    pub privkey_configured: bool,
67}
68
69/// Write the canonical `agent.did.json` to `pod_root` and, when a secret
70/// key is supplied, run `git config --local nostr.privkey <privkey_hex>`
71/// in the pod repo.
72///
73/// `pubkey_hex` is the 32-byte BIP-340 x-only public key in lowercase hex
74/// (the canonical identity, I1). `privkey_hex`, when `Some`, is the matching
75/// 32-byte secret key in hex; it is stored ONLY in the repo-local git config
76/// (never written to `agent.did.json`, never committed).
77///
78/// With a secret key the controller holds its full key, so the document is
79/// rendered by [`render_did_document_published`] and `publicKeyMultibase`
80/// carries the key's actual parity (`fe70103…` for odd y). Without one it is
81/// rendered by [`render_did_document`] (`fe70102…`, the identifier-only
82/// form). Either way the identifier is the x-only `pubkey_hex`.
83/// `agent.did.json` is written to the repo root so it is a first-class,
84/// committable file (the deploy ritual: edit → validate → commit → git-mark
85/// → push).
86///
87/// # Errors
88///
89/// - [`GitError::PathTraversal`] if `pubkey_hex` is not a valid 32-byte
90///   x-only hex pubkey (cannot render the canonical doc — refuse rather than
91///   emit a keyless / malformed document, mirroring the interop D-1 rule).
92/// - [`GitError::PathTraversal`] if `privkey_hex` is supplied but is not a
93///   valid 32-byte secp256k1 secret, or its public key's x-coordinate is not
94///   `pubkey_hex` — publishing a document for a key the configured signing
95///   secret does not control is refused. Nothing is written in either case.
96/// - [`GitError::Io`] if the doc cannot be written to disk.
97///
98/// The `git config nostr.privkey` step is **best-effort**: a missing git
99/// binary or a non-repo `pod_root` is logged and reported as
100/// `privkey_configured: false`, never an error — identity provisioning must
101/// not be blocked by a transient git-config failure (the privkey can be
102/// re-set on the next provisioning pass).
103pub async fn write_agent_identity(
104    pod_root: &Path,
105    pubkey_hex: &str,
106    privkey_hex: Option<&str>,
107) -> Result<AgentIdentityWritten, GitError> {
108    // I2/I1: render the canonical DID doc from the parsed x-only pubkey.
109    // Refuse malformed input rather than emit a non-conformant document.
110    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        // Controller-published with the full key: parity follows actual y.
114        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        // Identifier only: the 0x02 form.
124        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    // git config nostr.privkey <hex> — best-effort, repo-local.
137    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
164/// Full public key of a hex-encoded 32-byte secp256k1 secret. The decoded
165/// secret bytes are zeroised before return.
166fn 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    /// First secret `[b; 32]` whose public key has the given SEC1 tag.
190    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        // nostrcg/did-nostr#145: a controller-published document MAY carry
213        // 0x03 when the controller holds the full key and its y is odd.
214        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        // I1: did string unchanged.
260        assert_eq!(out.did, format!("did:nostr:{PK_HEX}"));
261        assert!(!out.privkey_configured, "no privkey supplied");
262
263        // On-disk doc is the canonical §1 shape.
264        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        // I2: publicKeyMultibase == fe70102 + same x-only hex.
273        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        // Canonical did:nostr omits `service` when empty (no `service: []`).
280        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        // No file leaked.
292        assert!(!td.path().join(AGENT_DID_FILE).exists());
293    }
294
295    /// Only runs when the git binary is available; git-configs the privkey
296    /// into a real pod repo and reads it back.
297    #[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, // no git binary — skip.
305        };
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        // Read it back — and confirm it is NOT in the committed doc.
319        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}