use bytes::Bytes;
use chrono::Utc;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use k256::schnorr::SigningKey;
use solid_pod_rs::{
storage::Storage,
wac::{AclAuthorization, AclDocument, IdOrIds, IdRef},
PodError,
};
pub const POD_PRIVKEY_PATH: &str = "/private/privkey.jsonld";
pub const POD_PRIVKEY_ACL_PATH: &str = "/private/privkey.jsonld.acl";
pub const POD_PROFILE_CARD_PATH: &str = "/profile/card";
pub const NOSTR_NS: &str = "https://nostr.org/ns#";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyProvisioningOutcome {
pub npub: String,
pub nsec: String,
pub pubkey_hex: String,
pub privkey_path: String,
pub webid: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KeyProvisioningPlan {
pub webid: String,
pub pod_base: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deterministic_entropy: Option<[u8; 32]>,
}
const BECH32_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const BECH32_CONST: u32 = 1;
fn convert_bits_8_to_5(data: &[u8]) -> Vec<u8> {
let mut acc: u32 = 0;
let mut bits: u32 = 0;
let mut ret = Vec::with_capacity(data.len() * 8 / 5 + 1);
for &v in data {
acc = (acc << 8) | (v as u32);
bits += 8;
while bits >= 5 {
bits -= 5;
ret.push(((acc >> bits) & 0x1f) as u8);
}
}
if bits > 0 {
ret.push(((acc << (5 - bits)) & 0x1f) as u8);
}
ret
}
fn hrp_expand(hrp: &str) -> Vec<u8> {
let mut r: Vec<u8> = hrp.bytes().map(|b| b >> 5).collect();
r.push(0);
r.extend(hrp.bytes().map(|b| b & 31));
r
}
fn polymod(values: &[u8]) -> u32 {
const GEN: [u32; 5] = [
0x3b6a_57b2,
0x2650_8e6d,
0x1ea1_19fa,
0x3d42_33dd,
0x2a14_62b3,
];
let mut chk: u32 = 1;
for &v in values {
let b = chk >> 25;
chk = ((chk & 0x01ff_ffff) << 5) ^ (v as u32);
for (i, g) in GEN.iter().enumerate() {
if (b >> i) & 1 != 0 {
chk ^= *g;
}
}
}
chk
}
fn bech32_encode(hrp: &str, payload32: &[u8; 32]) -> String {
let data = convert_bits_8_to_5(payload32);
let mut enc = hrp_expand(hrp);
enc.extend_from_slice(&data);
enc.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
let plm = polymod(&enc) ^ BECH32_CONST;
let checksum: [u8; 6] = std::array::from_fn(|i| ((plm >> (5 * (5 - i))) & 31) as u8);
let mut result = String::with_capacity(hrp.len() + 1 + data.len() + 6);
result.push_str(hrp);
result.push('1');
for &v in data.iter().chain(checksum.iter()) {
result.push(BECH32_CHARSET[v as usize] as char);
}
result
}
fn build_owner_only_acl(webid: &str, resource_iri: &str) -> AclDocument {
let owner = AclAuthorization {
id: Some("#owner".into()),
r#type: Some("acl:Authorization".into()),
agent: Some(IdOrIds::Single(IdRef { id: webid.into() })),
agent_class: None,
agent_group: None,
origin: None,
access_to: Some(IdOrIds::Single(IdRef {
id: resource_iri.into(),
})),
default: None,
mode: Some(IdOrIds::Multiple(vec![
IdRef {
id: "acl:Read".into(),
},
IdRef {
id: "acl:Write".into(),
},
IdRef {
id: "acl:Control".into(),
},
])),
condition: None,
};
AclDocument {
context: None,
graph: Some(vec![owner]),
}
}
fn render_privkey_jsonld(outcome: &KeyProvisioningOutcome) -> String {
let body = json!({
"@context": {
"nostr": NOSTR_NS,
},
"@id": "",
"nostr:npub": &outcome.npub,
"nostr:nsec": &outcome.nsec,
"nostr:pubkeyHex": &outcome.pubkey_hex,
"nostr:keyAlgorithm": "schnorr-secp256k1",
"nostr:createdAt": Utc::now().to_rfc3339(),
});
serde_json::to_string_pretty(&body).expect("static JSON always serialises")
}
fn patch_webid_with_nostr_pubkey(html: &str, pubkey_hex: &str) -> Result<String, PodError> {
let tag_marker = "application/ld+json";
let tag_idx = html.find(tag_marker).ok_or_else(|| {
PodError::BadRequest("profile/card missing application/ld+json data island".into())
})?;
let after_tag = html[tag_idx..]
.find('>')
.ok_or_else(|| PodError::BadRequest("profile/card data island has no closing >".into()))?;
let json_start = tag_idx + after_tag + 1;
let script_end_rel = html[json_start..]
.find("</script>")
.ok_or_else(|| PodError::BadRequest("profile/card data island has no </script>".into()))?;
let json_str = html[json_start..json_start + script_end_rel].trim();
let mut value: Value = serde_json::from_str(json_str).map_err(|e| {
PodError::BadRequest(format!(
"profile/card data island is not valid JSON-LD: {e}"
))
})?;
let ctx = value
.get_mut("@context")
.ok_or_else(|| PodError::BadRequest("profile/card missing @context".into()))?;
if let Some(map) = ctx.as_object_mut() {
map.entry("nostr")
.or_insert_with(|| Value::String(NOSTR_NS.into()));
}
let obj = value
.as_object_mut()
.ok_or_else(|| PodError::BadRequest("profile/card root is not a JSON object".into()))?;
obj.insert("nostr:pubkey".into(), Value::String(pubkey_hex.into()));
let new_json = serde_json::to_string_pretty(&value)
.map_err(|e| PodError::BadRequest(format!("failed to serialise patched WebID: {e}")))?;
let mut out = String::with_capacity(html.len() + new_json.len());
out.push_str(&html[..json_start]);
out.push('\n');
out.push_str(&new_json);
out.push('\n');
out.push_str(&html[json_start + script_end_rel..]);
Ok(out)
}
pub async fn provision_pod_keys(
storage: &dyn Storage,
plan: &KeyProvisioningPlan,
) -> Result<KeyProvisioningOutcome, PodError> {
let signing_key = match plan.deterministic_entropy {
Some(seed) => SigningKey::from_bytes(&seed)
.map_err(|e| PodError::Backend(format!("schnorr key from seed: {e}")))?,
None => SigningKey::random(&mut OsRng),
};
let verifying_key = signing_key.verifying_key();
let pubkey_bytes: [u8; 32] = verifying_key.to_bytes().into();
let secret_bytes: [u8; 32] = signing_key.to_bytes().into();
let pubkey_hex = hex::encode(pubkey_bytes);
let npub = bech32_encode("npub", &pubkey_bytes);
let nsec = bech32_encode("nsec", &secret_bytes);
let outcome = KeyProvisioningOutcome {
npub,
nsec,
pubkey_hex: pubkey_hex.clone(),
privkey_path: POD_PRIVKEY_PATH.to_string(),
webid: plan.webid.clone(),
};
let body = render_privkey_jsonld(&outcome);
storage
.put(
POD_PRIVKEY_PATH,
Bytes::from(body.into_bytes()),
"application/ld+json",
)
.await?;
let acl_doc = build_owner_only_acl(&plan.webid, POD_PRIVKEY_PATH);
let acl_json = serde_json::to_vec(&acl_doc)
.map_err(|e| PodError::Backend(format!("failed to serialise privkey ACL: {e}")))?;
storage
.put(
POD_PRIVKEY_ACL_PATH,
Bytes::from(acl_json),
"application/ld+json",
)
.await?;
let (existing_card, meta) = storage.get(POD_PROFILE_CARD_PATH).await?;
let html = std::str::from_utf8(&existing_card)
.map_err(|_| PodError::BadRequest("profile/card is not valid UTF-8".into()))?;
let patched = patch_webid_with_nostr_pubkey(html, &pubkey_hex)?;
storage
.put(
POD_PROFILE_CARD_PATH,
Bytes::from(patched.into_bytes()),
&meta.content_type,
)
.await?;
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bech32_npub_round_trip_prefix_and_length() {
let pk = [0u8; 32];
let encoded = bech32_encode("npub", &pk);
assert!(encoded.starts_with("npub1"), "npub prefix: {encoded}");
assert_eq!(encoded.len(), 63, "npub length: {encoded}");
}
#[test]
fn bech32_nsec_prefix() {
let sk = [0xffu8; 32];
let encoded = bech32_encode("nsec", &sk);
assert!(encoded.starts_with("nsec1"), "nsec prefix: {encoded}");
}
#[test]
fn deterministic_seed_yields_stable_pubkey() {
let seed = [0x42u8; 32];
let a = SigningKey::from_bytes(&seed).unwrap();
let b = SigningKey::from_bytes(&seed).unwrap();
assert_eq!(
hex::encode(a.verifying_key().to_bytes()),
hex::encode(b.verifying_key().to_bytes())
);
}
}