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

chadtmiller.com/atproto

atproto's primitives in Rust, small and free of I/O

0
0
main
404 lines · 13.5 KB Raw History
//! The space alpha's primitives, against vectors made by running
//! `@atproto/space` (tests/reference/space-vectors.json, from pds.js, and
//! tests/reference/space-meta.json, from space-meta.mjs beside it).

use atproto::cid::Cid;
use atproto::drisl;
use atproto::keys::{Curve, Keypair};
use atproto::space::{
    COMMIT_MAC, Commit, LtHash, RECORD_MAC, RECORD_MAX, STAR_MAGIC, STATE_BYTES, SpaceRef,
    SpaceWriter, Star, decapsulate, element, encapsulate, mac_header, parse_want_mac,
    read_space_meta, read_star_parts, record_mac, star, want_mac_header, write_star,
};
use serde_json::Value;
use std::collections::BTreeMap;

fn reference(name: &str) -> Value {
    let path = format!("{}/tests/reference/{name}", env!("CARGO_MANIFEST_DIR"));
    serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}

fn vectors() -> Value {
    reference("space-vectors.json")
}

fn hex(v: &Value) -> Vec<u8> {
    let h = v.as_str().unwrap();
    (0..h.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap())
        .collect()
}

fn s(v: &Value) -> &str {
    v.as_str().unwrap()
}

#[test]
fn set_hashes_as_the_vectors_say() {
    let v = vectors();
    let empty = LtHash::default();
    assert_eq!(empty.state(), hex(&v["lthash"]["empty"]["stateHex"]));
    assert_eq!(
        empty.digest().to_vec(),
        hex(&v["lthash"]["empty"]["digestHex"])
    );
    assert!(empty.is_empty());
    for one in v["lthash"]["single"].as_array().unwrap() {
        let mut h = LtHash::default();
        h.add(s(&one["element"]));
        assert_eq!(h.state(), hex(&one["stateHex"]));
        assert_eq!(h.digest().to_vec(), hex(&one["digestHex"]));
        assert_eq!(LtHash::from_state(&h.state()).unwrap(), h);
        h.remove(s(&one["element"]));
        assert!(h.is_empty());
    }
    for set in v["lthash"]["sets"].as_array().unwrap() {
        let mut h = LtHash::default();
        for e in set["elements"].as_array().unwrap() {
            h.add(s(e));
        }
        assert_eq!(h.digest().to_vec(), hex(&set["digestHex"]));
    }
    assert_eq!(
        LtHash::from_state(&[0; 3]).unwrap_err().to_string(),
        format!("an LtHash state is {STATE_BYTES} bytes")
    );
}

#[test]
fn commits_records_and_archives_as_the_vectors_say() {
    let v = vectors();
    let c = &v["commit"];
    let mut h = LtHash::default();
    let mut records = BTreeMap::new();
    for r in c["records"].as_array().unwrap() {
        let cid = Cid::parse(s(&r["cid"])).unwrap();
        h.add(&element(s(&r["collection"]), s(&r["rkey"]), &cid));
        records.insert(
            format!("{}/{}", s(&r["collection"]), s(&r["rkey"])),
            hex(&r["valueHex"]),
        );
    }
    assert_eq!(h.digest().to_vec(), hex(&c["dataHex"]));
    let commit = Commit {
        repo: s(&c["repo"]).into(),
        space: s(&c["space"]).into(),
        rev: s(&c["rev"]).into(),
        data: h.digest(),
    };
    let mac = commit.mac(&hex(&c["secretHex"]));
    assert_eq!(mac.to_vec(), hex(&c["macHex"]));
    assert_eq!(mac_header(COMMIT_MAC, &mac), s(&c["macHeader"]));
    assert_eq!(
        atproto::space::parse_mac_header(s(&c["macHeader"]), COMMIT_MAC).unwrap(),
        mac.to_vec()
    );
    for bad in [
        "type=\"atproto-space-record\", ver=1, mac=:AAAA:",
        "type=\"atproto-space-repo\", ver=2, mac=:AAAA:",
        "type=\"atproto-space-repo\", ver=1",
        "type=\"atproto-space-repo\", ver=1, mac=:not base64!:",
    ] {
        assert_eq!(
            atproto::space::parse_mac_header(bad, COMMIT_MAC)
                .unwrap_err()
                .to_string(),
            "not a MAC header of atproto-space-repo",
            "{bad}"
        );
    }
    assert_eq!(star(&commit, &records), hex(&c["starHex"]));

    let r = &v["record"];
    let mac = record_mac(
        &hex(&r["secretHex"]),
        s(&r["uri"]),
        &Cid::parse(s(&r["cid"])).unwrap(),
    );
    assert_eq!(mac.to_vec(), hex(&r["macHex"]));
    assert_eq!(mac_header(RECORD_MAC, &mac), s(&r["macHeader"]));
}

#[test]
fn agrees_secrets_as_the_vectors_say() {
    let v = vectors();
    let k = &v["kem"];
    let author = Keypair::from_bytes(Curve::P256, &hex(&k["authorPrivateKeyHex"])).unwrap();
    assert_eq!(author.public().did_key(), s(&k["authorDidKey"]));
    let secret = decapsulate(&author, s(&k["keyShare"])).unwrap();
    assert_eq!(secret.to_vec(), hex(&k["sharedSecretHex"]));
    assert_eq!(
        want_mac_header(COMMIT_MAC, s(&k["keyShare"])),
        s(&k["wantMacHeader"])
    );
    assert_eq!(
        parse_want_mac(s(&k["wantMacHeader"]), COMMIT_MAC).unwrap(),
        s(&k["keyShare"])
    );

    // A syncer's side and the author's agree.
    let (share, theirs) = encapsulate(&author.public().did_key()).unwrap();
    assert_eq!(decapsulate(&author, &share).unwrap(), theirs);
}

#[test]
fn reads_space_refs() {
    let uri = "at://did:plc:abc/space/com.example.members/self";
    let space = SpaceRef::parse(uri).unwrap();
    assert_eq!(space.authority, "did:plc:abc");
    assert_eq!(space.kind, "com.example.members");
    assert_eq!(space.skey, "self");
    assert_eq!(space.to_string(), uri);
    for bad in [
        "did:plc:abc/space/com.example.members/self",
        "at://did:plc:abc/space/com.example.members",
        "at://did:plc:abc/space/com.example.members/self/",
        "at://did:plc:abc/repo/com.example.members/self",
        "at://alice.test/space/com.example.members/self",
        "at://did:plc:abc/space/members/self",
        "at://did:plc:abc/space/com.example.members/..",
    ] {
        assert_eq!(
            SpaceRef::parse(bad).unwrap_err().to_string(),
            format!("not a space: {bad}")
        );
    }
}

#[test]
fn refuses_keys_and_headers_it_cannot_use() {
    let k256 = Keypair::generate(Curve::K256);
    let p256 = Keypair::generate(Curve::P256);
    let refused =
        |r: Result<_, atproto::space::SpaceError>| r.map(|_: [u8; 32]| ()).unwrap_err().to_string();
    assert_eq!(
        refused(decapsulate(&k256, &p256.public().did_key())),
        "a KEM key is P-256"
    );
    assert_eq!(
        refused(decapsulate(&p256, &k256.public().did_key())),
        "a KEM key is a P-256 did:key"
    );
    assert_eq!(
        encapsulate("did:key:z1").unwrap_err().to_string(),
        "a KEM key is a P-256 did:key"
    );
    let share = p256.public().did_key();
    for (header, why) in [
        ("type", "a malformed MAC header"),
        (
            &format!("type=\"{RECORD_MAC}\", ver=1, key_share=\"{share}\"") as &str,
            "a MAC header not of atproto-space-repo, version 1",
        ),
        (
            "type=\"atproto-space-repo\", ver=2",
            "a MAC header not of atproto-space-repo, version 1",
        ),
        (
            "type=\"atproto-space-repo\", ver=1",
            "a MAC header with no key share",
        ),
        (
            "type=\"atproto-space-repo\", ver=1, key_share=\"x\"",
            "a MAC header with no key share",
        ),
    ] {
        assert_eq!(
            parse_want_mac(header, COMMIT_MAC).unwrap_err().to_string(),
            why,
            "{header}"
        );
    }
}

#[test]
fn reads_a_star_archive_back() {
    let v = vectors();
    let c = &v["commit"];
    let star_bytes = hex(&c["starHex"]);
    let (commit, records) = atproto::space::read_star(&star_bytes).unwrap();
    assert_eq!(commit.repo, s(&c["repo"]));
    assert_eq!(commit.space, s(&c["space"]));
    assert_eq!(commit.rev, s(&c["rev"]));
    assert_eq!(commit.data.to_vec(), hex(&c["dataHex"]));
    assert_eq!(star(&commit, &records), star_bytes);
    assert_eq!(records.len(), c["records"].as_array().unwrap().len());

    // An empty repo is its commit alone.
    let (empty, none) = atproto::space::read_star(&star(&commit, &BTreeMap::new())).unwrap();
    assert_eq!((empty, none.len()), (commit.clone(), 0));

    let refused = |bytes: &[u8]| atproto::space::read_star(bytes).unwrap_err().to_string();
    assert_eq!(refused(b"nope"), "not a STAR archive");
    let magic = atproto::space::STAR_MAGIC.to_vec();
    assert_eq!(
        refused(&[magic.clone(), vec![0x05, 1]].concat()),
        "a STAR archive cut short"
    );
    assert_eq!(
        refused(&[magic.clone(), vec![0xff; 6]].concat()),
        "a STAR length too long"
    );
    assert_eq!(
        refused(&[magic.clone(), vec![0x81, 0x00]].concat()),
        "a STAR length not in its shortest form"
    );
    assert_eq!(refused(&magic), "a STAR archive with no metadata");
    assert_eq!(
        refused(&[magic.clone(), vec![0x81]].concat()),
        "a STAR archive cut short"
    );
    assert_eq!(
        refused(&[magic.clone(), vec![1, 0x00]].concat()),
        "STAR metadata that is not a DRISL map"
    );
    let not_a_commit = drisl::encode(&atproto::Value::Map(BTreeMap::from([(
        "$type".into(),
        atproto::Value::String("x".into()),
    )])));
    let framed = |b: &[u8]| [vec![b.len() as u8], b.to_vec()].concat();
    assert_eq!(
        refused(&[magic.clone(), framed(&not_a_commit)].concat()),
        "not a repo's STAR archive"
    );
    let untyped = drisl::encode(&atproto::Value::Map(BTreeMap::new()));
    assert_eq!(
        refused(&[magic.clone(), framed(&untyped)].concat()),
        "STAR metadata with no $type"
    );
    let head = star(&commit, &BTreeMap::new());
    let record = |path: &str| [framed(path.as_bytes()), framed(b"\xa0")].concat();
    assert_eq!(
        refused(&[head.clone(), record("b/1"), record("a/1")].concat()),
        "a STAR archive's records out of order"
    );
    assert_eq!(
        refused(&[head.clone(), record("a/1"), record("a/1")].concat()),
        "a STAR archive's records out of order"
    );
    assert_eq!(
        refused(&[head.clone(), framed(b"\xff"), framed(b"\xa0")].concat()),
        "a STAR key that is not text"
    );
    assert_eq!(
        refused(&[head.clone(), framed(b"a/1")].concat()),
        "a STAR key with no value"
    );
    assert_eq!(
        refused(&[head.clone(), framed(b"a/1"), vec![5, 1]].concat()),
        "a STAR archive cut short"
    );
    // A record is a mebibyte at most.
    let mut big = BTreeMap::new();
    big.insert("a/1".to_string(), vec![0xa0; RECORD_MAX + 1]);
    assert_eq!(
        refused(&star(&commit, &big)),
        format!(
            "a STAR frame of {} bytes, past {RECORD_MAX}",
            RECORD_MAX + 1
        )
    );
}

#[test]
fn writes_and_reads_a_star_archive_of_any_profile() {
    let meta = BTreeMap::from([("$type".into(), atproto::Value::String("test".into()))]);
    let pairs: [(&[u8], &[u8]); 2] = [(&[1], &[2, 3]), (&[], &[])];
    let bytes = write_star(&meta, pairs);
    assert_eq!(bytes[..4], STAR_MAGIC);
    let read = read_star_parts(&bytes, 2).unwrap();
    assert_eq!(
        read,
        Star {
            meta,
            pairs: pairs.to_vec()
        }
    );
    assert!(read_star_parts(&bytes, 1).is_err());
}

#[test]
fn reads_a_space_meta_archive_as_the_alpha_writes_it() {
    let v = reference("space-meta.json");
    let read = |case: &Value| read_space_meta(&hex(&case["hex"])).unwrap();
    let full = read(&v["full"]);
    let want = &v["full"]["read"];
    assert_eq!(full.space, s(&want["space"]));
    let json = |m: &BTreeMap<String, atproto::Value>| atproto::Value::Map(m.clone()).to_json();
    assert_eq!(json(&full.policy), want["policy"]);
    let state: Vec<(String, Value)> = full
        .policy_state
        .iter()
        .map(|(k, v)| (k.clone(), json(v)))
        .collect();
    let wanted: Vec<(String, Value)> = want["policyState"]
        .as_array()
        .unwrap()
        .iter()
        .map(|e| (s(&e["key"]).to_string(), e["value"].clone()))
        .collect();
    assert_eq!(state, wanted);
    let writers: Vec<SpaceWriter> = want["writers"]
        .as_array()
        .unwrap()
        .iter()
        .map(|w| SpaceWriter {
            did: s(&w["did"]).into(),
            rev: s(&w["rev"]).into(),
            data: hex(&w["data"]),
            space_rev: s(&w["spaceRev"]).into(),
        })
        .collect();
    assert_eq!(full.writers, writers);

    let empty = read(&v["empty"]);
    assert_eq!(json(&empty.policy), v["empty"]["read"]["policy"]);
    assert!(empty.policy_state.is_empty() && empty.writers.is_empty());

    // A writer's digest is bytes.
    let text = |t: &str| atproto::Value::String(t.into());
    let map = |pairs: &[(&str, atproto::Value)]| {
        let m: BTreeMap<String, atproto::Value> = pairs
            .iter()
            .map(|(k, v)| (k.to_string(), v.clone()))
            .collect();
        drisl::encode(&atproto::Value::Map(m))
    };
    let meta = BTreeMap::from([
        ("$type".into(), text("atproto-space-meta")),
        ("ver".into(), atproto::Value::Integer(1)),
        ("space".into(), text(&full.space)),
    ]);
    let policy = map(&[("$type", text("com.atproto.personalspace.policy"))]);
    let tid = text("3kbcq3p7ad42b");
    let writer = map(&[
        ("rev", tid.clone()),
        ("data", text("01")),
        ("spaceRev", tid),
    ]);
    let archive = write_star(
        &meta,
        [
            (&b"policy"[..], &policy[..]),
            (b"writer/did:plc:alice", &writer),
        ],
    );
    assert_eq!(
        read_space_meta(&archive).unwrap_err().to_string(),
        "writer/did:plc:alice is no writer"
    );

    // Whatever the alpha refuses, refused here too.
    let refused = v["refused"].as_array().unwrap();
    assert_eq!(refused.len(), 19);
    for case in refused {
        let why = read_space_meta(&hex(&case["hex"]));
        assert!(
            why.is_err(),
            "{} read, though the alpha says: {}",
            case["why"],
            case["error"]
        );
    }
}