Skip to main content

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