~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
539 lines · 19.9 KB Raw History
//! 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<u8> = 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<Vec<u8>, String> {
    let mut out: Vec<u8> = 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<u8> {
    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<Vec<u8>, 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<u8>, 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<u8>) -> 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<u8>, 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<u8> {
    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<Value, String> {
    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<Self, String> {
        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<u8> {
        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<f64>) -> 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<String, Value>>,
        input: Option<&Value>,
    ) -> Option<Result<Value, String>> {
        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::<f64>().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....<relay host>.
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<u8> {
        let mut bytes: Vec<u8> = 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());
    }
}