AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
205 lines
7.5 KiB
Rust
205 lines
7.5 KiB
Rust
use anyhow::{anyhow, Result};
|
|
use at_crypto::cid::cid_for_cbor;
|
|
use at_crypto::signing::verify_dag_cbor;
|
|
use cid::Cid;
|
|
use k256::ecdsa::VerifyingKey;
|
|
use serde_json::Value;
|
|
|
|
/// A signed repository commit.
|
|
///
|
|
/// `Commit` carries the canonical DAG-CBOR serialization of a signed commit
|
|
/// block (including the `sig` field) together with the parsed fields. The
|
|
/// `cid` is the SHA-256 DAG-CBOR content-address of `signed_bytes`.
|
|
///
|
|
/// The `data` field is `Option<Cid>` to support commits on empty repositories
|
|
/// — an empty repo has no MST root to point at, so the JSON payload's `data`
|
|
/// is serialized as `null`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Commit {
|
|
pub cid: Cid,
|
|
pub signed_bytes: Vec<u8>,
|
|
pub did: String,
|
|
pub rev: String,
|
|
pub prev: Option<Cid>,
|
|
pub data: Option<Cid>,
|
|
}
|
|
|
|
impl Commit {
|
|
/// Verify the commit's signature.
|
|
///
|
|
/// Delegates the actual cryptographic check to [`at_crypto::signing::verify_dag_cbor`],
|
|
/// which uses the `pubkey` field embedded inside the signed commit. The
|
|
/// `signing_pubkey` argument is the caller-trusted key — we additionally
|
|
/// require the embedded pubkey to match it, so a malicious swap of the
|
|
/// `pubkey` field (followed by a forged signature under the swapped key)
|
|
/// is rejected.
|
|
pub fn verify(&self, signing_pubkey: &VerifyingKey) -> Result<()> {
|
|
let embedded = verify_dag_cbor(&self.signed_bytes)?;
|
|
if &embedded != signing_pubkey {
|
|
return Err(anyhow!(
|
|
"commit embedded pubkey does not match expected signing pubkey"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Parse a signed commit block out of raw DAG-CBOR bytes.
|
|
///
|
|
/// This is used by `Repo::load` to reconstruct the head commit when
|
|
/// re-hydrating a repository from a blockstore.
|
|
pub fn from_signed_bytes(signed_bytes: Vec<u8>) -> Result<Self> {
|
|
let cid = cid_for_cbor(&signed_bytes)?;
|
|
let value: Value = ciborium::from_reader(&signed_bytes[..])
|
|
.map_err(|e| anyhow!("invalid commit CBOR: {e}"))?;
|
|
let obj = value
|
|
.as_object()
|
|
.ok_or_else(|| anyhow!("commit CBOR is not an object"))?;
|
|
|
|
let did = obj
|
|
.get("did")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow!("commit missing `did`"))?
|
|
.to_string();
|
|
let rev = obj
|
|
.get("rev")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow!("commit missing `rev`"))?
|
|
.to_string();
|
|
let prev = parse_optional_cid(obj.get("prev"), "prev")?;
|
|
let data = parse_optional_cid(obj.get("data"), "data")?;
|
|
|
|
Ok(Self {
|
|
cid,
|
|
signed_bytes,
|
|
did,
|
|
rev,
|
|
prev,
|
|
data,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_optional_cid(value: Option<&Value>, field: &str) -> Result<Option<Cid>> {
|
|
match value {
|
|
None | Some(Value::Null) => Ok(None),
|
|
Some(Value::String(s)) => s
|
|
.parse::<Cid>()
|
|
.map(Some)
|
|
.map_err(|e| anyhow!("commit `{field}` is not a valid CID: {e}")),
|
|
Some(_) => Err(anyhow!("commit `{field}` must be null or string")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use at_crypto::did_key::pubkey_to_multibase;
|
|
use k256::ecdsa::SigningKey;
|
|
use k256::SecretKey;
|
|
use k256::PublicKey;
|
|
|
|
fn make_test_commit(
|
|
sk: &SigningKey,
|
|
did: &str,
|
|
rev: &str,
|
|
prev: Option<&str>,
|
|
data: Option<&str>,
|
|
) -> Commit {
|
|
let pk: PublicKey = sk.verifying_key().into();
|
|
let mb = pubkey_to_multibase(&pk).unwrap();
|
|
let mut payload = serde_json::json!({
|
|
"did": did,
|
|
"version": 3,
|
|
"prev": prev.map(|s| serde_json::Value::String(s.to_string()))
|
|
.unwrap_or(serde_json::Value::Null),
|
|
"data": data.map(|s| serde_json::Value::String(s.to_string()))
|
|
.unwrap_or(serde_json::Value::Null),
|
|
"rev": rev,
|
|
"pubkey": mb,
|
|
});
|
|
let _ = payload.as_object_mut().unwrap().remove("sig");
|
|
let signed = at_crypto::signing::sign_dag_cbor(sk, &payload).unwrap();
|
|
Commit::from_signed_bytes(signed.signed_bytes).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn self_signed_commit_verifies() {
|
|
let sk = SigningKey::from(SecretKey::from_slice(&[7u8; 32]).unwrap());
|
|
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
|
|
assert!(commit.verify(&sk.verifying_key()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_key_fails_verify() {
|
|
let sk = SigningKey::from(SecretKey::from_slice(&[7u8; 32]).unwrap());
|
|
let sk2 = SigningKey::from(SecretKey::from_slice(&[9u8; 32]).unwrap());
|
|
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
|
|
assert!(commit.verify(&sk2.verifying_key()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn commit_with_prev_and_data_roundtrip() {
|
|
let sk = SigningKey::from(SecretKey::from_slice(&[11u8; 32]).unwrap());
|
|
let prev_cid: Cid = "bafyreig7qfkqdk5v3jy3z6xgc4n3yh6ycjxqrvt5pqjpwxgvvcxyzw7tqy"
|
|
.parse()
|
|
.unwrap();
|
|
let data_cid: Cid = "bafyreihzfgvyuwdq5i3qaqj2vnlv4bgw2xhcsoa2uh2pqkpcw55nuefzzi"
|
|
.parse()
|
|
.unwrap();
|
|
let commit = make_test_commit(
|
|
&sk,
|
|
"did:plc:abc",
|
|
"abc123",
|
|
Some(&prev_cid.to_string()),
|
|
Some(&data_cid.to_string()),
|
|
);
|
|
assert_eq!(commit.did, "did:plc:abc");
|
|
assert_eq!(commit.rev, "abc123");
|
|
assert_eq!(commit.prev, Some(prev_cid));
|
|
assert_eq!(commit.data, Some(data_cid));
|
|
assert!(commit.verify(&sk.verifying_key()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_signed_bytes_fail_verify() {
|
|
let sk = SigningKey::from(SecretKey::from_slice(&[15u8; 32]).unwrap());
|
|
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
|
|
// Flip a bit in `sig` (last bytes of the CBOR object). The simplest
|
|
// way to land inside `sig` (a hex string of ~128 chars) is to flip a
|
|
// byte near the tail of the payload.
|
|
let mut tampered_bytes = commit.signed_bytes.clone();
|
|
let len = tampered_bytes.len();
|
|
tampered_bytes[len - 4] ^= 0x01;
|
|
tampered_bytes[len - 3] ^= 0x01;
|
|
let res = match Commit::from_signed_bytes(tampered_bytes) {
|
|
Ok(c) => c.verify(&sk.verifying_key()),
|
|
Err(e) => Err(e),
|
|
};
|
|
assert!(
|
|
res.is_err(),
|
|
"tampered commit must not verify; got Ok"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_field_fails_verify() {
|
|
let sk = SigningKey::from(SecretKey::from_slice(&[16u8; 32]).unwrap());
|
|
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
|
|
// Parse the signed CBOR object, swap the `did`, and re-encode. The
|
|
// resulting CID is different, but the signature over the unsigned
|
|
// payload is now stale — verification must fail.
|
|
let mut value: Value = ciborium::from_reader(commit.signed_bytes.as_slice()).unwrap();
|
|
{
|
|
let obj = value.as_object_mut().unwrap();
|
|
obj.insert("did".into(), Value::String("did:plc:imposter".into()));
|
|
}
|
|
let mut new_bytes = Vec::new();
|
|
ciborium::into_writer(&value, &mut new_bytes).unwrap();
|
|
let res = match Commit::from_signed_bytes(new_bytes) {
|
|
Ok(c) => c.verify(&sk.verifying_key()),
|
|
Err(e) => Err(e),
|
|
};
|
|
assert!(res.is_err(), "did swap must invalidate signature");
|
|
}
|
|
}
|