Skip to main content

car_sync/
org_key_directory.rs

1//! Org-key directory — the publish/fetch surface for the client-side org-key
2//! agreement (the follow-up named in `crypto.rs` alongside the merged
3//! `wrap_org_key`/`unwrap_org_key` primitives).
4//!
5//! ## What this carries — and why it is NOT the oplog
6//!
7//! The org master key `K_org` is shared across an org's members by *wrapping*
8//! it once per member ([`crate::crypto::wrap_org_key`], ECIES over X25519) so
9//! every member can recover the same key while the relay only ever sees
10//! ciphertext. To distribute those wraps, members need a place to **publish**
11//! their [`WrappedOrgKey`] blobs and their X25519 **public keys**, and to
12//! **fetch** the ones addressed to them.
13//!
14//! That surface is a **key-value directory**, not an append-only op stream:
15//!
16//! - a wrapped blob is keyed by `(epoch, recipient_user_id)` within one org;
17//! - a member public key is keyed by `account_id`;
18//! - there is no sequence chain, no frontier, no GC horizon, no checkpoint
19//!   dominance — the invariants that define [`crate::relay::Relay`].
20//!
21//! So it is deliberately a **separate trait**, not four more methods bolted
22//! onto `Relay`/`SyncTransport`. Bolting KV semantics onto the oplog state
23//! machine would let a blob be counted in a device frontier or swept by a GC
24//! pass that mistook it for a stale op. Routing org-key traffic around the
25//! oplog keeps both models honest. (This mirrors the crate's own idiom: a
26//! [`OrgKeyDirectory`] trait over a pure [`OrgKeyDirectoryState`] machine,
27//! driven by both an in-memory and an fs-backed reference — exactly as
28//! `Relay` is driven by `InMemoryRelay` + `FsRelay`.)
29//!
30//! ## Scope, and what this slice is (and is not)
31//!
32//! Like [`crate::relay::Relay`], the trait here is **single-directory**: one
33//! handle serves one org's directory (an [`FsOrgKeyDirectory`] is bound to one
34//! dir, just as an `FsRelay` is bound to one scope dir). The scope-keyed,
35//! network-faithful form (the `org:<id>` param, mirroring
36//! [`crate::net_relay::SyncTransport`]) and the Parslee/m365 backend come in a
37//! later slice — this one is the pure reference: no prod caller dispatches it,
38//! no `SyncTransport`/`car-parslee` change is forced, and it stays inert until
39//! the org-scope path is wired behind the cryptographer-audit gate.
40//!
41//! ## Trust boundary — publisher authz is CONFIDENTIALITY-critical, not availability
42//!
43//! This reference is a **dumb store**: `publish_*` is last-write-wins and does
44//! NOT authenticate the publisher. That is the right shape for a pure state
45//! machine — but do not mistake it for "just a DoS surface." The authz the
46//! backend slice must add ("only the account itself may publish its own pubkey,
47//! and only a legitimate `K_org` holder may publish a wrap") is a
48//! **confidentiality** control. Unauthenticated publish enables two attacks,
49//! both strictly worse than denial of service:
50//!
51//! - **Wrap-table poisoning → key substitution.** A member's X25519 public key
52//!   is public (it is served from THIS directory). Anyone can therefore wrap an
53//!   attacker-chosen `K_org'` against a victim's real pubkey and publish it;
54//!   the victim's [`crate::crypto::unwrap_org_key`] *authenticates* it (the DH
55//!   matches the victim's secret, the bound `user_id` is the victim's), so the
56//!   victim adopts the attacker's key and encrypts future org data under it.
57//! - **Pubkey-table poisoning → genuine `K_org` leak.** Overwrite a victim's
58//!   pubkey entry with the attacker's own pubkey; a legitimate holder of
59//!   `K_org` then wraps the REAL `K_org` against that attacker pubkey (it
60//!   believes it is the victim's), and the attacker unwraps it with their own
61//!   secret and the victim's `user_id` — recovering the real org key.
62//!
63//! Neither attack is stopped by the AEAD / contributory-DH checks in
64//! `unwrap_org_key`: those only prove "some wrapper used my `user_id` and a
65//! pubkey matching my secret," and both inputs are public. The `recipient`
66//! field is likewise ADVISORY (see [`crate::crypto::WrappedOrgKey`]) and must
67//! not be routed or authorized on. So the backend MUST authenticate the
68//! publisher of every `publish_wrapped` / `publish_pubkey`; treating that as
69//! optional hardening is a key-compromise bug, not a UX one. The one thing the
70//! wrap ciphertext itself never leaks is `K_org` to a passive relay — but the
71//! pubkey-poisoning path above leaks it to an *active* publisher, which is why
72//! publish authz cannot be deferred as availability-only.
73
74use crate::crypto::WrappedOrgKey;
75use serde::{Deserialize, Serialize};
76use std::collections::BTreeMap;
77use std::fmt;
78use std::fs::{self, File, OpenOptions};
79use std::io::Write;
80use std::path::{Path, PathBuf};
81
82/// A member's published X25519 identity public key (the recipient key
83/// [`crate::crypto::wrap_org_key`] wraps against), addressed by `account_id`.
84/// `public_hex` is the lowercase hex of the 32-byte X25519 point produced by
85/// [`crate::crypto::x25519_public`]; the directory stores it opaquely and does
86/// not validate the point (validation happens at wrap/unwrap time).
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct MemberPublicKey {
89    pub account_id: String,
90    pub public_hex: String,
91}
92
93/// Failures publishing to / fetching from an org-key directory.
94#[derive(Debug)]
95pub enum OrgKeyDirectoryError {
96    /// Persistence I/O (fs-backed directory only).
97    Io(std::io::Error),
98    /// The publisher is not authorized for this directory / key. This is a
99    /// LOAD-BEARING, distinct signal — NOT a transient failure to retry.
100    /// Because publisher authz here is confidentiality-critical (see the module
101    /// doc: unauthenticated publish enables key substitution + `K_org` leak),
102    /// the transport-backed directory must surface a backend authz refusal as
103    /// THIS variant, never collapse it into `Io` where a retry loop would treat
104    /// a security "no" as a network blip.
105    Unauthorized(String),
106}
107
108impl fmt::Display for OrgKeyDirectoryError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            OrgKeyDirectoryError::Io(e) => write!(f, "org-key directory io error: {e}"),
112            OrgKeyDirectoryError::Unauthorized(m) => {
113                write!(f, "org-key directory unauthorized: {m}")
114            }
115        }
116    }
117}
118
119impl std::error::Error for OrgKeyDirectoryError {
120    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
121        match self {
122            OrgKeyDirectoryError::Io(e) => Some(e),
123            OrgKeyDirectoryError::Unauthorized(_) => None,
124        }
125    }
126}
127
128/// The publish/fetch surface for one org's directory.
129///
130/// Single-directory (no `org` param) — one handle serves one org, mirroring
131/// [`crate::relay::Relay`]. `&mut self` because the fs-backed reference takes
132/// an exclusive file lock and persists on write.
133pub trait OrgKeyDirectory {
134    /// Publish (or replace) the wrap for `(wrapped.epoch, wrapped.recipient)`.
135    /// Last-write-wins — re-publishing a fresh wrap of the same `K_org`
136    /// (a new ephemeral each time) for a recipient is expected and benign.
137    fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) -> Result<(), OrgKeyDirectoryError>;
138
139    /// Fetch the wrap addressed to `recipient_user_id` at exactly `epoch`, if
140    /// one has been published. Reads are pure (`&self`) — unlike
141    /// [`crate::relay::Relay`], this type has no per-call roster/checkpoint
142    /// reconciliation, so a fetch never rewrites state (the fs reference takes a
143    /// shared lock and returns; no fsync-on-read).
144    fn fetch_wrapped(
145        &self,
146        epoch: u64,
147        recipient_user_id: &str,
148    ) -> Result<Option<WrappedOrgKey>, OrgKeyDirectoryError>;
149
150    /// Every wrap addressed to `recipient_user_id`, newest epoch first — the
151    /// client picks the highest epoch it can [`crate::crypto::unwrap_org_key`].
152    fn fetch_wrapped_for(
153        &self,
154        recipient_user_id: &str,
155    ) -> Result<Vec<WrappedOrgKey>, OrgKeyDirectoryError>;
156
157    /// Publish (or replace) `account_id`'s X25519 public key.
158    fn publish_pubkey(
159        &mut self,
160        account_id: &str,
161        public_hex: &str,
162    ) -> Result<(), OrgKeyDirectoryError>;
163
164    /// Every member public key in this directory, ordered by `account_id`.
165    fn fetch_pubkeys(&self) -> Result<Vec<MemberPublicKey>, OrgKeyDirectoryError>;
166}
167
168/// The pure directory state — the same "trait over a pure state machine"
169/// shape as `crate::relay::RelayState`. Both references drive this; it holds
170/// no I/O and is deterministic.
171#[derive(Clone, Debug, Default, Serialize, Deserialize)]
172pub struct OrgKeyDirectoryState {
173    /// `recipient_user_id → (epoch → wrap)`. Nested so a recipient's wraps
174    /// across epochs stay grouped for `fetch_wrapped_for`. `BTreeMap<u64, _>`
175    /// serializes to a JSON object with stringified integer keys (serde_json).
176    ///
177    /// TODO(org-scope): the key is `(recipient, epoch)` only — `wrapped.org` is
178    /// NOT part of it because this handle is single-org by construction (one dir
179    /// = one org, like `FsRelay` = one scope). When the scope-keyed `org:<id>`
180    /// transport form lands, the key must include `org` (or the handle must
181    /// carry + verify it) or two orgs sharing a `(recipient, epoch)` in one
182    /// store would collide.
183    #[serde(default)]
184    wrapped: BTreeMap<String, BTreeMap<u64, WrappedOrgKey>>,
185    /// `account_id → X25519 public key hex`.
186    #[serde(default)]
187    pubkeys: BTreeMap<String, String>,
188}
189
190impl OrgKeyDirectoryState {
191    fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) {
192        self.wrapped
193            .entry(wrapped.recipient.clone())
194            .or_default()
195            .insert(wrapped.epoch, wrapped.clone());
196    }
197
198    fn fetch_wrapped(&self, epoch: u64, recipient_user_id: &str) -> Option<WrappedOrgKey> {
199        self.wrapped
200            .get(recipient_user_id)
201            .and_then(|by_epoch| by_epoch.get(&epoch))
202            .cloned()
203    }
204
205    fn fetch_wrapped_for(&self, recipient_user_id: &str) -> Vec<WrappedOrgKey> {
206        self.wrapped
207            .get(recipient_user_id)
208            .map(|by_epoch| by_epoch.values().rev().cloned().collect())
209            .unwrap_or_default()
210    }
211
212    fn publish_pubkey(&mut self, account_id: &str, public_hex: &str) {
213        self.pubkeys
214            .insert(account_id.to_string(), public_hex.to_string());
215    }
216
217    fn fetch_pubkeys(&self) -> Vec<MemberPublicKey> {
218        self.pubkeys
219            .iter()
220            .map(|(account_id, public_hex)| MemberPublicKey {
221                account_id: account_id.clone(),
222                public_hex: public_hex.clone(),
223            })
224            .collect()
225    }
226}
227
228/// In-memory reference — tests and in-process coordination (the
229/// [`crate::relay::InMemoryRelay`] analogue).
230#[derive(Clone, Debug, Default)]
231pub struct InMemoryOrgKeyDirectory {
232    state: OrgKeyDirectoryState,
233}
234
235impl InMemoryOrgKeyDirectory {
236    pub fn new() -> Self {
237        Self::default()
238    }
239}
240
241impl OrgKeyDirectory for InMemoryOrgKeyDirectory {
242    fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) -> Result<(), OrgKeyDirectoryError> {
243        self.state.publish_wrapped(wrapped);
244        Ok(())
245    }
246
247    fn fetch_wrapped(
248        &self,
249        epoch: u64,
250        recipient_user_id: &str,
251    ) -> Result<Option<WrappedOrgKey>, OrgKeyDirectoryError> {
252        Ok(self.state.fetch_wrapped(epoch, recipient_user_id))
253    }
254
255    fn fetch_wrapped_for(
256        &self,
257        recipient_user_id: &str,
258    ) -> Result<Vec<WrappedOrgKey>, OrgKeyDirectoryError> {
259        Ok(self.state.fetch_wrapped_for(recipient_user_id))
260    }
261
262    fn publish_pubkey(
263        &mut self,
264        account_id: &str,
265        public_hex: &str,
266    ) -> Result<(), OrgKeyDirectoryError> {
267        self.state.publish_pubkey(account_id, public_hex);
268        Ok(())
269    }
270
271    fn fetch_pubkeys(&self) -> Result<Vec<MemberPublicKey>, OrgKeyDirectoryError> {
272        Ok(self.state.fetch_pubkeys())
273    }
274}
275
276/// Fs-backed reference — one directory of one org's wraps + member pubkeys,
277/// the [`crate::relay::FsRelay`] analogue (the realistic single-host / shared
278/// dir). Every mutation runs under an exclusive lock file and persists the
279/// whole state via temp-write + fsync + atomic rename, matching `FsRelay`.
280#[derive(Debug)]
281pub struct FsOrgKeyDirectory {
282    dir: PathBuf,
283}
284
285impl FsOrgKeyDirectory {
286    pub fn open(dir: &Path) -> std::io::Result<Self> {
287        fs::create_dir_all(dir)?;
288        Ok(Self {
289            dir: dir.to_path_buf(),
290        })
291    }
292
293    fn state_path(&self) -> PathBuf {
294        self.dir.join("org-keys.json")
295    }
296
297    fn lock_path(&self) -> PathBuf {
298        self.dir.join("org-keys.lock")
299    }
300
301    /// Deserialize the state file; a missing file is an empty directory.
302    fn read_state(path: &Path) -> Result<OrgKeyDirectoryState, OrgKeyDirectoryError> {
303        match fs::read_to_string(path) {
304            Ok(raw) => serde_json::from_str(&raw)
305                .map_err(|e| OrgKeyDirectoryError::Io(std::io::Error::other(e))),
306            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
307                Ok(OrgKeyDirectoryState::default())
308            }
309            Err(e) => Err(OrgKeyDirectoryError::Io(e)),
310        }
311    }
312
313    /// Load state under a SHARED lock — a pure read that never rewrites the
314    /// file. Unlike [`crate::relay::FsRelay`], which rewrites on every call to
315    /// run roster liveness + checkpoint reconciliation, a directory fetch has
316    /// no such side work, so reads take a shared lock (concurrent readers) and
317    /// return — no fsync-on-read, and `fetch_*` stay `&self`.
318    fn load(&self) -> Result<OrgKeyDirectoryState, OrgKeyDirectoryError> {
319        let lock = OpenOptions::new()
320            .read(true)
321            .write(true)
322            .create(true)
323            .truncate(false)
324            .open(self.lock_path())
325            .map_err(OrgKeyDirectoryError::Io)?;
326        lock.lock_shared().map_err(OrgKeyDirectoryError::Io)?; // shared; released on drop
327        Self::read_state(&self.state_path())
328    }
329
330    /// Run `f` over the loaded state under the EXCLUSIVE lock, then persist —
331    /// same durable-write shape as [`crate::relay::FsRelay::with_state`].
332    fn with_state<T>(
333        &mut self,
334        f: impl FnOnce(&mut OrgKeyDirectoryState) -> T,
335    ) -> Result<T, OrgKeyDirectoryError> {
336        let lock = OpenOptions::new()
337            .read(true)
338            .write(true)
339            .create(true)
340            .truncate(false)
341            .open(self.lock_path())
342            .map_err(OrgKeyDirectoryError::Io)?;
343        lock.lock().map_err(OrgKeyDirectoryError::Io)?; // exclusive; released on drop
344
345        let mut state = Self::read_state(&self.state_path())?;
346
347        let result = f(&mut state);
348
349        let tmp = self.dir.join("org-keys.json.tmp");
350        {
351            let mut file = File::create(&tmp).map_err(OrgKeyDirectoryError::Io)?;
352            file.write_all(
353                serde_json::to_string(&state)
354                    .map_err(|e| OrgKeyDirectoryError::Io(std::io::Error::other(e)))?
355                    .as_bytes(),
356            )
357            .map_err(OrgKeyDirectoryError::Io)?;
358            file.sync_all().map_err(OrgKeyDirectoryError::Io)?;
359        }
360        fs::rename(&tmp, self.state_path()).map_err(OrgKeyDirectoryError::Io)?;
361        Ok(result)
362    }
363}
364
365impl OrgKeyDirectory for FsOrgKeyDirectory {
366    fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) -> Result<(), OrgKeyDirectoryError> {
367        self.with_state(|state| state.publish_wrapped(wrapped))
368    }
369
370    fn fetch_wrapped(
371        &self,
372        epoch: u64,
373        recipient_user_id: &str,
374    ) -> Result<Option<WrappedOrgKey>, OrgKeyDirectoryError> {
375        Ok(self.load()?.fetch_wrapped(epoch, recipient_user_id))
376    }
377
378    fn fetch_wrapped_for(
379        &self,
380        recipient_user_id: &str,
381    ) -> Result<Vec<WrappedOrgKey>, OrgKeyDirectoryError> {
382        Ok(self.load()?.fetch_wrapped_for(recipient_user_id))
383    }
384
385    fn publish_pubkey(
386        &mut self,
387        account_id: &str,
388        public_hex: &str,
389    ) -> Result<(), OrgKeyDirectoryError> {
390        self.with_state(|state| state.publish_pubkey(account_id, public_hex))
391    }
392
393    fn fetch_pubkeys(&self) -> Result<Vec<MemberPublicKey>, OrgKeyDirectoryError> {
394        Ok(self.load()?.fetch_pubkeys())
395    }
396}
397
398/// The shared behavioural contract for any [`OrgKeyDirectory`] — kept out of
399/// `mod tests` (as `pub(crate)`) so both the reference tests here AND the
400/// `LoopbackTransport`-backed `NetworkOrgKeyDirectory` tests in
401/// [`crate::net_relay`] drive the *same* sequence, proving the wire form is
402/// identical to these references before a byte crosses into `car-parslee`.
403#[cfg(test)]
404pub(crate) mod conformance {
405    use super::*;
406    use crate::crypto::{
407        derive_ed25519_identity, derive_x25519_identity, wrap_org_key, x25519_public,
408        StretchedMaster,
409    };
410
411    /// A real wrap for `recipient` at `epoch` — exercises the actual crypto (now
412    /// signed by a publisher) so the directory is proven to carry the genuine wire
413    /// type intact, not a hand-built stand-in. Uses issued-high-entropy masters
414    /// (skips Argon2id) so the conformance suite stays fast.
415    pub(crate) fn make_wrap(org: &str, epoch: u64, recipient: &str) -> WrappedOrgKey {
416        let k_org = [7u8; 32];
417        let sk = derive_x25519_identity(
418            &StretchedMaster::from_issued_high_entropy(b"login-secret-for-tests", recipient),
419            recipient,
420        );
421        let recipient_pub = x25519_public(&sk);
422        let signer = derive_ed25519_identity(
423            &StretchedMaster::from_issued_high_entropy(b"granter-login-for-tests", "acc_granter"),
424            "acc_granter",
425        );
426        wrap_org_key(
427            &k_org,
428            org,
429            epoch,
430            recipient,
431            &recipient_pub,
432            "acc_granter",
433            &signer,
434        )
435        .expect("wrap succeeds")
436    }
437
438    /// The behavioural contract every `OrgKeyDirectory` implementation must
439    /// satisfy — run against both references here, and reused verbatim against
440    /// the `LoopbackTransport`-backed directory in the next slice.
441    pub(crate) fn round_trip_suite(dir: &mut dyn OrgKeyDirectory) {
442        // wrapped: composite-key (epoch, recipient) round-trip
443        let w = make_wrap("acme", 1, "alice");
444        dir.publish_wrapped(&w).unwrap();
445        assert_eq!(dir.fetch_wrapped(1, "alice").unwrap().as_ref(), Some(&w));
446        // opaque payload preserved bit-identically (the AEAD envelope survives)
447        let got = dir.fetch_wrapped(1, "alice").unwrap().unwrap();
448        assert_eq!(got.envelope, w.envelope);
449        assert_eq!(got.ephemeral_pub, w.ephemeral_pub);
450        assert_eq!(got.car_wrap, w.car_wrap);
451
452        // isolation: wrong epoch / wrong recipient miss
453        assert!(dir.fetch_wrapped(2, "alice").unwrap().is_none());
454        assert!(dir.fetch_wrapped(1, "bob").unwrap().is_none());
455
456        // multiple epochs for one recipient, newest first
457        let w2 = make_wrap("acme", 5, "alice");
458        let w3 = make_wrap("acme", 3, "alice");
459        dir.publish_wrapped(&w2).unwrap();
460        dir.publish_wrapped(&w3).unwrap();
461        let epochs: Vec<u64> = dir
462            .fetch_wrapped_for("alice")
463            .unwrap()
464            .iter()
465            .map(|w| w.epoch)
466            .collect();
467        assert_eq!(epochs, vec![5, 3, 1], "newest epoch first");
468        assert!(dir.fetch_wrapped_for("nobody").unwrap().is_empty());
469
470        // pubkey round-trip + ordering
471        dir.publish_pubkey("bob", "beef").unwrap();
472        dir.publish_pubkey("alice", "cafe").unwrap();
473        let keys = dir.fetch_pubkeys().unwrap();
474        assert_eq!(
475            keys,
476            vec![
477                MemberPublicKey {
478                    account_id: "alice".into(),
479                    public_hex: "cafe".into()
480                },
481                MemberPublicKey {
482                    account_id: "bob".into(),
483                    public_hex: "beef".into()
484                },
485            ],
486            "ordered by account_id"
487        );
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::conformance::{make_wrap, round_trip_suite};
494    use super::*;
495    use serde_json::json;
496
497    #[test]
498    fn in_memory_round_trip() {
499        let mut dir = InMemoryOrgKeyDirectory::new();
500        round_trip_suite(&mut dir);
501    }
502
503    #[test]
504    fn fs_round_trip() {
505        let tmp = tempfile::tempdir().unwrap();
506        let mut dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
507        round_trip_suite(&mut dir);
508    }
509
510    #[test]
511    fn publish_wrapped_is_last_write_wins() {
512        let mut dir = InMemoryOrgKeyDirectory::new();
513        let mut w = make_wrap("acme", 1, "alice");
514        dir.publish_wrapped(&w).unwrap();
515        // a re-publish at the same (epoch, recipient) replaces, not duplicates
516        w.envelope = json!({"car_enc": "org-key-wrap/v1", "nonce": "00", "ct": "ff"});
517        dir.publish_wrapped(&w).unwrap();
518        assert_eq!(dir.fetch_wrapped_for("alice").unwrap().len(), 1);
519        assert_eq!(
520            dir.fetch_wrapped(1, "alice").unwrap().unwrap().envelope,
521            w.envelope
522        );
523    }
524
525    #[test]
526    fn publish_pubkey_is_last_write_wins() {
527        let mut dir = InMemoryOrgKeyDirectory::new();
528        dir.publish_pubkey("alice", "aaaa").unwrap();
529        dir.publish_pubkey("alice", "bbbb").unwrap();
530        let keys = dir.fetch_pubkeys().unwrap();
531        assert_eq!(keys.len(), 1);
532        assert_eq!(keys[0].public_hex, "bbbb");
533    }
534
535    #[test]
536    fn fs_persists_across_handles() {
537        let tmp = tempfile::tempdir().unwrap();
538        let w = make_wrap("acme", 2, "alice");
539        {
540            let mut dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
541            dir.publish_wrapped(&w).unwrap();
542            dir.publish_pubkey("alice", "cafe").unwrap();
543        }
544        // a fresh handle over the same dir sees the persisted state (reads are
545        // `&self` now, so the handle needs no `mut`)
546        let dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
547        assert_eq!(dir.fetch_wrapped(2, "alice").unwrap().as_ref(), Some(&w));
548        assert_eq!(dir.fetch_pubkeys().unwrap()[0].public_hex, "cafe");
549    }
550
551    /// Pins the `#[serde(default)]` forward-compat guarantee: a state file
552    /// written by an older build (missing one or both fields, or `{}`) still
553    /// loads as an empty-for-that-field directory rather than failing.
554    #[test]
555    fn state_loads_from_files_missing_fields() {
556        let empty: OrgKeyDirectoryState = serde_json::from_str("{}").unwrap();
557        assert!(empty.fetch_pubkeys().is_empty());
558        assert!(empty.fetch_wrapped_for("alice").is_empty());
559
560        // only pubkeys present → wrapped defaults
561        let partial: OrgKeyDirectoryState =
562            serde_json::from_str(r#"{"pubkeys":{"alice":"ff"}}"#).unwrap();
563        assert_eq!(partial.fetch_pubkeys().len(), 1);
564        assert!(partial.fetch_wrapped_for("alice").is_empty());
565    }
566
567    /// A corrupt state file must SURFACE as an error on both read and write —
568    /// never panic, and never silently reset to an empty directory (which would
569    /// drop everyone's published keys). This is the "someone overwrote the
570    /// file" case the module doc warns about.
571    #[test]
572    fn corrupt_state_file_surfaces_as_error() {
573        let tmp = tempfile::tempdir().unwrap();
574        let mut dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
575        dir.publish_pubkey("alice", "cafe").unwrap(); // creates the state file
576        std::fs::write(tmp.path().join("org-keys.json"), b"{ not json").unwrap();
577
578        assert!(matches!(
579            dir.fetch_pubkeys(),
580            Err(OrgKeyDirectoryError::Io(_))
581        ));
582        assert!(matches!(
583            dir.publish_pubkey("bob", "beef"),
584            Err(OrgKeyDirectoryError::Io(_))
585        ));
586    }
587}