Skip to main content

crypto/
thread_ownership_resolution.rs

1//! The immutable local owner chooses a surviving claim and the recipient
2//! independently accepts it with current account authority. Admission checks
3//! that authority and the complete stored conflict/frontier separately.
4use heddle_object_model::object::thread_replication::{
5    ownership_claim::ThreadOwnershipClaim,
6    ownership_resolution::{FORMAT, ThreadOwnershipResolution},
7};
8use serde::{Deserialize, Serialize};
9
10use crate::{Ed25519Signer, Signer, thread_operation::Error};
11
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SignedOwnershipResolution {
14    pub canonical: Vec<u8>,
15    pub local_signature: Vec<u8>,
16    pub acceptance_signature: Vec<u8>,
17}
18impl SignedOwnershipResolution {
19    pub fn sign(
20        value: &ThreadOwnershipResolution,
21        local_owner: &impl Signer,
22        acceptor: &impl Signer,
23    ) -> Result<Self, Error> {
24        if local_owner.public_key() != value.local_owner
25            || acceptor.public_key() != value.accepting_publisher
26        {
27            return Err(Error::Publisher);
28        }
29        let canonical = value.encode()?;
30        let local_signature = local_owner.sign(&signing_bytes(&canonical))?;
31        let acceptance_signature = acceptor.sign(&signing_bytes(&canonical))?;
32        Ok(Self {
33            canonical,
34            local_signature,
35            acceptance_signature,
36        })
37    }
38    /// This proves both signatures and the selected claim's exact identity.
39    /// Repository admission binds genesis, stored claims, frontier and current
40    /// recipient capability; none may be inferred from these signatures alone.
41    pub fn verify(
42        &self,
43        winning_claim: &ThreadOwnershipClaim,
44    ) -> Result<ThreadOwnershipResolution, Error> {
45        let value = ThreadOwnershipResolution::decode(&self.canonical)?;
46        if winning_claim.id()? != value.winning_claim
47            || winning_claim.prior_local_key != value.local_owner
48            || winning_claim.thread != value.thread
49            || winning_claim.account()? != value.account()?
50        {
51            return Err(Error::Publisher);
52        }
53        Ed25519Signer::verify_with_public_key(
54            &signing_bytes(&self.canonical),
55            &value.local_owner,
56            &self.local_signature,
57        )?;
58        Ed25519Signer::verify_with_public_key(
59            &signing_bytes(&self.canonical),
60            &value.accepting_publisher,
61            &self.acceptance_signature,
62        )?;
63        Ok(value)
64    }
65}
66pub fn signing_bytes(canonical: &[u8]) -> Vec<u8> {
67    let mut bytes = FORMAT.as_bytes().to_vec();
68    bytes.push(0);
69    bytes.extend_from_slice(canonical);
70    bytes
71}
72
73#[cfg(test)]
74mod tests {
75    use heddle_object_model::object::{
76        CollaborationActor, ContentHash,
77        thread_replication::{SourceAuthor, ownership_claim::ThreadOwnershipClaim},
78    };
79
80    use super::*;
81
82    fn fixture() -> (
83        ThreadOwnershipClaim,
84        ThreadOwnershipResolution,
85        Ed25519Signer,
86        Ed25519Signer,
87    ) {
88        let local = Ed25519Signer::from_seed(&[41; 32]).expect("local key");
89        let acceptor = Ed25519Signer::from_seed(&[42; 32]).expect("recipient key");
90        let spool = "00000000-0000-0000-0000-000000000022"
91            .parse()
92            .expect("UUID");
93        let acceptance = SourceAuthor::account(
94            spool,
95            CollaborationActor {
96                principal_id: "00000000-0000-0000-0000-000000000023"
97                    .parse()
98                    .expect("UUID"),
99                agent_id: None,
100            },
101            vec![46; 32],
102        )
103        .expect("account acceptance");
104        let claim = ThreadOwnershipClaim {
105            version: 1,
106            thread: ContentHash::from_bytes([43; 32]),
107            prior_local_key: local.public_key().try_into().expect("key"),
108            accepting_publisher: acceptor.public_key().try_into().expect("key"),
109            acceptance: acceptance.clone(),
110            source_frontier: [ContentHash::from_bytes([47; 32])].into(),
111        };
112        let winning_claim = claim.id().expect("claim id");
113        let resolution = ThreadOwnershipResolution {
114            version: 1,
115            spool,
116            thread: claim.thread,
117            winning_claim,
118            conflicting_claims: [winning_claim, ContentHash::from_bytes([48; 32])].into(),
119            frontier: [ContentHash::from_bytes([49; 32])].into(),
120            local_owner: local.public_key().try_into().expect("key"),
121            accepting_publisher: acceptor.public_key().try_into().expect("key"),
122            acceptance,
123            occurred_at_ms: 1,
124        };
125        (claim, resolution, local, acceptor)
126    }
127
128    #[test]
129    fn original_owner_and_fresh_recipient_must_both_sign_exact_resolution() {
130        let (claim, resolution, local, acceptor) = fixture();
131        let signed = SignedOwnershipResolution::sign(&resolution, &local, &acceptor)
132            .expect("both signatures");
133        assert_eq!(signed.verify(&claim).expect("verified"), resolution);
134        let mut missing_local = signed.clone();
135        missing_local.local_signature = vec![0; 64];
136        assert!(missing_local.verify(&claim).is_err());
137        let mut missing_acceptance = signed.clone();
138        missing_acceptance.acceptance_signature = vec![0; 64];
139        assert!(missing_acceptance.verify(&claim).is_err());
140        let mut changed_choice = signed.clone();
141        changed_choice.canonical = ThreadOwnershipResolution {
142            winning_claim: ContentHash::from_bytes([48; 32]),
143            ..resolution.clone()
144        }
145        .encode()
146        .expect("changed choice");
147        assert!(changed_choice.verify(&claim).is_err());
148        assert!(
149            Ed25519Signer::verify_with_public_key(
150                &signed.canonical,
151                &resolution.local_owner,
152                &signed.local_signature,
153            )
154            .is_err(),
155            "domain separation"
156        );
157    }
158
159    #[test]
160    fn recipient_key_is_independent_of_historical_claim_publisher() {
161        let (mut claim, mut resolution, local, _) = fixture();
162        let current_recipient = Ed25519Signer::from_seed(&[99; 32]).expect("current recipient");
163        claim.accepting_publisher = [42; 32];
164        resolution.winning_claim = claim.id().expect("changed claim");
165        resolution.conflicting_claims =
166            [resolution.winning_claim, ContentHash::from_bytes([48; 32])].into();
167        resolution.accepting_publisher = current_recipient.public_key().try_into().expect("key");
168        let signed = SignedOwnershipResolution::sign(&resolution, &local, &current_recipient)
169            .expect("current acceptance");
170        assert_eq!(signed.verify(&claim).expect("independent keys"), resolution);
171    }
172}