//! Host-held keys, byte for byte with the JS host: com.fedfork.exec.attest (javascript/src/attest: //! network.attested.signature, secp256k1, DAG-CBOR CID) and the did:key pseudo-calls //! (javascript/src/didkey: P-256 did:key, ES256 service-auth JWTs, raw signatures). use base64::Engine; use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}; use k256::ecdsa::signature::Signer; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; use crate::store::{Store, random_bytes, unbase64}; pub const ATTEST_NSID: &str = "com.fedfork.exec.attest"; pub const GET_SERVICE_AUTH: &str = "com.fedfork.exec.didkey.getServiceAuth"; pub const SIGN: &str = "com.fedfork.exec.didkey.sign"; const SIGNATURE: &str = "network.attested.signature"; const MAX_TTL_S: u64 = 3600; const B58: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; const B32: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; pub fn base58(bytes: &[u8]) -> String { let mut digits: Vec = vec![]; for &byte in bytes { let mut carry = byte as u32; for d in digits.iter_mut() { carry += (*d as u32) << 8; *d = (carry % 58) as u8; carry /= 58; } while carry > 0 { digits.push((carry % 58) as u8); carry /= 58; } } let zeros = bytes.iter().take_while(|b| **b == 0).count(); std::iter::repeat_n(b'1', zeros) .chain(digits.iter().rev().map(|d| B58[*d as usize])) .map(char::from) .collect() } pub fn unbase58(text: &str) -> Result, String> { let mut out: Vec = vec![]; for c in text.bytes() { let mut carry = B58.iter().position(|&a| a == c).ok_or("bad base58")? as u32; for byte in out.iter_mut().rev() { carry += *byte as u32 * 58; *byte = carry as u8; carry >>= 8; } while carry > 0 { out.insert(0, carry as u8); carry >>= 8; } } let zeros = text.bytes().take_while(|&c| c == b'1').count(); Ok([vec![0; zeros], out].concat()) } pub fn base32(bytes: &[u8]) -> String { let (mut out, mut value, mut bits) = (String::new(), 0u32, 0); for &byte in bytes { value = (value << 8) | byte as u32; bits += 8; while bits >= 5 { bits -= 5; out.push(B32[((value >> bits) & 31) as usize] as char); } value &= (1 << bits) - 1; } if bits > 0 { out.push(B32[((value << (5 - bits)) & 31) as usize] as char); } out } pub fn unbase32(text: &str) -> Vec { let (mut out, mut value, mut bits) = (vec![], 0u32, 0); for c in text.bytes() { value = (value << 5) | B32.iter().position(|x| *x == c).unwrap_or(0) as u32; bits += 5; if bits >= 8 { bits -= 8; out.push(((value >> bits) & 0xff) as u8); value &= (1 << bits) - 1; } } out } pub fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn unhex(text: &str) -> Result, String> { (0..text.len()) .step_by(2) .map(|i| { text.get(i..i + 2) .and_then(|h| u8::from_str_radix(h, 16).ok()) .ok_or_else(|| "bad hex key".to_string()) }) .collect() } fn head(out: &mut Vec, major: u8, n: u64) { let m = major << 5; match n { 0..24 => out.push(m | n as u8), 24..0x100 => out.extend([m | 24, n as u8]), 0x100..0x10000 => out.extend([m | 25].into_iter().chain((n as u16).to_be_bytes())), 0x10000..0x1_0000_0000 => out.extend([m | 26].into_iter().chain((n as u32).to_be_bytes())), _ => out.extend([m | 27].into_iter().chain(n.to_be_bytes())), } } /// The attest flavour of DAG-CBOR: JSON values, maps sorted length-first, $link/$bytes plain maps. pub fn dag_cbor(value: &Value, out: &mut Vec) -> Result<(), String> { match value { Value::Null => out.push(0xf6), Value::Bool(b) => out.push(if *b { 0xf5 } else { 0xf4 }), Value::Number(n) => { let int = n.as_i64().or_else(|| { n.as_f64() .filter(|f| f.fract() == 0.0 && f.abs() <= 9_007_199_254_740_991.0) .map(|f| f as i64) }); match int.filter(|i| i.unsigned_abs() <= 9_007_199_254_740_991) { Some(i) if i >= 0 => head(out, 0, i as u64), Some(i) => head(out, 1, (-1 - i) as u64), None => return Err("Attested records hold integers only".into()), } } Value::String(s) => { head(out, 3, s.len() as u64); out.extend(s.as_bytes()); } Value::Array(items) => { head(out, 4, items.len() as u64); for item in items { dag_cbor(item, out)?; } } Value::Object(map) => { let mut keys: Vec<&String> = map.keys().collect(); keys.sort_by(|a, b| a.len().cmp(&b.len()).then(a.as_bytes().cmp(b.as_bytes()))); head(out, 5, keys.len() as u64); for key in keys { head(out, 3, key.len() as u64); out.extend(key.as_bytes()); dag_cbor(&map[key], out)?; } } } Ok(()) } /// CIDv1 dag-cbor sha2-256 of the record without signatures, with $sig = entry metadata + repository. pub fn attestation_cid( record: &Value, metadata: &Value, repository: &str, ) -> Result<(Vec, String), String> { if !record .get("$type") .and_then(Value::as_str) .is_some_and(|t| !t.is_empty()) { return Err("Attested record needs a $type".into()); } let mut sig = metadata.as_object().cloned().unwrap_or_default(); sig.shift_remove("cid"); sig.shift_remove("signature"); sig.insert("repository".into(), json!(repository)); let mut prepared = record.as_object().cloned().unwrap_or_default(); prepared.shift_remove("signatures"); prepared.insert("$sig".into(), Value::Object(sig)); let mut encoded = vec![]; dag_cbor(&Value::Object(prepared), &mut encoded)?; let bytes = [&[0x01, 0x71, 0x12, 0x20][..], &Sha256::digest(&encoded)].concat(); let cid = format!("b{}", base32(&bytes)); Ok((bytes, cid)) } /// did:key of a compressed secp256k1 public key (multicodec 0xe7 0x01). pub fn did_key_k256(key: &k256::ecdsa::SigningKey) -> String { let point = key.verifying_key().to_encoded_point(true); format!( "did:key:z{}", base58(&[&[0xe7, 0x01], point.as_bytes()].concat()) ) } /// sha256 + ECDSA secp256k1, compact r||s, low-S. pub fn sign_k256(key: &k256::ecdsa::SigningKey, message: &[u8]) -> Vec { let signature: k256::ecdsa::Signature = key.sign(message); signature .normalize_s() .unwrap_or(signature) .to_bytes() .to_vec() } /// attestHost: one key per account DID in the secret setting `attestKeys` ({did: hex}). pub fn attest( store: &Store, account: Option<&str>, input: Option<&Value>, ) -> Result { let empty = Value::Object(Map::new()); let input = input.filter(|i| i.is_object()).unwrap_or(&empty); let account = account .filter(|a| input.get("repository").and_then(Value::as_str) == Some(*a)) .ok_or("attest repository must be the session DID")?; let mut keys = store.setting("attestKeys")?.unwrap_or_else(|| json!({})); if keys.get(account).is_none() { keys[account] = json!(hex(&random_bytes::<32>())); store.set_setting("attestKeys", &keys)?; } let secret = unhex( store .setting("attestKeys")? .and_then(|k| k.get(account).and_then(Value::as_str).map(String::from)) .ok_or("attest key missing")? .as_str(), )?; let key = k256::ecdsa::SigningKey::from_slice(&secret).map_err(|e| e.to_string())?; let did = did_key_k256(&key); let Some(record) = input.get("record") else { return Ok(json!({ "key": did })); }; if !record.is_object() { return Err("attest record must be an object".into()); } let metadata = json!({ "$type": SIGNATURE, "key": did, "issuer": account }); let (bytes, cid) = attestation_cid(record, &metadata, account)?; let mut entry = metadata; entry["cid"] = json!(cid); entry["signature"] = json!({ "$bytes": STANDARD_NO_PAD.encode(sign_k256(&key, &bytes)) }); let mut signed = record.clone(); let mut signatures = record .get("signatures") .and_then(Value::as_array) .cloned() .unwrap_or_default(); signatures.push(entry); signed["signatures"] = Value::Array(signatures); Ok(json!({ "key": did, "record": signed })) } /// A P-256 did:key the host holds for a module (its JWK sealed in the `didkeys` setting). pub struct DidKey { pub did: String, pub jwk: Value, key: p256::ecdsa::SigningKey, } impl DidKey { /// From a private JWK (WebCrypto's export), or a fresh key when None. pub fn new(jwk: Option<&Value>) -> Result { let key = match jwk { Some(jwk) => { let d = jwk .get("d") .and_then(Value::as_str) .ok_or("did:key JWK needs d")?; p256::ecdsa::SigningKey::from_slice(&unbase64(d)?).map_err(|e| e.to_string())? } None => p256::ecdsa::SigningKey::from_slice(&random_bytes::<32>()) .map_err(|e| e.to_string())?, }; let point = key.verifying_key().to_encoded_point(false); let (x, y) = ( URL_SAFE_NO_PAD.encode(point.x().expect("uncompressed")), URL_SAFE_NO_PAD.encode(point.y().expect("uncompressed")), ); let jwk = jwk.cloned().unwrap_or_else(|| json!({ "key_ops": ["sign"], "ext": true, "kty": "EC", "x": x, "y": y, "crv": "P-256", "d": URL_SAFE_NO_PAD.encode(key.to_bytes()) })); // multicodec p256-pub (varint 0x80 0x24) + compressed point let compressed = key.verifying_key().to_encoded_point(true); let did = format!( "did:key:z{}", base58(&[&[0x80, 0x24], compressed.as_bytes()].concat()) ); Ok(Self { did, jwk, key }) } /// sha256 + ECDSA P-256, compact r||s, low-S. pub fn sign(&self, data: &[u8]) -> Vec { let signature: p256::ecdsa::Signature = self.key.sign(data); signature .normalize_s() .unwrap_or(signature) .to_bytes() .to_vec() } /// A service-auth JWT the did:key signs itself (ES256). exp: default now + 5 min, at most now + 1 h. pub fn service_auth(&self, aud: &str, lxm: &str, exp: Option) -> String { let iat = crate::now_ms() / 1000; let exp = exp .filter(|e| e.fract() == 0.0 && e.abs() <= 9_007_199_254_740_991.0 && *e > iat as f64) .map_or(iat + 300, |e| e as u64) .min(iat + MAX_TTL_S); let enc = |v: Value| URL_SAFE_NO_PAD.encode(v.to_string()); let signing = format!( "{}.{}", enc(json!({ "typ": "JWT", "alg": "ES256" })), enc( json!({ "iss": self.did, "aud": aud, "lxm": lxm, "iat": iat, "exp": exp, "jti": hex(&random_bytes::<16>()) }) ) ); format!( "{signing}.{}", URL_SAFE_NO_PAD.encode(self.sign(signing.as_bytes())) ) } /// The did:key pseudo-calls; None when nsid is neither. pub fn pseudo_call( &self, nsid: &str, params: Option<&Map>, input: Option<&Value>, ) -> Option> { match nsid { GET_SERVICE_AUTH => { let param = |k: &str| params.and_then(|p| p.get(k)).and_then(Value::as_str); Some(match (param("aud").filter(|a| a.starts_with("did:")), param("lxm")) { (Some(aud), Some(lxm)) => { let exp = param("exp").map(|e| e.trim().parse::().unwrap_or(f64::NAN)); Ok(json!({ "token": self.service_auth(aud, lxm, exp), "did": self.did })) } _ => Err(format!("{GET_SERVICE_AUTH} needs params aud (a DID) and lxm")), }) } SIGN => Some(match input.and_then(|i| i.get("data")).and_then(Value::as_str) { Some(data) => unbase64(data).and_then(|bytes| { if !nonce_shaped(&bytes) { return Err(format!("{SIGN} signs only a relay nonce ({NONCE_BYTES} random bytes)")); } Ok(json!({ "did": self.did, "signature": STANDARD.encode(self.sign(&bytes)) })) }), None => Err(format!("{SIGN} needs input data (base64)")), }), _ => None, } } } pub const NONCE_BYTES: usize = 64; /// didkey.sign signs only the relay nonce shape: 64 random bytes, not all JWS-compact characters /// (a JWS signing input is base64url "header.payload"). The same key signs the service-auth JWTs, /// so signing any bytes would let a module mint its own, past the exp cap and aud check. pub fn nonce_shaped(bytes: &[u8]) -> bool { bytes.len() == NONCE_BYTES && !bytes .iter() .all(|b| b.is_ascii_alphanumeric() || b"_.=-".contains(b)) } /// The host name the relay serves a did at: did:key:zDn... -> did-key-zdn..... pub fn ingress_host(did: &str, relay: &str) -> String { let host = relay .split("://") .nth(1) .unwrap_or(relay) .split(['/', ':']) .next() .unwrap_or_default(); format!("{}.{host}", did.replace(':', "-").to_lowercase()) } #[cfg(test)] mod tests { use super::*; use k256::ecdsa::signature::Verifier; // The signed RFP the live bidders accepted (atlibs-socialweb-computer-requester: web/webrtc/attest.test.mjs). fn rfp() -> Value { json!({ "$type": "com.publicdomainrelay.temp.market.rfp", "domain": "compute", "payload": { "cid": "bafyreigh3k4lw4txsk4ojkx3b3x7i32umdi26vmzo2vw3oxyxkfyfckvja", "uri": "at://did:plc:lpfuqerea3deuoyrn7ojser4/com.publicdomainrelay.temp.compute.vm/3mzof3sah7v2t", "$type": "com.atproto.repo.strongRef" }, "policies": [{ "cid": "bafyreigr27gkscq2otmv5xfmyx3t2s32ga63xcwp2amafubjyxfd4oky5y", "uri": "at://did:plc:lpfuqerea3deuoyrn7ojser4/computer.socialweb.temp.policy.ghalite/3mzof3sjie32i", "$type": "com.atproto.repo.strongRef" }], "createdAt": "2026-11-06T22:02:10.263Z", "signatures": [{ "cid": "bafyreifxbegxhn5nds47mb74segtp75sdtm5iva45l7tphfykkeliax3ca", "key": "did:key:zQ3shXLEhJWkcH5M1dceCTRkkPajiZ7sAxAbvPMLbtNSLLxaP", "$type": "network.attested.signature", "issuer": "did:plc:lpfuqerea3deuoyrn7ojser4", "signature": { "$bytes": "W1NmGCHM1MwpsXEgt2x8N4ZPpwFIunGzzAm5AxaqWoYelO+UH2zp8ZUitHRgv/4itLteOKEePEhvKF4OdLUS6g" } }] }) } fn verifies(signature: &str, message: &[u8], did: &str) -> bool { let decoded = bs58_decode(did.strip_prefix("did:key:z").unwrap()); let key = k256::ecdsa::VerifyingKey::from_sec1_bytes(&decoded[2..]).unwrap(); let sig = k256::ecdsa::Signature::from_slice(&unbase64(signature).unwrap()).unwrap(); key.verify(message, &sig).is_ok() } fn bs58_decode(text: &str) -> Vec { let mut bytes: Vec = vec![]; for c in text.bytes() { let mut carry = B58.iter().position(|x| *x == c).unwrap() as u32; for b in bytes.iter_mut() { carry += (*b as u32) * 58; *b = carry as u8; carry >>= 8; } while carry > 0 { bytes.push(carry as u8); carry >>= 8; } } bytes.reverse(); bytes } #[test] fn the_live_rfp_vector_has_the_same_cid_and_its_signature_verifies() { let rfp = rfp(); let entry = &rfp["signatures"][0]; let (bytes, cid) = attestation_cid(&rfp, entry, "did:plc:lpfuqerea3deuoyrn7ojser4").unwrap(); assert_eq!(cid, entry["cid"]); assert!(verifies( entry["signature"]["$bytes"].as_str().unwrap(), &bytes, entry["key"].as_str().unwrap() )); assert_ne!( attestation_cid(&rfp, entry, "did:plc:other").unwrap().1, cid ); } #[test] fn attest_signs_with_one_stored_key_per_did() { let dir = std::env::temp_dir().join(format!("atlibs-host-attest-{}", crate::store::uuid())); let store = Store::open(&dir, "atlibs-server").unwrap(); let repo = "did:plc:lpfuqerea3deuoyrn7ojser4"; let key = attest(&store, Some(repo), Some(&json!({ "repository": repo }))).unwrap()["key"] .clone(); assert!(key.as_str().unwrap().starts_with("did:key:zQ3s")); let mut unsigned = rfp(); unsigned.as_object_mut().unwrap().shift_remove("signatures"); let out = attest( &store, Some(repo), Some(&json!({ "repository": repo, "record": unsigned })), ) .unwrap(); assert_eq!(out["key"], key); let entry = &out["record"]["signatures"][0]; let (bytes, cid) = attestation_cid(&out["record"], entry, repo).unwrap(); assert_eq!(entry["cid"], json!(cid)); assert!(verifies( entry["signature"]["$bytes"].as_str().unwrap(), &bytes, key.as_str().unwrap() )); assert_eq!( attest( &store, Some(repo), Some(&json!({ "repository": "did:plc:x" })) ) .unwrap_err(), "attest repository must be the session DID" ); assert_eq!( attest( &store, Some(repo), Some(&json!({ "repository": repo, "record": [] })) ) .unwrap_err(), "attest record must be an object" ); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn didkey_jwk_round_trips_and_names_the_same_did() { let key = DidKey::new(None).unwrap(); assert!(key.did.starts_with("did:key:zDn")); let again = DidKey::new(Some(&key.jwk)).unwrap(); assert_eq!(again.did, key.did); assert_eq!( ingress_host("did:key:zDnaE", "https://xrpc.fedproxy.com"), "did-key-zdnae.xrpc.fedproxy.com" ); let token = key.service_auth("did:web:x.example", "a.b.c", None); let claims: Value = serde_json::from_slice( &URL_SAFE_NO_PAD .decode(token.split('.').nth(1).unwrap()) .unwrap(), ) .unwrap(); assert_eq!( (claims["iss"].as_str(), claims["lxm"].as_str()), (Some(key.did.as_str()), Some("a.b.c")) ); assert_eq!( claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(), 300 ); } #[test] fn didkey_sign_takes_only_a_relay_nonce() { let key = DidKey::new(None).unwrap(); let sign = |bytes: &[u8]| { key.pseudo_call(SIGN, None, Some(&json!({ "data": STANDARD.encode(bytes) }))) .unwrap() }; let nonce: [u8; 64] = std::array::from_fn(|i| (i * 37 + 200) as u8); let signed = sign(&nonce).unwrap(); let pk = p256::ecdsa::VerifyingKey::from_sec1_bytes( &bs58_decode(key.did.strip_prefix("did:key:z").unwrap())[2..], ) .unwrap(); let sig = p256::ecdsa::Signature::from_slice( &unbase64(signed["signature"].as_str().unwrap()).unwrap(), ) .unwrap(); assert!(pk.verify(&nonce, &sig).is_ok()); // a JWS signing input (what would mint a service-auth token) and other shapes are refused let token = key.service_auth("did:web:x", "a.b.c", None); let jws = token.rsplit_once('.').unwrap().0; assert!(sign(jws.as_bytes()).is_err()); assert!(sign(&jws.as_bytes()[..64]).is_err()); assert!(sign(b"hello").is_err()); assert!(sign(&[nonce, nonce].concat()).is_err()); } }