use serde::{Deserialize, Serialize};
use serde_json::Value;
use vta_sdk::trust_task_proof::{ProofPurpose, PurposeVmResolver, verify_proof_set};
use vta_sdk::trust_task_sign::{HolderKey, sign_object_for_assertion};
pub use vti_rooms::files::FileManifest;
const ACCEPTED_SUITES: [&str; 2] = ["eddsa-jcs-2022", "ecdsa-jcs-2019"];
const MAX_KEY: usize = 512;
const MAX_TITLE: usize = 512;
const MAX_DESCRIPTION: usize = 2048;
const MAX_BODY: usize = 1_048_576;
const MAX_TAGS: usize = 32;
const MAX_TAG: usize = 64;
#[derive(Debug, thiserror::Error)]
pub enum BodyError {
#[error("the record body is malformed: {0}")]
Malformed(String),
#[error("the record body's signature does not hold: {0}")]
Signature(String),
#[error("the record body was signed for {signed}, but was read from {read}")]
Relocated { signed: String, read: String },
#[error(
"the record body is signed by {signed}, but the host recorded {recorded} as its author"
)]
AuthorMismatch { signed: String, recorded: String },
#[error("could not sign the record body: {0}")]
Signing(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecordLocation<'a> {
pub room_id: &'a str,
pub key: &'a str,
pub version: u64,
}
impl std::fmt::Display for RecordLocation<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} / {} @ v{}", self.room_id, self.key, self.version)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SealedRecordBody {
pub room_id: String,
pub key: String,
pub version: u64,
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<FileManifest>,
}
impl SealedRecordBody {
pub fn new(at: RecordLocation<'_>, author: impl Into<String>) -> Self {
Self {
room_id: at.room_id.to_string(),
key: at.key.to_string(),
version: at.version,
author: author.into(),
title: None,
description: None,
body: None,
tags: Vec::new(),
file: None,
}
}
pub fn location(&self) -> RecordLocation<'_> {
RecordLocation {
room_id: &self.room_id,
key: &self.key,
version: self.version,
}
}
#[must_use]
pub fn at_version(mut self, version: u64) -> Self {
self.version = version;
self
}
fn check(&self) -> Result<(), BodyError> {
let bad = |m: String| Err(BodyError::Malformed(m));
if self.room_id.is_empty() {
return bad("roomId is empty".into());
}
if self.key.is_empty() || self.key.len() > MAX_KEY {
return bad(format!("key must be 1 to {MAX_KEY} characters"));
}
if self.version == 0 {
return bad("version starts at 1".into());
}
if !self.author.starts_with("did:") {
return bad("author is not a DID".into());
}
let too_long =
|v: &Option<String>, max: usize| v.as_ref().is_some_and(|s| s.chars().count() > max);
if too_long(&self.title, MAX_TITLE) {
return bad(format!("title is longer than {MAX_TITLE} characters"));
}
if too_long(&self.description, MAX_DESCRIPTION) {
return bad(format!(
"description is longer than {MAX_DESCRIPTION} characters"
));
}
if too_long(&self.body, MAX_BODY) {
return bad(format!("body is longer than {MAX_BODY} characters"));
}
if self.tags.len() > MAX_TAGS || self.tags.iter().any(|t| t.chars().count() > MAX_TAG) {
return bad(format!(
"at most {MAX_TAGS} tags of {MAX_TAG} characters each"
));
}
Ok(())
}
pub async fn sign(&self, key: &HolderKey) -> Result<Vec<u8>, BodyError> {
self.check()?;
if key.holder_did() != self.author {
return Err(BodyError::Signing(format!(
"the key belongs to {}, not to the author {}",
key.holder_did(),
self.author
)));
}
let mut value =
serde_json::to_value(self).map_err(|e| BodyError::Signing(e.to_string()))?;
sign_object_for_assertion(&mut value, key)
.await
.map_err(|e| BodyError::Signing(e.to_string()))?;
serde_json::to_vec(&value).map_err(|e| BodyError::Signing(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRecordBody {
inner: SealedRecordBody,
}
impl VerifiedRecordBody {
pub fn body(&self) -> &SealedRecordBody {
&self.inner
}
pub fn into_inner(self) -> SealedRecordBody {
self.inner
}
}
pub async fn verify_record_body(
plaintext: &[u8],
read_at: RecordLocation<'_>,
host_author: Option<&str>,
resolver: &(dyn PurposeVmResolver + '_),
) -> Result<VerifiedRecordBody, BodyError> {
let signed: Value = serde_json::from_slice(plaintext)
.map_err(|e| BodyError::Malformed(format!("not JSON: {e}")))?;
let verified = verify_proof_set(&signed, ProofPurpose::AssertionMethod, resolver)
.await
.map_err(|e| BodyError::Signature(e.to_string()))?;
for proof in verified.proofs() {
let suite = serde_json::to_value(proof.cryptosuite)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
if !ACCEPTED_SUITES.contains(&suite.as_str()) {
return Err(BodyError::Signature(format!(
"signed with {suite}; a record body takes {}",
ACCEPTED_SUITES.join(" or ")
)));
}
}
let mut unsigned = signed;
if let Some(obj) = unsigned.as_object_mut() {
obj.remove("proof");
}
let body: SealedRecordBody =
serde_json::from_value(unsigned).map_err(|e| BodyError::Malformed(e.to_string()))?;
body.check()?;
if verified.signer() != body.author {
return Err(BodyError::Signature(format!(
"it verifies for {}, but the body names {} as its author",
verified.signer(),
body.author
)));
}
if body.location() != read_at {
return Err(BodyError::Relocated {
signed: body.location().to_string(),
read: read_at.to_string(),
});
}
if let Some(recorded) = host_author
&& recorded != body.author
{
return Err(BodyError::AuthorMismatch {
signed: body.author,
recorded: recorded.to_string(),
});
}
Ok(VerifiedRecordBody { inner: body })
}
#[cfg(test)]
mod tests {
use super::*;
use vta_sdk::trust_task_proof::TrustTaskVmResolver;
fn did_key(seed_byte: u8) -> (String, HolderKey) {
let seed = [seed_byte; 32];
let sk = ed25519_dalek::SigningKey::from_bytes(&seed);
let did = format!(
"did:key:{}",
vta_sdk::did_key::ed25519_multibase_pubkey(&sk.verifying_key().to_bytes())
);
let mut buf = vec![0x80, 0x26];
buf.extend_from_slice(&seed);
let key =
HolderKey::from_did_key(&did, multibase::encode(multibase::Base::Base58Btc, &buf))
.expect("a did:key holder");
(did, key)
}
const ROOM: &str = "did:webvh:QmRoom:rooms.example:deal";
fn at(key: &str, version: u64) -> RecordLocation<'_> {
RecordLocation {
room_id: ROOM,
key,
version,
}
}
async fn signed_by(seed: u8, location: RecordLocation<'_>) -> (String, Vec<u8>) {
let (did, key) = did_key(seed);
let mut body = SealedRecordBody::new(location, &did);
body.title = Some("Pricing holds".into());
body.body = Some("Northwind accepted the term sheet.".into());
(did, body.sign(&key).await.expect("signs"))
}
fn resolver() -> TrustTaskVmResolver {
TrustTaskVmResolver::did_key_only()
}
#[tokio::test]
async fn a_body_verifies_where_it_was_signed() {
let (did, bytes) = signed_by(1, at("k1", 3)).await;
let v = verify_record_body(&bytes, at("k1", 3), Some(&did), &resolver())
.await
.expect("verifies");
assert_eq!(v.body().title.as_deref(), Some("Pricing holds"));
assert_eq!(v.body().author, did);
}
#[tokio::test]
async fn a_body_moved_to_another_key_or_version_is_refused() {
let (_, bytes) = signed_by(1, at("k1", 3)).await;
for elsewhere in [at("k2", 3), at("k1", 4)] {
let err = verify_record_body(&bytes, elsewhere, None, &resolver())
.await
.expect_err("relocated");
assert!(matches!(err, BodyError::Relocated { .. }), "{err}");
}
}
#[tokio::test]
async fn an_altered_body_does_not_verify() {
let (_, bytes) = signed_by(1, at("k1", 3)).await;
let mut v: Value = serde_json::from_slice(&bytes).unwrap();
v["title"] = "Pricing collapses".into();
let err = verify_record_body(
&serde_json::to_vec(&v).unwrap(),
at("k1", 3),
None,
&resolver(),
)
.await
.expect_err("altered");
assert!(matches!(err, BodyError::Signature(_)), "{err}");
}
#[tokio::test]
async fn a_body_claiming_another_author_is_refused() {
let (other, _) = did_key(2);
let (_, key) = did_key(1);
let mut body = SealedRecordBody::new(at("k1", 3), &other);
body.body = Some("x".into());
assert!(matches!(body.sign(&key).await, Err(BodyError::Signing(_))));
let mut value = serde_json::to_value(&body).unwrap();
sign_object_for_assertion(&mut value, &key).await.unwrap();
let err = verify_record_body(
&serde_json::to_vec(&value).unwrap(),
at("k1", 3),
None,
&resolver(),
)
.await
.expect_err("wrong author");
assert!(matches!(err, BodyError::Signature(_)), "{err}");
}
#[tokio::test]
async fn on_an_attributed_room_the_host_recorded_author_must_match() {
let (_, bytes) = signed_by(1, at("k1", 3)).await;
let (someone_else, _) = did_key(2);
let err = verify_record_body(&bytes, at("k1", 3), Some(&someone_else), &resolver())
.await
.expect_err("author mismatch");
assert!(matches!(err, BodyError::AuthorMismatch { .. }), "{err}");
}
#[tokio::test]
async fn an_unsigned_or_unknown_member_body_is_refused() {
let (did, _) = did_key(1);
let unsigned = serde_json::to_vec(&SealedRecordBody::new(at("k1", 3), &did)).unwrap();
assert!(matches!(
verify_record_body(&unsigned, at("k1", 3), None, &resolver()).await,
Err(BodyError::Signature(_))
));
let (_, key) = did_key(1);
let mut value = serde_json::to_value(SealedRecordBody::new(at("k1", 3), &did)).unwrap();
value["comment"] = "smuggled".into();
sign_object_for_assertion(&mut value, &key).await.unwrap();
assert!(matches!(
verify_record_body(
&serde_json::to_vec(&value).unwrap(),
at("k1", 3),
None,
&resolver()
)
.await,
Err(BodyError::Malformed(_))
));
}
#[tokio::test]
async fn the_schema_bounds_hold_on_signing() {
let (did, key) = did_key(1);
let mut body = SealedRecordBody::new(at("k1", 3), &did);
body.title = Some("t".repeat(MAX_TITLE + 1));
assert!(matches!(
body.sign(&key).await,
Err(BodyError::Malformed(_))
));
let body = SealedRecordBody::new(at("k1", 0), &did);
assert!(matches!(
body.sign(&key).await,
Err(BodyError::Malformed(_))
));
}
}