car-sync 0.53.0

Multi-device sync core for Common Agent Runtime — replica-tagged append-only oplog + deterministic CRDT fold
Documentation
//! `resolve_org_root` — the out-of-band resolver that turns a member's published
//! wraps into their org master key `K_org`, for feeding
//! [`crate::org_key_provider::OrgAwareKeyProvider`]. This runs OFF the hot path
//! (at subsystem open), never inside `cipher_for`.
//!
//! ## What it does — and the trust it holds (audit item D)
//!
//! It fetches the candidate set addressed to the member
//! ([`OrgKeyDirectory::fetch_wrapped_for`], newest-epoch-first) and returns the
//! first wrap that [`crate::crypto::unwrap_org_key`] accepts under the caller's
//! `trusted` granter keys. The primitive carries all the security: it
//! `verify_strict`s the publisher signature against `trusted` BEFORE any decrypt
//! and binds the CALLER's `my_user_id` (not the blob's advisory `recipient`), so
//! the resolver stays dumb and lets the primitive judge.
//!
//! Its safety therefore reduces ENTIRELY to two things it cannot itself check,
//! and which the cryptographer audit still owns:
//! 1. the correctness of the `trusted` slice (admin-designated granters — keep it
//!    small; a compromised granter can sign a wrap of a bogus `K_org'`), and
//! 2. backend publish-authz — in particular **pubkey-table poisoning** (an active
//!    publisher who overwrote a victim's published pubkey can recover the REAL
//!    `K_org`; see the [`crate::org_key_directory`] module threat model).
//! `verify_strict` does not close #2 — do not read this resolver as doing so.
//!
//! ## Fail-closed return shape (NOT a bare `Option`)
//!
//! - `Err(..)` — the directory was UNREACHABLE (fetch failed). The caller MUST
//!   fail closed (org scope → `DenyCipher`), never silently drop org scope.
//! - `Ok(None)` — the directory was reachable but holds no trusted grant for this
//!   member (not yet granted). Also `DenyCipher`, but a distinct, benign state.
//! - `Ok(Some(ResolvedOrgRoot))` — the root plus the epoch it came from.
//!
//! Collapsing the first two into one `None` would let a transient fetch blip
//! silently disable org encryption — a fail-open. They are kept distinct.

use zeroize::Zeroizing;

use crate::crypto::unwrap_org_key;
use crate::org_key_directory::{OrgKeyDirectory, OrgKeyDirectoryError};
use ed25519_dalek::VerifyingKey;
use x25519_dalek::StaticSecret;

/// A resolved org master key and the epoch it was granted at. The epoch is
/// surfaced so a future rotation-aware caller (or audit log) can see WHICH
/// generation was selected without retrofitting the API.
pub struct ResolvedOrgRoot {
    pub root: Zeroizing<[u8; 32]>,
    pub epoch: u64,
}

/// Resolve `org`'s `K_org` for a member from their published wraps. See the module
/// docs for the fail-closed return contract and the trust this holds.
///
/// Selection: newest epoch first (the directory's contract), and within an epoch
/// the first wrap that unwraps under a `trusted` key wins — safe because every
/// trusted granter wraps the IDENTICAL `K_org` (it is the org master, not a
/// per-granter secret), so which trusted-signed candidate is chosen cannot change
/// the bytes. A lower epoch is selected only when every newer wrap fails to unwrap
/// (i.e. the member was not re-granted at the newer epoch) — correct fail-closed
/// selection.
pub fn resolve_org_root(
    directory: &dyn OrgKeyDirectory,
    org: &str,
    my_secret: &StaticSecret,
    my_user_id: &str,
    trusted: &[VerifyingKey],
) -> Result<Option<ResolvedOrgRoot>, OrgKeyDirectoryError> {
    // Err propagates → the caller fails closed. Do NOT map this to Ok(None).
    let candidates = directory.fetch_wrapped_for(my_user_id)?;
    for w in candidates {
        // Defense-in-depth: the directory is single-org by construction, but the
        // signature + KDF bind the wrap's OWN `org`, so a trusted granter's wrap
        // for org A misrouted into org B's directory would verify+decrypt cleanly
        // to A's key. Refuse to adopt it as B's root.
        if w.org != org {
            continue;
        }
        if let Ok(k) = unwrap_org_key(&w, my_secret, my_user_id, trusted) {
            return Ok(Some(ResolvedOrgRoot {
                root: Zeroizing::new(k),
                epoch: w.epoch,
            }));
        }
    }
    Ok(None)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::crypto::{ed25519_verifying, wrap_org_key, x25519_public, StretchedMaster};
    use crate::org_key_directory::{InMemoryOrgKeyDirectory, OrgKeyDirectory};

    // Fast test identity helpers (issued-high-entropy skips Argon2id); same
    // (secret, user) shape as the old raw-bytes fns → call sites are a pure rename.
    fn x25519_id(secret: &[u8], user: &str) -> StaticSecret {
        crate::crypto::derive_x25519_identity(
            &StretchedMaster::from_issued_high_entropy(secret, user),
            user,
        )
    }
    fn ed25519_id(secret: &[u8], user: &str) -> ed25519_dalek::SigningKey {
        crate::crypto::derive_ed25519_identity(
            &StretchedMaster::from_issued_high_entropy(secret, user),
            user,
        )
    }

    fn granter() -> ed25519_dalek::SigningKey {
        ed25519_id(b"granter-login", "acc_granter")
    }

    // Publish a wrap of `k_org` for `recipient` in `org` at `epoch`, signed by `signer`.
    #[allow(clippy::too_many_arguments)]
    fn publish(
        dir: &mut InMemoryOrgKeyDirectory,
        k_org: &[u8; 32],
        org: &str,
        epoch: u64,
        recipient: &str,
        recipient_pub: &x25519_dalek::PublicKey,
        publisher: &str,
        signer: &ed25519_dalek::SigningKey,
    ) {
        let w = wrap_org_key(
            k_org,
            org,
            epoch,
            recipient,
            recipient_pub,
            publisher,
            signer,
        )
        .unwrap();
        dir.publish_wrapped(&w).unwrap();
    }

    #[test]
    fn resolves_the_root_for_a_granted_member() {
        let mut dir = InMemoryOrgKeyDirectory::new();
        let k_org = [7u8; 32];
        let alice = x25519_id(b"alice", "acc_alice");
        publish(
            &mut dir,
            &k_org,
            "acme",
            1,
            "acc_alice",
            &x25519_public(&alice),
            "acc_granter",
            &granter(),
        );

        let trusted = [ed25519_verifying(&granter())];
        let got = resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
            .unwrap()
            .expect("granted member resolves");
        assert_eq!(*got.root, k_org);
        assert_eq!(got.epoch, 1);
    }

    #[test]
    fn ungranted_member_is_ok_none_not_err() {
        let dir = InMemoryOrgKeyDirectory::new(); // reachable, empty
        let alice = x25519_id(b"alice", "acc_alice");
        let trusted = [ed25519_verifying(&granter())];
        // Reachable + no grant → Ok(None), the benign DenyCipher state.
        assert!(
            resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn wrap_from_untrusted_publisher_is_not_resolved() {
        let mut dir = InMemoryOrgKeyDirectory::new();
        let k_org = [9u8; 32];
        let alice = x25519_id(b"alice", "acc_alice");
        let mallory = ed25519_id(b"mallory", "acc_mallory");
        publish(
            &mut dir,
            &k_org,
            "acme",
            1,
            "acc_alice",
            &x25519_public(&alice),
            "acc_mallory",
            &mallory,
        );

        // Alice trusts only the granter, not Mallory → the poisoned wrap is skipped.
        let trusted = [ed25519_verifying(&granter())];
        assert!(
            resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn newest_grantable_epoch_wins() {
        let mut dir = InMemoryOrgKeyDirectory::new();
        let alice = x25519_id(b"alice", "acc_alice");
        let trusted = [ed25519_verifying(&granter())];
        publish(
            &mut dir,
            &[1u8; 32],
            "acme",
            1,
            "acc_alice",
            &x25519_public(&alice),
            "acc_granter",
            &granter(),
        );
        publish(
            &mut dir,
            &[2u8; 32],
            "acme",
            3,
            "acc_alice",
            &x25519_public(&alice),
            "acc_granter",
            &granter(),
        );

        let got = resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
            .unwrap()
            .unwrap();
        assert_eq!(got.epoch, 3, "newest resolvable epoch selected");
        assert_eq!(*got.root, [2u8; 32]);
    }

    #[test]
    fn wrap_for_a_different_org_is_skipped() {
        // A trusted granter's wrap for org "other" (would unwrap fine on its own)
        // must NOT be adopted as "acme"'s root — defense-in-depth against a
        // misrouted blob in the wrong org's directory.
        let mut dir = InMemoryOrgKeyDirectory::new();
        let alice = x25519_id(b"alice", "acc_alice");
        let trusted = [ed25519_verifying(&granter())];
        publish(
            &mut dir,
            &[5u8; 32],
            "other",
            1,
            "acc_alice",
            &x25519_public(&alice),
            "acc_granter",
            &granter(),
        );

        assert!(
            resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn directory_error_propagates_as_err_not_ok_none() {
        // Fail-closed: an UNREACHABLE directory must surface as Err (the wiring
        // then fails closed to DenyCipher), never collapse into Ok(None) which a
        // caller might read as "reachable, ungranted" and silently drop org scope.
        struct FailingDir;
        impl OrgKeyDirectory for FailingDir {
            fn publish_wrapped(
                &mut self,
                _: &crate::crypto::WrappedOrgKey,
            ) -> Result<(), OrgKeyDirectoryError> {
                unimplemented!()
            }
            fn fetch_wrapped(
                &self,
                _: u64,
                _: &str,
            ) -> Result<Option<crate::crypto::WrappedOrgKey>, OrgKeyDirectoryError> {
                unimplemented!()
            }
            fn fetch_wrapped_for(
                &self,
                _: &str,
            ) -> Result<Vec<crate::crypto::WrappedOrgKey>, OrgKeyDirectoryError> {
                Err(OrgKeyDirectoryError::Io(std::io::Error::other(
                    "unreachable",
                )))
            }
            fn publish_pubkey(&mut self, _: &str, _: &str) -> Result<(), OrgKeyDirectoryError> {
                unimplemented!()
            }
            fn fetch_pubkeys(
                &self,
            ) -> Result<Vec<crate::org_key_directory::MemberPublicKey>, OrgKeyDirectoryError>
            {
                unimplemented!()
            }
        }
        let alice = x25519_id(b"alice", "acc_alice");
        let trusted = [ed25519_verifying(&granter())];
        assert!(resolve_org_root(&FailingDir, "acme", &alice, "acc_alice", &trusted).is_err());
    }
}