Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
525 lines
22 KiB
Rust
525 lines
22 KiB
Rust
//! 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;
|
|
pub use prod_mc_cli_chat_proxy_types::{
|
|
MANAGED_IDENTITY_TYP, MANAGED_POLICY_TYP, ManagedIdentityClaim, SignatureEnvelope,
|
|
SignedPayload, now_unix,
|
|
};
|
|
/// 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.
|
|
/// Provisioning order: keyed clients reject `typ`-less envelopes, so the
|
|
/// typ-emitting server must be fully rolled out before any client embeds a key.
|
|
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 {
|
|
f(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS)
|
|
}
|
|
/// 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";
|
|
/// The is-managed claim's own sidecar (see
|
|
/// [`prod_mc_cli_chat_proxy_types::ManagedIdentityClaim`]).
|
|
pub const MANAGED_IDENTITY_SIDECAR_FILE: &str = "managed_identity.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 carries the wrong message type")]
|
|
WrongType,
|
|
#[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). Requires
|
|
/// the [`MANAGED_POLICY_TYP`] tag (a claim must never verify as a policy). 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)?;
|
|
verify_signature_with_keys(signed_payload, signature_b64, trusted_keys, &payload.key_id)?;
|
|
if payload.typ != MANAGED_POLICY_TYP {
|
|
return Err(SigError::WrongType);
|
|
}
|
|
Ok(payload)
|
|
}
|
|
/// [`verify_signed_payload`]'s mirror for claims (requires [`MANAGED_IDENTITY_TYP`]).
|
|
pub fn verify_managed_identity_claim(
|
|
signed_payload: &str,
|
|
signature_b64: &str,
|
|
trusted_keys: &[(&str, &[u8])],
|
|
) -> Result<ManagedIdentityClaim, SigError> {
|
|
let claim: ManagedIdentityClaim =
|
|
serde_json::from_str(signed_payload).map_err(|_| SigError::BadPayload)?;
|
|
verify_signature_with_keys(signed_payload, signature_b64, trusted_keys, &claim.key_id)?;
|
|
if claim.typ != MANAGED_IDENTITY_TYP {
|
|
return Err(SigError::WrongType);
|
|
}
|
|
Ok(claim)
|
|
}
|
|
/// Shared Ed25519 check: select the trusted key named by the signed bytes' `key_id`, verify.
|
|
fn verify_signature_with_keys(
|
|
signed_payload: &str,
|
|
signature_b64: &str,
|
|
trusted_keys: &[(&str, &[u8])],
|
|
key_id: &str,
|
|
) -> Result<(), SigError> {
|
|
let (_, public_key) = trusted_keys
|
|
.iter()
|
|
.find(|(id, _)| *id == 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)
|
|
}
|
|
/// 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))
|
|
}
|
|
/// Fetch-time claim verification (signature + expiry; binding is the caller's rule).
|
|
pub fn verify_fetched_claim(
|
|
sidecar: &SignatureEnvelope,
|
|
now_unix: u64,
|
|
) -> Result<ManagedIdentityClaim, SigError> {
|
|
with_embedded_keys(|keys| verify_fetched_claim_with_keys(sidecar, keys, now_unix))
|
|
}
|
|
/// Key-injected core of [`verify_fetched_claim`] so tests can supply throwaway keys.
|
|
fn verify_fetched_claim_with_keys(
|
|
sidecar: &SignatureEnvelope,
|
|
trusted_keys: &[(&str, &[u8])],
|
|
now_unix: u64,
|
|
) -> Result<ManagedIdentityClaim, SigError> {
|
|
let claim =
|
|
verify_managed_identity_claim(&sidecar.signed_payload, &sidecar.signature, trusted_keys)?;
|
|
if now_unix > claim.expires_at {
|
|
return Err(SigError::Expired);
|
|
}
|
|
Ok(claim)
|
|
}
|
|
/// 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 {
|
|
read_envelope_at(&sidecar_path(home))
|
|
}
|
|
fn read_envelope_at(path: &std::path::Path) -> SidecarRead {
|
|
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<()> {
|
|
write_envelope_at(&sidecar_path(home), sidecar)
|
|
}
|
|
pub(crate) fn managed_identity_sidecar_path(home: &std::path::Path) -> std::path::PathBuf {
|
|
home.join(MANAGED_IDENTITY_SIDECAR_FILE)
|
|
}
|
|
/// [`write_sidecar`] for the claim (0600 for uniformity; the claim has no secret).
|
|
pub fn write_managed_identity_sidecar(
|
|
home: &std::path::Path,
|
|
sidecar: &SignatureEnvelope,
|
|
) -> std::io::Result<()> {
|
|
write_envelope_at(&managed_identity_sidecar_path(home), sidecar)
|
|
}
|
|
fn write_envelope_at(path: &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(path, &json, Some(0o600))
|
|
}
|
|
/// Whether an authentic claim IMPOSES fail-closed enforcement: verified, bound to
|
|
/// the KNOWN `expected_principal`, in-date vs the caller-clamped `now_unix`, and
|
|
/// `fail_closed`. Anything else imposes nothing: permissive (must not override a
|
|
/// now-fail_closed marker), unknown principal (a planted claim must not brick a
|
|
/// signed-out victim), foreign, expired, forged, or absent.
|
|
pub fn managed_identity_claim_imposes(
|
|
home: &std::path::Path,
|
|
expected_principal: Option<&str>,
|
|
now_unix: u64,
|
|
) -> bool {
|
|
if !verification_active() {
|
|
return false;
|
|
}
|
|
with_embedded_keys(|keys| {
|
|
managed_identity_claim_imposes_with_keys(home, keys, expected_principal, now_unix)
|
|
})
|
|
}
|
|
/// Key-injected core of [`managed_identity_claim_imposes`] so tests can supply throwaway keys.
|
|
fn managed_identity_claim_imposes_with_keys(
|
|
home: &std::path::Path,
|
|
trusted_keys: &[(&str, &[u8])],
|
|
expected_principal: Option<&str>,
|
|
now_unix: u64,
|
|
) -> bool {
|
|
let Some(expected) = expected_principal else {
|
|
return false;
|
|
};
|
|
let SidecarRead::Present(sidecar) = read_envelope_at(&managed_identity_sidecar_path(home))
|
|
else {
|
|
return false;
|
|
};
|
|
let Ok(claim) =
|
|
verify_managed_identity_claim(&sidecar.signed_payload, &sidecar.signature, trusted_keys)
|
|
else {
|
|
return false;
|
|
};
|
|
claim.principal == expected && now_unix <= claim.expires_at && claim.fail_closed
|
|
}
|
|
/// 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/grok 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.
|
|
/// The claim is deliberately NOT consulted here: a genuine blip must not
|
|
/// refuse, and a chmod-capable attacker could delete the claim anyway.
|
|
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;
|