Skip to main content

aven_protocol/
claim.rs

1//! Sequence-zero membership and one-vault claim, without transport.
2//!
3//! The fixed genesis profile uses strict Ed25519 and an HPKE base-mode self grant.
4//! Operator setup authority, not the self signature, admits the first device.
5//! Public validation cannot prove the encrypted self grant is usable. Hosts must
6//! verify it and persist exact authority outside replaceable databases before use.
7//! A fixed signed publication successor preserves genesis authority. Neither
8//! preparation nor server acceptance enables client sync or advances a local
9//! protected checkpoint.
10
11use std::fmt;
12
13use anyhow::{Context, Result, ensure};
14use chacha20::ChaCha20Rng;
15use ed25519_dalek::{Signer, SigningKey};
16use hpke::{Deserializable, OpModeR, OpModeS, Serializable};
17use rand_core::SeedableRng;
18use subtle::ConstantTimeEq;
19use zeroize::Zeroizing;
20
21use crate::context::{LocalSharedStatePackageContext, LocalSharedStatePackageKey};
22pub mod codec;
23
24use codec::{MEMBERSHIP_SIGN, Reader, bytes, cce, hash, membership_core, valid};
25
26type Kem = hpke::kem::X25519HkdfSha256;
27type Aead = hpke::aead::ChaCha20Poly1305;
28type Kdf = hpke::kdf::HkdfSha256;
29type HpkePrivate = <Kem as hpke::Kem>::PrivateKey;
30type Enc = <Kem as hpke::Kem>::EncappedKey;
31
32pub const GENESIS_BYTES: usize = 902;
33pub const CLAIM_BYTES: usize = 912;
34/// Signing seed, HPKE private key, token and exact public genesis. Host-only storage.
35pub const SEED_STORAGE_BYTES: usize = 96 + GENESIS_BYTES;
36
37/// A high-entropy secret with redacted diagnostics. Never serialize into task data.
38pub struct Secret(Zeroizing<[u8; 32]>);
39
40impl Secret {
41    pub fn new(bytes: [u8; 32]) -> Self {
42        Self(Zeroizing::new(bytes))
43    }
44
45    pub fn generate() -> Result<Self> {
46        let mut bytes = Zeroizing::new([0; 32]);
47        getrandom::fill(bytes.as_mut()).context("error seed-claim-entropy-unavailable")?;
48        Ok(Self(bytes))
49    }
50
51    /// For protected persistence or authenticated transport only, never logs.
52    pub fn expose(&self) -> &[u8; 32] {
53        &self.0
54    }
55}
56
57impl fmt::Debug for Secret {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str("Secret([REDACTED])")
60    }
61}
62
63/// Immutable, publicly verified sequence-zero record. Not proof of admission.
64#[derive(Clone, PartialEq, Eq)]
65pub struct Genesis {
66    record: [u8; GENESIS_BYTES],
67    context: LocalSharedStatePackageContext,
68    setup: [u8; 32],
69    claim: [u8; 32],
70    device: [u8; 32],
71    signing_public: [u8; 32],
72    hpke_public: [u8; 32],
73    verifier: [u8; 32],
74    generation_commitment: [u8; 32],
75}
76
77impl fmt::Debug for Genesis {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.debug_struct("Genesis")
80            .field("commitment", &self.commitment())
81            .finish()
82    }
83}
84
85impl Genesis {
86    pub fn from_claim(bytes: &[u8]) -> Result<Self> {
87        Self::from_record(codec::claim_record(bytes)?)
88    }
89
90    pub fn from_record(record: &[u8]) -> Result<Self> {
91        codec::parse(record)
92    }
93
94    pub fn record(&self) -> &[u8; GENESIS_BYTES] {
95        &self.record
96    }
97
98    pub fn commitment(&self) -> [u8; 32] {
99        hash(&self.record)
100    }
101
102    pub fn authorizes_bearer(&self, token: &Secret) -> bool {
103        bool::from(
104            credential_verifier(self.context.vault_id, self.device, token).ct_eq(&self.verifier),
105        )
106    }
107
108    pub fn context(&self) -> LocalSharedStatePackageContext {
109        self.context
110    }
111
112    pub fn setup_id(&self) -> [u8; 32] {
113        self.setup
114    }
115
116    pub fn claim_id(&self) -> [u8; 32] {
117        self.claim
118    }
119
120    pub fn device_id(&self) -> [u8; 32] {
121        self.device
122    }
123
124    pub fn signing_public(&self) -> [u8; 32] {
125        self.signing_public
126    }
127
128    pub fn hpke_public(&self) -> [u8; 32] {
129        self.hpke_public
130    }
131
132    pub fn verifier(&self) -> [u8; 32] {
133        self.verifier
134    }
135
136    pub fn generation_commitment(&self) -> [u8; 32] {
137        self.generation_commitment
138    }
139
140    pub fn claim_bytes(&self) -> Vec<u8> {
141        let mut out = b"AVCL\0\x01".to_vec();
142        bytes(&mut out, &self.record);
143        out
144    }
145
146    fn info(&self) -> Vec<u8> {
147        cce(
148            "aven-e2ee/v1/genesis/self",
149            &[
150                &1_u16.to_be_bytes(),
151                &[1],
152                &self.context.vault_id,
153                &self.setup,
154                &self.claim,
155                &self.device,
156                &self.hpke_public,
157                &self.context.generation_id,
158            ],
159        )
160    }
161
162    fn state(&self) -> Vec<u8> {
163        let mut out = b"AVGS\0\x01\x01".to_vec();
164        out.extend(self.context.vault_id);
165        out.extend([0, 0, 0, 1]);
166        out.extend(self.device);
167        out.extend(self.signing_public);
168        out.extend(self.hpke_public);
169        out.extend(self.verifier);
170        out.extend(1_u32.to_be_bytes());
171        out.extend(self.claim);
172        out.push(1);
173        out.extend(self.context.generation_id);
174        out.extend(self.generation_commitment);
175        out.extend(0_u64.to_be_bytes());
176        out
177    }
178
179    fn core(&self, state: &[u8]) -> Vec<u8> {
180        let mut body = b"AVGC\0\x01".to_vec();
181        body.extend(self.setup);
182        body.extend(self.claim);
183        membership_core(
184            &self.context.vault_id,
185            0,
186            &[0; 32],
187            &self.device,
188            1,
189            &body,
190            state,
191        )
192    }
193}
194
195/// Private seed material and pinned exact genesis, validated against package authority.
196/// Construction alone is not durable. The host must save before making a request.
197pub struct SeedAuthority {
198    signing: Secret,
199    recipient: Secret,
200    bearer: Secret,
201    genesis: Genesis,
202}
203
204impl fmt::Debug for SeedAuthority {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str("SeedAuthority([REDACTED])")
207    }
208}
209
210impl SeedAuthority {
211    pub fn generate(
212        context: LocalSharedStatePackageContext,
213        key: &LocalSharedStatePackageKey,
214        setup: [u8; 32],
215    ) -> Result<Self> {
216        let signing = Secret::generate()?;
217        let ikm = Secret::generate()?;
218        let (recipient, _) = <Kem as hpke::Kem>::derive_keypair(ikm.expose());
219        let bearer = Secret::generate()?;
220        let claim = *Secret::generate()?.expose();
221        let device = *Secret::generate()?.expose();
222        let entropy = Secret::generate()?;
223        let mut rng = ChaCha20Rng::from_seed(*entropy.expose());
224        Self::build(
225            context, key, setup, claim, device, signing, recipient, bearer, &mut rng,
226        )
227    }
228
229    #[allow(clippy::too_many_arguments)]
230    fn build(
231        context: LocalSharedStatePackageContext,
232        key: &LocalSharedStatePackageKey,
233        setup: [u8; 32],
234        claim: [u8; 32],
235        device: [u8; 32],
236        signing: Secret,
237        recipient: HpkePrivate,
238        bearer: Secret,
239        rng: &mut impl rand_core::CryptoRng,
240    ) -> Result<Self> {
241        let signer = SigningKey::from_bytes(signing.expose());
242        let hpke_public = <Kem as hpke::Kem>::sk_to_pk(&recipient);
243        let mut genesis = Genesis {
244            record: [0; GENESIS_BYTES],
245            context,
246            setup,
247            claim,
248            device,
249            signing_public: signer.verifying_key().to_bytes(),
250            hpke_public: hpke_public.to_bytes().into(),
251            verifier: credential_verifier(context.vault_id, device, &bearer),
252            generation_commitment: generation_commitment(context, key.protected_storage_bytes()),
253        };
254        let state = genesis.state();
255        let core = genesis.core(&state);
256        let plaintext = self_plaintext(&genesis, key.protected_storage_bytes());
257        let (enc, ciphertext) = hpke::single_shot_seal_with_rng::<Aead, Kdf, Kem>(
258            &OpModeS::Base,
259            &hpke_public,
260            &genesis.info(),
261            &plaintext,
262            &core,
263            rng,
264        )
265        .map_err(|_| anyhow::anyhow!("error seed-claim-self-seal"))?;
266        let mut attachments = b"AVGA\0\x01\x01\x01\x01".to_vec();
267        bytes(&mut attachments, &device);
268        bytes(&mut attachments, &genesis.hpke_public);
269        bytes(&mut attachments, &enc.to_bytes());
270        bytes(&mut attachments, &ciphertext);
271        let signature = signer.sign(&cce(MEMBERSHIP_SIGN, &[&core, &attachments]));
272        let mut record = vec![1];
273        for component in [&core[..], &state, &attachments, &signature.to_bytes()] {
274            bytes(&mut record, component);
275        }
276        genesis = Genesis::from_record(&record)?;
277        let authority = Self {
278            signing,
279            recipient: Secret::new(recipient.to_bytes().into()),
280            bearer,
281            genesis,
282        };
283        authority.validate(context, key)?;
284        Ok(authority)
285    }
286
287    pub fn genesis(&self) -> &Genesis {
288        &self.genesis
289    }
290
291    /// Borrows signing material only for authenticated device operations or protected storage.
292    /// Never place these bytes in logs, task data or ordinary backups.
293    pub fn protected_signing_seed(&self) -> &Secret {
294        &self.signing
295    }
296
297    /// Borrows recipient material only for authenticated device operations or protected storage.
298    /// Never place these bytes in logs, task data or ordinary backups.
299    pub fn protected_recipient_key(&self) -> &Secret {
300        &self.recipient
301    }
302
303    pub fn bearer(&self) -> &Secret {
304        &self.bearer
305    }
306
307    /// Exact protected-storage representation, never SQLite or ordinary backups.
308    pub fn protected_storage_bytes(&self) -> Zeroizing<Vec<u8>> {
309        let mut out = Zeroizing::new(Vec::with_capacity(SEED_STORAGE_BYTES));
310        out.extend_from_slice(self.signing.expose());
311        out.extend_from_slice(self.recipient.expose());
312        out.extend_from_slice(self.bearer.expose());
313        out.extend_from_slice(self.genesis.record());
314        out
315    }
316
317    pub fn from_protected_storage(
318        raw: &[u8],
319        context: LocalSharedStatePackageContext,
320        key: &LocalSharedStatePackageKey,
321    ) -> Result<Self> {
322        valid(raw.len() == SEED_STORAGE_BYTES)?;
323        let mut reader = Reader(raw);
324        let signing = Secret::new(reader.array()?);
325        let recipient = Secret::new(reader.array()?);
326        let bearer = Secret::new(reader.array()?);
327        let genesis = Genesis::from_record(reader.take(GENESIS_BYTES)?)?;
328        reader.end()?;
329        let authority = Self {
330            signing,
331            recipient,
332            bearer,
333            genesis,
334        };
335        authority.validate(context, key)?;
336        Ok(authority)
337    }
338
339    pub fn validate(
340        &self,
341        context: LocalSharedStatePackageContext,
342        key: &LocalSharedStatePackageKey,
343    ) -> Result<()> {
344        let g = &self.genesis;
345        valid(g.context == context)?;
346        valid(
347            SigningKey::from_bytes(self.signing.expose())
348                .verifying_key()
349                .to_bytes()
350                == g.signing_public,
351        )?;
352        valid(bool::from(
353            credential_verifier(context.vault_id, g.device, &self.bearer).ct_eq(&g.verifier),
354        ))?;
355        valid(
356            generation_commitment(context, key.protected_storage_bytes())
357                == g.generation_commitment,
358        )?;
359        let private = HpkePrivate::from_bytes(self.recipient.expose())
360            .map_err(|_| anyhow::anyhow!("error seed-claim-recipient"))?;
361        valid(<Kem as hpke::Kem>::sk_to_pk(&private).to_bytes().as_slice() == g.hpke_public)?;
362        let (core, _, attachments, _) = codec::components(&g.record)?;
363        let mut reader = Reader(attachments);
364        reader.take(9)?;
365        reader.blob(32)?;
366        reader.blob(32)?;
367        let enc = Enc::from_bytes(reader.blob(32)?)
368            .map_err(|_| anyhow::anyhow!("error seed-claim-encapsulation"))?;
369        let ciphertext = reader.blob(191)?;
370        reader.end()?;
371        let plaintext = Zeroizing::new(
372            hpke::single_shot_open::<Aead, Kdf, Kem>(
373                &OpModeR::Base,
374                &private,
375                &enc,
376                &g.info(),
377                ciphertext,
378                core,
379            )
380            .map_err(|_| anyhow::anyhow!("error seed-claim-self-open"))?,
381        );
382        valid(bool::from(
383            plaintext
384                .as_slice()
385                .ct_eq(&self_plaintext(g, key.protected_storage_bytes())),
386        ))
387    }
388}
389
390fn self_plaintext(g: &Genesis, secret: &[u8; 32]) -> Zeroizing<Vec<u8>> {
391    let mut out = Zeroizing::new(b"AVGK\0\x01\x01".to_vec());
392    for value in [
393        &g.context.vault_id,
394        &g.claim,
395        &g.device,
396        &g.context.generation_id,
397        secret,
398    ] {
399        out.extend_from_slice(value);
400    }
401    out.extend_from_slice(&0_u64.to_be_bytes());
402    out
403}
404
405pub fn generation_commitment(
406    context: LocalSharedStatePackageContext,
407    secret: &[u8; 32],
408) -> [u8; 32] {
409    let message = Zeroizing::new(cce(
410        "aven-e2ee/v1/generation/commit",
411        &[&context.vault_id, &context.generation_id, secret],
412    ));
413    hash(&message)
414}
415
416pub fn credential_verifier(vault: [u8; 32], device: [u8; 32], token: &Secret) -> [u8; 32] {
417    let message = Zeroizing::new(cce(
418        "aven-e2ee/v1/credential/verifier",
419        &[&vault, &device, &1_u32.to_be_bytes(), token.expose()],
420    ));
421    hash(&message)
422}
423
424/// Operator-configured public verifier. Never accept this value from a claimant.
425#[derive(Clone, Debug)]
426pub struct SetupAuthority {
427    id: [u8; 32],
428    verifier: [u8; 32],
429}
430
431impl SetupAuthority {
432    pub fn from_verifier(id: [u8; 32], verifier: [u8; 32]) -> Self {
433        Self { id, verifier }
434    }
435
436    pub fn id(&self) -> [u8; 32] {
437        self.id
438    }
439
440    pub fn verifier(id: [u8; 32], secret: &Secret) -> [u8; 32] {
441        let message = Zeroizing::new(cce("aven-e2ee/v1/setup/verifier", &[&id, secret.expose()]));
442        hash(&message)
443    }
444
445    pub fn authorizes(&self, id: [u8; 32], secret: &Secret) -> bool {
446        self.id == id && bool::from(Self::verifier(id, secret).ct_eq(&self.verifier))
447    }
448}
449
450#[derive(Debug)]
451pub enum ClaimAuthentication<'a> {
452    SetupSecret(&'a Secret),
453    SeedBearer(&'a Secret),
454}
455
456/// A claim refusal decided inside the claim transaction. Any other claim
457/// error leaves the outcome unknown to the claimant.
458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
459pub enum ClaimRefusal {
460    /// The credential does not authorize this claim; `claimed` records
461    /// whether storage already held a claim.
462    Unauthorized { claimed: bool },
463    /// Unclaimed storage whose setup verifier expired, refusing the exact
464    /// secret that verifier was issued for.
465    Expired,
466    /// Storage holds a different claim.
467    Conflict,
468    /// The stored claim was retired by publication.
469    Retired,
470}
471
472impl fmt::Display for ClaimRefusal {
473    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474        f.write_str(match self {
475            Self::Unauthorized { .. } => "error seed-claim-unauthorized",
476            Self::Expired => "error seed-claim-setup-expired",
477            Self::Conflict => "error seed-claim-conflict",
478            Self::Retired => "error seed-claim-retired",
479        })
480    }
481}
482
483impl std::error::Error for ClaimRefusal {}
484
485/// Server storage must not contain local domain data or sync history.
486#[derive(Debug)]
487pub struct StorageNotEmpty;
488
489impl fmt::Display for StorageNotEmpty {
490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491        f.write_str("error e2ee-server-storage-not-empty")
492    }
493}
494
495impl std::error::Error for StorageNotEmpty {}
496
497/// Equality result, not proof of READY, active membership or physical durability.
498#[derive(Clone, Debug, PartialEq, Eq)]
499pub struct ClaimResult {
500    pub vault_id: [u8; 32],
501    pub claim_id: [u8; 32],
502    pub genesis_commitment: [u8; 32],
503}
504
505impl ClaimResult {
506    pub fn from_genesis(g: &Genesis) -> Self {
507        Self {
508            vault_id: g.context.vault_id,
509            claim_id: g.claim,
510            genesis_commitment: g.commitment(),
511        }
512    }
513
514    pub fn validate_pinned(&self, pinned: &Genesis) -> Result<()> {
515        ensure!(
516            *self == Self::from_genesis(pinned),
517            "error seed-claim-result-mismatch"
518        );
519        Ok(())
520    }
521}
522
523#[cfg(test)]
524mod tests;
525
526pub mod membership;
527pub mod peer;
528pub mod publication;
529pub use publication::{PUBLICATION_BYTES, Publication, PublicationBinding, PublicationOutcome};