use std::path::Path;
use solid_pod_rs::did_nostr_types::{
render_did_document, render_did_document_published, NostrPubkey,
};
use zeroize::Zeroize;
use crate::config::{find_git_dir, run_git_config};
use crate::error::GitError;
pub const AGENT_DID_FILE: &str = "agent.did.json";
pub const NOSTR_PRIVKEY_KEY: &str = "nostr.privkey";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentityWritten {
pub did_path: std::path::PathBuf,
pub did: String,
pub privkey_configured: bool,
}
pub async fn write_agent_identity(
pod_root: &Path,
pubkey_hex: &str,
privkey_hex: Option<&str>,
) -> Result<AgentIdentityWritten, GitError> {
let pk = NostrPubkey::from_hex(pubkey_hex)
.map_err(|e| GitError::PathTraversal(format!("agent.did.json: invalid pubkey: {e}")))?;
let doc = match privkey_hex {
Some(sk_hex) => {
let full = controller_public_key(sk_hex)?;
if NostrPubkey::from_public_key(&full) != pk {
return Err(GitError::PathTraversal(
"agent.did.json: privkey does not match pubkey".into(),
));
}
render_did_document_published(&full)
}
None => render_did_document(&pk),
};
let did = doc["id"].as_str().unwrap_or_default().to_string();
let body = serde_json::to_string_pretty(&doc)
.map_err(|e| GitError::MalformedCgi(format!("agent.did.json serialise: {e}")))?;
let did_path = pod_root.join(AGENT_DID_FILE);
tokio::fs::write(&did_path, body.as_bytes())
.await
.map_err(GitError::Io)?;
let mut privkey_configured = false;
if let Some(sk_hex) = privkey_hex {
match find_git_dir(pod_root) {
Ok(Some(git_dir)) => {
match run_git_config(pod_root, &git_dir.git_dir, NOSTR_PRIVKEY_KEY, sk_hex).await {
Ok(()) => privkey_configured = true,
Err(e) => tracing::debug!(
target: "solid_pod_rs_git::identity",
"git config {NOSTR_PRIVKEY_KEY} skipped (non-fatal): {e}"
),
}
}
_ => tracing::debug!(
target: "solid_pod_rs_git::identity",
"pod_root {} is not a git repo; nostr.privkey not configured",
pod_root.display()
),
}
}
Ok(AgentIdentityWritten {
did_path,
did,
privkey_configured,
})
}
fn controller_public_key(sk_hex: &str) -> Result<k256::PublicKey, GitError> {
let invalid = || GitError::PathTraversal("agent.did.json: invalid privkey".into());
let mut bytes = [0u8; 32];
let decoded = hex::decode_to_slice(sk_hex, &mut bytes);
let sk = decoded
.ok()
.and_then(|()| k256::SecretKey::from_slice(&bytes).ok());
bytes.zeroize();
Ok(sk.ok_or_else(invalid)?.public_key())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::process::Command;
const PK_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
fn x_only_hex(pk: &k256::PublicKey) -> String {
NostrPubkey::from_public_key(pk).to_hex()
}
fn secret_hex_with_parity(tag: u8) -> String {
use k256::elliptic_curve::sec1::ToSec1Point;
(1u8..=255)
.map(|b| hex::encode([b; 32]))
.find(|h| {
let pk = controller_public_key(h).unwrap();
pk.to_sec1_point(true).as_bytes()[0] == tag
})
.unwrap()
}
fn written_multibase(path: &std::path::Path) -> String {
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
v["verificationMethod"][0]["publicKeyMultibase"]
.as_str()
.unwrap()
.to_string()
}
#[tokio::test]
async fn odd_y_controller_key_publishes_0x03() {
let td = TempDir::new().unwrap();
let sk_hex = secret_hex_with_parity(0x03);
let pk_hex = x_only_hex(&controller_public_key(&sk_hex).unwrap());
let out = write_agent_identity(td.path(), &pk_hex, Some(&sk_hex))
.await
.unwrap();
assert_eq!(out.did, format!("did:nostr:{pk_hex}"));
assert_eq!(written_multibase(&out.did_path), format!("fe70103{pk_hex}"));
}
#[tokio::test]
async fn even_y_controller_key_publishes_0x02() {
let td = TempDir::new().unwrap();
let sk_hex = secret_hex_with_parity(0x02);
let pk_hex = x_only_hex(&controller_public_key(&sk_hex).unwrap());
let out = write_agent_identity(td.path(), &pk_hex, Some(&sk_hex))
.await
.unwrap();
assert_eq!(written_multibase(&out.did_path), format!("fe70102{pk_hex}"));
}
#[tokio::test]
async fn rejects_privkey_that_does_not_match_pubkey() {
let td = TempDir::new().unwrap();
let sk_hex = secret_hex_with_parity(0x02);
let res = write_agent_identity(td.path(), PK_HEX, Some(&sk_hex)).await;
assert!(res.is_err(), "mismatched key pair must be refused");
assert!(!td.path().join(AGENT_DID_FILE).exists());
}
#[tokio::test]
async fn rejects_malformed_privkey() {
let td = TempDir::new().unwrap();
for bad in ["zz", &"0".repeat(64), &"f".repeat(64)] {
let res = write_agent_identity(td.path(), PK_HEX, Some(bad)).await;
assert!(res.is_err(), "privkey {bad:?} must be refused");
}
assert!(!td.path().join(AGENT_DID_FILE).exists());
}
#[tokio::test]
async fn writes_canonical_agent_did_json() {
let td = TempDir::new().unwrap();
let out = write_agent_identity(td.path(), PK_HEX, None).await.unwrap();
assert_eq!(out.did, format!("did:nostr:{PK_HEX}"));
assert!(!out.privkey_configured, "no privkey supplied");
let written = std::fs::read_to_string(out.did_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(v["type"], "DIDNostr");
assert_eq!(v["@context"][0], "https://www.w3.org/ns/did/v1");
assert_eq!(v["@context"][1], "https://www.w3.org/ns/cid/v1");
assert_eq!(v["@context"][2], "https://w3id.org/nostr/context");
let vm = &v["verificationMethod"][0];
assert_eq!(vm["type"], "Multikey");
assert_eq!(vm["publicKeyMultibase"], format!("fe70102{PK_HEX}"));
assert!(
vm.get("publicKeyHex").is_none(),
"2019 publicKeyHex dropped"
);
assert_eq!(v["authentication"][0], "#key1");
assert!(v.get("service").is_none());
}
#[tokio::test]
async fn rejects_malformed_pubkey() {
let td = TempDir::new().unwrap();
let res = write_agent_identity(td.path(), "not-hex", None).await;
assert!(
res.is_err(),
"malformed pubkey must be refused, not written"
);
assert!(!td.path().join(AGENT_DID_FILE).exists());
}
#[tokio::test]
async fn configures_nostr_privkey_in_repo() {
let td = TempDir::new().unwrap();
let repo = td.path();
let status = Command::new("git").arg("init").arg(repo).output().await;
let status = match status {
Ok(o) => o.status,
Err(_) => return, };
assert!(status.success());
let sk_hex = "1111111111111111111111111111111111111111111111111111111111111111";
let pk_hex = x_only_hex(&controller_public_key(sk_hex).unwrap());
let out = write_agent_identity(repo, &pk_hex, Some(sk_hex))
.await
.unwrap();
assert!(
out.privkey_configured,
"privkey must be git-configured in a repo"
);
let gd = find_git_dir(repo).unwrap().unwrap();
let read = Command::new("git")
.arg("config")
.arg("--local")
.arg(NOSTR_PRIVKEY_KEY)
.current_dir(repo)
.env("GIT_DIR", &gd.git_dir)
.output()
.await
.unwrap();
assert!(read.status.success());
assert_eq!(String::from_utf8_lossy(&read.stdout).trim(), sk_hex);
let doc = std::fs::read_to_string(out.did_path).unwrap();
assert!(
!doc.contains(sk_hex),
"privkey must NEVER appear in agent.did.json"
);
}
}