//! Ed25519-signed, identity-bound managed-policy envelope.
//!
//! The server signs a canonical payload (the served policy, the bound principal,
//! an expiry) with an Ed25519 private key; the client verifies it against a
//! compiled-in trusted key set (selected by the signed `key_id`, so keys can
//! rotate), binds it to the active principal, and checks the on-disk policy
//! matches the signed bytes — so an in-place edit is caught, not just a deletion.
//! Inert until a public key is provisioned: with no embedded keys the cache
//! marker stays the (best-effort) authority.
use base64::Engine;
use serde::{Deserialize, Serialize};

/// The payload format version the server currently signs. Bump when the payload
/// gains semantics (e.g. an anti-replay counter or a key-fingerprint binding) so
/// verifiers can distinguish generations; `0` means a pre-versioned payload.
pub const SIGNED_PAYLOAD_VERSION: u32 = 1;

/// The exact bytes the server signs: the served policy, the principal it is
/// bound to, and an expiry. Serialized once on the server and shipped verbatim
/// as `signed_payload`, so the client verifies the received bytes directly
/// instead of re-canonicalizing (no cross-language serialization drift).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedPayload {
    /// Payload format version ([`SIGNED_PAYLOAD_VERSION`]); `default` 0 so
    /// pre-versioned sidecars parse and verify unchanged.
    #[serde(default)]
    pub version: u32,
    #[serde(default)]
    pub deployment_id: Option<String>,
    #[serde(default)]
    pub team_id: Option<String>,
    #[serde(default)]
    pub managed_config: Option<String>,
    #[serde(default)]
    pub requirements: Option<String>,
    /// Strict (fail-closed) opt-in, carried in the SIGNED bytes so a local actor can't
    /// flip enforcement. `default` false so an older/unsigned payload stays lenient.
    #[serde(default)]
    pub fail_closed: bool,
    /// Unix seconds after which the signature is no longer trusted.
    pub expires_at: u64,
    /// Identifies the signing key, so a rotation can be distinguished.
    pub key_id: String,
}

/// One signed envelope carried alongside the legacy policy fields in the
/// deployment-config response (additive: old clients ignore it). Also the
/// shape the client persists as its on-disk signature sidecar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureEnvelope {
    /// The exact JSON string that was signed (a serialized [`SignedPayload`]).
    pub signed_payload: String,
    /// Base64 (standard) Ed25519 signature over `signed_payload`'s UTF-8 bytes.
    pub signature: String,
    /// Untrusted (outside the signed bytes): a hint for picking among multiple
    /// envelopes, never for selecting the verifying key — only the signed
    /// payload's `key_id` is authoritative.
    #[serde(default)]
    pub key_id: String,
}

/// Unix seconds now (saturating to 0 on a pre-epoch clock).
pub fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}
/// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one
/// entry only during a rotation. Empty ships dark (see [`verification_active`]).
/// Compile-time, not an env flag: the local attacker controls their env.
pub const EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS: &[(&str, &[u8])] = &[];
const _: () = {
    let keys = EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS;
    let mut i = 0;
    while i < keys.len() {
        assert!(
            keys[i].1.len() == 32,
            "every embedded key must be exactly 32 raw Ed25519 bytes"
        );
        assert!(
            !keys[i].0.is_empty(),
            "every embedded key id must be non-empty"
        );
        let mut j = i + 1;
        while j < keys.len() {
            assert!(
                !const_str_eq(keys[i].0, keys[j].0),
                "embedded key ids must be unique"
            );
            j += 1;
        }
        i += 1;
    }
};
const fn const_str_eq(a: &str, b: &str) -> bool {
    let (a, b) = (a.as_bytes(), b.as_bytes());
    if a.len() != b.len() {
        return false;
    }
    let mut i = 0;
    while i < a.len() {
        if a[i] != b[i] {
            return false;
        }
        i += 1;
    }
    true
}
/// Run `f` over the trusted key set — the compiled-in [`EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS`],
/// unless the compile-time-excluded test seam overrides it.
fn with_embedded_keys<R>(f: impl FnOnce(&[(&str, &[u8])]) -> R) -> R {
    #[cfg(any(test, feature = "test-support"))]
    {
        let installed = test_seam::EMBEDDED_KEYS_OVERRIDE
            .read()
            .unwrap_or_else(|e| e.into_inner());
        if let Some(keys) = installed.as_ref() {
            let borrowed: Vec<(&str, &[u8])> = keys
                .iter()
                .map(|(id, key)| (id.as_str(), key.as_slice()))
                .collect();
            return f(&borrowed);
        }
    }
    f(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS)
}
/// Test-only override of the compiled-in trusted key set.
///
/// Compiled out of production builds entirely: present only under `cfg(test)`
/// or the `test-support` feature, which dependents enable exclusively via
/// `[dev-dependencies]`. With the seam absent, [`with_embedded_keys`] is a
/// direct read of [`EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS`] — the trust anchor
/// stays compile-time in shipping binaries.
#[cfg(any(test, feature = "test-support"))]
pub mod test_seam {
    use std::sync::RwLock;
    /// `(key_id, raw public key bytes)` pairs forming a trusted key set.
    type EmbeddedKeys = Vec<(String, Vec<u8>)>;
    /// Installed override; `None` falls through to the compiled-in set.
    pub(super) static EMBEDDED_KEYS_OVERRIDE: RwLock<Option<EmbeddedKeys>> = RwLock::new(None);
    /// Install `(key_id, raw 32-byte Ed25519 public key)` pairs as the trusted
    /// key set for this process, arming verification (see
    /// [`verification_active`](super::verification_active)).
    pub fn set_embedded_keys(keys: &[(&str, &[u8])]) {
        *EMBEDDED_KEYS_OVERRIDE
            .write()
            .unwrap_or_else(|e| e.into_inner()) = Some(
            keys.iter()
                .map(|(id, key)| ((*id).to_owned(), key.to_vec()))
                .collect(),
        );
    }
    /// Remove the override, restoring the compiled-in key set.
    pub fn clear_embedded_keys() {
        *EMBEDDED_KEYS_OVERRIDE
            .write()
            .unwrap_or_else(|e| e.into_inner()) = None;
    }
}
/// Sidecar persisted next to the policy so the load-time gate can re-verify it offline.
pub const SIGNATURE_SIDECAR_FILE: &str = "managed_config.sig.json";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SigError {
    #[error("signature is not valid base64")]
    BadSignatureEncoding,
    #[error("signature does not verify against the provided public key")]
    SignatureMismatch,
    #[error("signed payload is not valid JSON")]
    BadPayload,
    #[error("signed payload names a key_id outside the trusted set")]
    UnknownKeyId,
    #[error("signed policy is bound to a different principal")]
    PrincipalMismatch,
    #[error("signed policy has expired")]
    Expired,
    #[error("on-disk {0} does not match the signed policy")]
    ContentMismatch(&'static str),
    /// The file exists but can't be read (EACCES etc. — never plain absence). Not
    /// tamper evidence: callers refetch but don't refuse on a read blip.
    #[error("on-disk {0} cannot be read")]
    Unreadable(&'static str),
}
/// Whether the client must require + verify a signature — true iff the key set is
/// non-empty (no env toggle; see [`EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS`]).
pub fn verification_active() -> bool {
    with_embedded_keys(|keys| !keys.is_empty())
}
/// Whether `key_id` names a trusted key. Only PICKS among served envelopes;
/// verification re-selects the key from the signed bytes, so a lying hint can at
/// most cause a verification failure.
pub fn embedded_key_id_trusted(key_id: &str) -> bool {
    with_embedded_keys(|keys| keys.iter().any(|(id, _)| *id == key_id))
}
/// Verify `signature_b64` over `signed_payload` against `trusted_keys`, returning the
/// parsed payload. The verifying key is selected by the SIGNED payload's `key_id` —
/// safe to read pre-verification because selection can only land within the trusted
/// set (a forged id either misses or picks a key the signature won't match). Pure:
/// callers supply the keys so tests can use throwaway keypairs.
pub fn verify_signed_payload(
    signed_payload: &str,
    signature_b64: &str,
    trusted_keys: &[(&str, &[u8])],
) -> Result<SignedPayload, SigError> {
    let payload: SignedPayload =
        serde_json::from_str(signed_payload).map_err(|_| SigError::BadPayload)?;
    let (_, public_key) = trusted_keys
        .iter()
        .find(|(id, _)| *id == payload.key_id)
        .ok_or(SigError::UnknownKeyId)?;
    let sig = base64::engine::general_purpose::STANDARD
        .decode(signature_b64.trim())
        .map_err(|_| SigError::BadSignatureEncoding)?;
    ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
        .verify(signed_payload.as_bytes(), &sig)
        .map_err(|_| SigError::SignatureMismatch)?;
    Ok(payload)
}
/// Fetch-time identity binding for a VERIFIED payload, expiry enforced: a
/// deployment-signed payload is trusted on signature alone; a team-signed payload
/// must match the active team. Lenient on a missing active team — an `auth.json`
/// read blip must not brick a session (a cross-team attacker has a team of their
/// own). The at-rest checks use [`signed_principal_matches`] instead.
pub fn check_fetch_identity(
    payload: &SignedPayload,
    active_team_id: Option<&str>,
    now_unix: u64,
) -> Result<(), SigError> {
    if now_unix > payload.expires_at {
        return Err(SigError::Expired);
    }
    if payload.deployment_id.is_some() {
        return Ok(());
    }
    if let (Some(signed), Some(active)) = (payload.team_id.as_deref(), active_team_id)
        && signed != active
    {
        return Err(SigError::PrincipalMismatch);
    }
    Ok(())
}
/// Whether the payload's effective principal (`deployment_id`, else `team_id`) matches
/// ours — the at-rest identity rule, so another tenant's cache reads foreign. Lenient
/// when either side is unknown. Deliberately expiry-free: the gate orders identity
/// BEFORE the `fail_closed` short-circuit and expiry after it (see [`SignedCacheFacts`]).
fn signed_principal_matches(payload: &SignedPayload, expected_principal: Option<&str>) -> bool {
    let signed = payload
        .deployment_id
        .as_deref()
        .or(payload.team_id.as_deref());
    !matches!(
        (signed, expected_principal), (Some(signed), Some(expected)) if signed !=
        expected
    )
}
/// Full verification of a fetched envelope against the embedded trusted keys
/// (signature, binding, expiry), returning the trusted payload to persist.
pub fn verify_fetched(
    sidecar: &SignatureEnvelope,
    active_team_id: Option<&str>,
    now_unix: u64,
) -> Result<SignedPayload, SigError> {
    with_embedded_keys(|keys| verify_fetched_with_keys(sidecar, keys, active_team_id, now_unix))
}
/// Key-injected core of [`verify_fetched`] so tests can supply throwaway keypairs.
fn verify_fetched_with_keys(
    sidecar: &SignatureEnvelope,
    trusted_keys: &[(&str, &[u8])],
    active_team_id: Option<&str>,
    now_unix: u64,
) -> Result<SignedPayload, SigError> {
    let payload = verify_signed_payload(&sidecar.signed_payload, &sidecar.signature, trusted_keys)?;
    check_fetch_identity(&payload, active_team_id, now_unix)?;
    Ok(payload)
}
/// True when something occupies `path` that is not a regular file — directory,
/// symlink, fifo, … NO-FOLLOW, so even a symlink to a byte-identical file counts:
/// a squatter blocks or redirects reads/rewrites, which is tamper, never a blip.
/// The clearing side stays no-follow too (a symlink squat is removed as the link).
fn non_regular_file_at(path: &std::path::Path) -> bool {
    std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_file())
}
/// Confirm the on-disk artifacts match the signed payload byte-for-byte — an in-place
/// edit is caught, not just a deletion. A signed-ABSENT slot must be empty on disk: a
/// locally planted `requirements.toml` (the highest-precedence layer) is tamper, not
/// noise. An unreadable file is [`SigError::Unreadable`] (refetch, don't refuse — a
/// read blip); anything non-regular squatting the slot ([`non_regular_file_at`])
/// reads as tamper.
pub fn check_on_disk_matches(
    home: &std::path::Path,
    payload: &SignedPayload,
) -> Result<(), SigError> {
    for (name, label, signed) in [
        (
            "managed_config.toml",
            "managed_config",
            payload.managed_config.as_deref(),
        ),
        (
            "requirements.toml",
            "requirements",
            payload.requirements.as_deref(),
        ),
    ] {
        let path = home.join(name);
        if non_regular_file_at(&path) {
            return Err(SigError::ContentMismatch(label));
        }
        let on_disk = match std::fs::read_to_string(&path) {
            Ok(s) => Some(s),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
            Err(_) => return Err(SigError::Unreadable(label)),
        };
        let matches = match signed.filter(|s| !s.is_empty()) {
            Some(signed) => on_disk.as_deref() == Some(signed),
            None => on_disk.as_deref().is_none_or(str::is_empty),
        };
        if !matches {
            return Err(SigError::ContentMismatch(label));
        }
    }
    Ok(())
}
pub(crate) fn sidecar_path(home: &std::path::Path) -> std::path::PathBuf {
    home.join(SIGNATURE_SIDECAR_FILE)
}
/// Outcome of reading the on-disk sidecar; mirrors the artifact-slot semantics of
/// [`check_on_disk_matches`].
enum SidecarRead {
    Present(SignatureEnvelope),
    /// NotFound, unparseable JSON, or a squatting non-regular file (directory,
    /// symlink, …) — not an authentic sidecar.
    Absent,
    /// EACCES-style transient failure on a regular file — not tamper evidence: the
    /// gate must not refuse on it, but the refetch trigger fires to self-heal.
    Unreadable,
}
fn read_sidecar(home: &std::path::Path) -> SidecarRead {
    let path = sidecar_path(home);
    if non_regular_file_at(&path) {
        return SidecarRead::Absent;
    }
    let json = match std::fs::read_to_string(&path) {
        Ok(json) => json,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SidecarRead::Absent,
        Err(_) => return SidecarRead::Unreadable,
    };
    match serde_json::from_str(&json) {
        Ok(sidecar) => SidecarRead::Present(sidecar),
        Err(_) => SidecarRead::Absent,
    }
}
/// Persist the sidecar atomically — a torn sidecar would fail the load-time gate.
/// Written 0600 on unix: for a deployment-key principal the signed payload embeds
/// the key, so the sidecar is a second at-rest copy of a bearer credential.
pub fn write_sidecar(home: &std::path::Path, sidecar: &SignatureEnvelope) -> std::io::Result<()> {
    let json = serde_json::to_string(sidecar)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    crate::fs_atomic::write_atomically(&sidecar_path(home), &json, Some(0o600))
}
/// True when signature verification is active AND a cloud-cache policy on disk is
/// NOT covered by a valid, in-date, identity-bound, content-matching signature.
/// Dark build or no policy on disk → false (behavior unchanged until a key ships).
pub fn cloud_cache_signature_invalid(
    home: &std::path::Path,
    expected_principal: Option<&str>,
    now_unix: u64,
) -> bool {
    if !verification_active() {
        return false;
    }
    with_embedded_keys(|keys| {
        cloud_cache_signature_invalid_with_keys(home, keys, expected_principal, now_unix)
    })
}
/// Key-injected core of [`cloud_cache_signature_invalid`] so tests can supply throwaway keys.
fn cloud_cache_signature_invalid_with_keys(
    home: &std::path::Path,
    trusted_keys: &[(&str, &[u8])],
    expected_principal: Option<&str>,
    now_unix: u64,
) -> bool {
    let has_policy =
        home.join("requirements.toml").exists() || home.join("managed_config.toml").exists();
    if !has_policy {
        return false;
    }
    use SignedCacheEvaluation as Eval;
    match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) {
        Eval::NoAuthenticSidecar | Eval::SidecarUnreadable => true,
        Eval::Facts(f) => !f.identity_ok || f.expired || f.disk != DiskStatus::Match,
    }
}
/// On-disk status of the signed artifact slots, from [`check_on_disk_matches`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiskStatus {
    /// Every slot matches the signed payload (content and absence).
    Match,
    /// Tamper: edited, deleted-while-signed, planted, or a squatting non-file.
    Mismatch,
    /// A read blip (EACCES on a regular file) — stale for the refetch, lenient at
    /// the gate.
    Unreadable,
}
/// What one verification pass over the on-disk sidecar establishes. The two public
/// checks are projections over the same facts: the refetch trigger flags ANY
/// deviation; the gate applies the fail-closed rules.
struct SignedCacheFacts {
    /// The payload's effective principal matches ours ([`signed_principal_matches`]).
    identity_ok: bool,
    expired: bool,
    /// The SIGNED opt-in — read from the payload, never the forgeable marker.
    fail_closed: bool,
    disk: DiskStatus,
}
/// One evaluation of the on-disk sidecar; both public checks project from this.
enum SignedCacheEvaluation {
    /// No authentic sidecar: missing, corrupt, a squatting non-file, forged, or
    /// keyed outside the trusted set — never facts from unverified bytes.
    NoAuthenticSidecar,
    /// The sidecar exists but a transient IO error blocked the read
    /// ([`SidecarRead::Unreadable`]) — nothing verified, nothing tamper-shaped.
    SidecarUnreadable,
    Facts(SignedCacheFacts),
}
/// Read the sidecar, verify it against `trusted_keys`, reduce to a [`SignedCacheEvaluation`].
fn evaluate_signed_cache(
    home: &std::path::Path,
    trusted_keys: &[(&str, &[u8])],
    expected_principal: Option<&str>,
    now_unix: u64,
) -> SignedCacheEvaluation {
    let sidecar = match read_sidecar(home) {
        SidecarRead::Present(sidecar) => sidecar,
        SidecarRead::Absent => return SignedCacheEvaluation::NoAuthenticSidecar,
        SidecarRead::Unreadable => return SignedCacheEvaluation::SidecarUnreadable,
    };
    let Ok(payload) =
        verify_signed_payload(&sidecar.signed_payload, &sidecar.signature, trusted_keys)
    else {
        return SignedCacheEvaluation::NoAuthenticSidecar;
    };
    SignedCacheEvaluation::Facts(SignedCacheFacts {
        identity_ok: signed_principal_matches(&payload, expected_principal),
        expired: now_unix > payload.expires_at,
        fail_closed: payload.fail_closed,
        disk: match check_on_disk_matches(home, &payload) {
            Ok(()) => DiskStatus::Match,
            Err(SigError::Unreadable(_)) => DiskStatus::Unreadable,
            Err(_) => DiskStatus::Mismatch,
        },
    })
}
/// Verdict of the signed-sidecar check for the load-time gate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignedVerdict {
    /// Verification is not active (no embedded keys — the dark build): the marker is
    /// the only signal. A distinct variant, not an `Option`, so a dark build can never
    /// be confused with [`Self::NoAuthenticSidecar`], whose absence rule must never
    /// fire keyless.
    Inactive,
    /// No sidecar, or one whose signature doesn't verify — not an authentic verdict.
    /// Under a fail-closed marker that recorded served policy, absence is itself
    /// tamper: stripping the sidecar must not downgrade enforcement to the forgeable
    /// marker path (a first keyed launch over a pre-signing cache also refuses until
    /// one online refetch writes it — deliberate). Residual: wiping the marker with
    /// the sidecar — inherent to user-writable state, covered by the root-owned
    /// /etc/kigi and MDM layers. Otherwise the marker decides.
    NoAuthenticSidecar,
    /// The sidecar exists but a transient IO error (EACCES-style, never plain absence
    /// or a squatting non-file) blocked the read. Not tamper evidence: the gate falls
    /// back to the marker decision, and the refetch trigger fires to rewrite it.
    SidecarUnreadable,
    /// Authentic sidecar; the policy is valid for this principal (or never opted into
    /// fail-closed enforcement).
    Trusted,
    /// Authentic sidecar proving an opted-in policy is no longer valid here: edited on
    /// disk, expired, or bound to a different principal. Refuse — always.
    Compromised,
}
/// The signed verdict for the on-disk cache; see [`SignedVerdict`]. The fail-closed
/// opt-in is read from the SIGNED bytes, not the forgeable marker. `expected_principal`
/// is the machine's managed principal (active team id, or the recorded deployment id);
/// a payload bound elsewhere is a cross-tenant replay and reads compromised.
pub fn signed_cache_compromised(
    home: &std::path::Path,
    expected_principal: Option<&str>,
    now_unix: u64,
) -> SignedVerdict {
    if !verification_active() {
        return SignedVerdict::Inactive;
    }
    with_embedded_keys(|keys| {
        signed_cache_compromised_with_keys(home, keys, expected_principal, now_unix)
    })
}
/// Key-injected core of [`signed_cache_compromised`] so tests can supply throwaway keys.
fn signed_cache_compromised_with_keys(
    home: &std::path::Path,
    trusted_keys: &[(&str, &[u8])],
    expected_principal: Option<&str>,
    now_unix: u64,
) -> SignedVerdict {
    use SignedCacheEvaluation as Eval;
    match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) {
        Eval::NoAuthenticSidecar => SignedVerdict::NoAuthenticSidecar,
        Eval::SidecarUnreadable => SignedVerdict::SidecarUnreadable,
        Eval::Facts(f) if !f.identity_ok => SignedVerdict::Compromised,
        Eval::Facts(f) if !f.fail_closed => SignedVerdict::Trusted,
        Eval::Facts(f) if f.expired || f.disk == DiskStatus::Mismatch => SignedVerdict::Compromised,
        Eval::Facts(_) => SignedVerdict::Trusted,
    }
}
#[cfg(test)]
#[path = "signed_policy/tests.rs"]
mod tests;
