feat(helper): accept dormant mutation envelopes
Cut 2 adds a verify-envelope path beside the closure-token executor. The helper parses an envelope, binds it to the independently provisioned agent identity, validates time and lifetime bounds, selects the trusted key, verifies the signed contract, checks backend payload shape, and then refuses with backend_not_migrated. Every path emits an unsigned MutationReceipt. No backend executes, no replay slot is consumed, and no artifact-custody claim is made until the first existing backend migrates.
This commit is contained in:
parent
517f1aca20
commit
320ad46e00
5 changed files with 480 additions and 39 deletions
|
|
@ -54,6 +54,20 @@ Agent, helper, and desktop binaries update through the same gate as packages: `s
|
|||
|
||||
---
|
||||
|
||||
## Dormant Envelope Verification
|
||||
|
||||
`verify-envelope` is the mutation-envelope path (ARCH-002 cut 2). It runs the same
|
||||
pipeline shape as execute mode — parse, host binding against the independently read
|
||||
`agent_id`, SEC-021 trusted keyring, Ed25519 verification, time window, authorization
|
||||
lifetime ceiling, backend payload shape — and then refuses every envelope with
|
||||
`backend_not_migrated`, emitting a `MutationReceipt`.
|
||||
|
||||
Two absences are deliberate. It **records no replay state**: execute mode records a token
|
||||
only after a plan is built, so an operation that cannot run never burns its slot, and
|
||||
nothing here can run. And it enforces **no artifact custody**: when a backend carries a
|
||||
local cached artifact as executor input the helper must rehash it, but no backend does
|
||||
yet, and remote repository execution keeps the limits stated above.
|
||||
|
||||
## Standalone Mint Mode
|
||||
|
||||
The helper also carries a local-authority minting path (`MintRequest` / `MintedToken` / `load_mint_key`) for deployments where the signing authority runs beside the host rather than on a central server. Design of record: [security/06-standalone-authority](../security/06-standalone-authority.md).
|
||||
|
|
@ -69,4 +83,4 @@ The helper also carries a local-authority minting path (`MintRequest` / `MintedT
|
|||
|
||||
---
|
||||
|
||||
*Last reviewed: 2026-08-25*
|
||||
*Last reviewed: 2026-08-26*
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
| **Helper** | The privileged, short-lived Rust executor — the only RedFlag mutation path on gated ecosystems. It uses fixed argv and a cleared environment; its current transient unit is not network-isolated. [components/04-helper](components/04-helper.md) |
|
||||
| **Legacy command path** | Direct signed-command execution for docker / winget / windows_update — ecosystems the capability gate doesn't cover yet. A documented gap, not a feature. [OVERVIEW](OVERVIEW.md) |
|
||||
| **Machine binding** | Hardware fingerprint registered at enrollment and checked on every authenticated request, including token renewal. A stolen `config.json` is inert elsewhere. [security/04-machine-binding](security/04-machine-binding.md) |
|
||||
| **Mutation manifest** | Dormant, backend-neutral description of one resolved state change: target, backend, operation, exact backend payloads, and provenance evidence. It is pinned cross-language but not yet used by runtime execution. [security/05-supply-chain-gate](security/05-supply-chain-gate.md) |
|
||||
| **Mutation manifest** | Dormant, backend-neutral description of one resolved state change: target, backend, operation, exact backend payloads, and provenance evidence. Its `target_id` is the RedFlag `agent_id`. Pinned cross-language and verifiable by the helper, but no backend executes through it yet. [security/05-supply-chain-gate](security/05-supply-chain-gate.md) |
|
||||
| **Mutation receipt** | The response half of the mutation contract: what the executor did with one envelope, carrying the operation/manifest/authorization join for audit. Unsigned by design — the executor is not a second authority. [security/05-supply-chain-gate](security/05-supply-chain-gate.md) |
|
||||
| **Nonce** | Per-command signed value with a 10-minute window; agents track executed nonces and reject replays. [verification/04-replay-protection](verification/04-replay-protection.md) |
|
||||
| **OSV** | OSV.dev, the open vulnerability database. Queried for discovered packages and for the resolved entries reported after dry-run; verdicts persist and gate approval. An unresolved dependency omitted from that report is not checked by this path. |
|
||||
| **RAF** | This document tree — the RedFlag Architecture Framework, the design of record. What the system is, not what's currently on the task list. |
|
||||
|
|
@ -30,4 +31,4 @@
|
|||
|
||||
---
|
||||
|
||||
*Last reviewed: 2026-08-25*
|
||||
*Last reviewed: 2026-08-26*
|
||||
|
|
|
|||
|
|
@ -197,10 +197,17 @@ outer action collection is therefore an unordered multiset; a backend that needs
|
|||
steps must encode their order inside one backend payload. Unknown format values fail closed.
|
||||
|
||||
The canonical encoding is specified in `protocol/README.md`. One shared fixture under
|
||||
`protocol/testdata/` pins canonical manifest bytes, manifest hash,
|
||||
authorization bytes, key ID, and signature across Server, Agent, and helper. Tamper tests
|
||||
cover provenance, execution location, target, backend, resolved action, duplicates, ordering,
|
||||
authorization metadata, validity, decision, and unknown formats.
|
||||
`protocol/testdata/` pins canonical manifest bytes, manifest hash, authorization bytes, key
|
||||
ID, signature, and receipt bytes/digest across Server, Agent, and helper. Tamper tests cover
|
||||
provenance, execution location, target, backend, resolved action, duplicates, ordering,
|
||||
authorization metadata, validity, decision, identifier shape, lifetime ceiling, and unknown
|
||||
formats.
|
||||
|
||||
`redflag-helper verify-envelope` runs an envelope through the helper's real pipeline —
|
||||
parse, host binding, trusted keyring, signature, time, lifetime, backend payload shape —
|
||||
and refuses with `backend_not_migrated`. It records no replay state and enforces no
|
||||
artifact custody: nothing executes through it yet, so it must not consume a replay slot,
|
||||
and cached-byte enforcement arrives with the first backend that carries one.
|
||||
|
||||
### Reuse, don't reinvent
|
||||
|
||||
|
|
@ -462,4 +469,4 @@ make it true.
|
|||
|
||||
---
|
||||
|
||||
*Last reviewed: 2026-08-25*
|
||||
*Last reviewed: 2026-08-26*
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use subtle::ConstantTimeEq;
|
|||
|
||||
#[allow(dead_code)]
|
||||
mod mutation_protocol;
|
||||
use mutation_protocol::{key_id_for, MutationEnvelope, MutationManifest, MutationOutcome, MutationReceipt};
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
|
|
@ -60,6 +61,7 @@ const EXIT_MINT_STALE: i32 = 23; // gate evidence outside the freshness window
|
|||
const EXIT_MINT_KEY: i32 = 24; // mint key missing/unsafe permissions
|
||||
const EXIT_MINT_DUPLICATE: i32 = 25; // request_id already minted
|
||||
const EXIT_TRUST_PATH: i32 = 26; // trusted file/dir fails owner/perm/symlink validation (SEC-021)
|
||||
const EXIT_AUTHORIZATION_DENIED: i32 = 27; // envelope carries a non-allow authority decision
|
||||
|
||||
// Default on-host locations. All overridable by env so packaging/tests can relocate.
|
||||
const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
|
||||
|
|
@ -230,38 +232,11 @@ fn local_agent_id() -> Result<String, Denial> {
|
|||
))
|
||||
}
|
||||
|
||||
fn key_id_for(pubkey: &[u8]) -> String {
|
||||
let digest = Sha256::digest(pubkey);
|
||||
hex::encode(&digest[..16])
|
||||
}
|
||||
|
||||
// Canonical UUID v4: 8-4-4-4-12 lowercase hex with version (4) and variant
|
||||
// (8/9/a/b) nibbles. Used to constrain request_id so the journal dedup
|
||||
// needle is always an exact-match target, never a substring of another ID.
|
||||
fn is_valid_uuid_v4(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 36 {
|
||||
return false;
|
||||
}
|
||||
// Hyphens at fixed positions
|
||||
if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' {
|
||||
return false;
|
||||
}
|
||||
// All non-hyphen positions must be lowercase hex digits
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
continue;
|
||||
}
|
||||
if !b.is_ascii_hexdigit() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Version nibble at position 14 must be '4'
|
||||
if bytes[14] != b'4' {
|
||||
return false;
|
||||
}
|
||||
// Variant nibble at position 19 must be 8, 9, a, or b
|
||||
matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
|
||||
mutation_protocol::is_canonical_uuid_v4(s)
|
||||
}
|
||||
|
||||
// SEC-021: the helper defends its own trust inputs instead of relying on the
|
||||
|
|
@ -633,14 +608,14 @@ fn execute_plan(plan: &[(String, Vec<String>)]) -> Result<(), Denial> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_result(result: &PolicyResult) {
|
||||
fn emit_result<T: Serialize>(result: &T) {
|
||||
match serde_json::to_string(result) {
|
||||
Ok(s) => println!("{}", s),
|
||||
Err(e) => log_error(&format!("result_serialize_failed error={}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_result_to_file(result: &PolicyResult, path: &str) {
|
||||
fn emit_result_to_file<T: Serialize>(result: &T, path: &str) {
|
||||
let json = match serde_json::to_string(result) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
|
|
@ -2103,6 +2078,427 @@ mod tests {
|
|||
req.operation = "remove".into();
|
||||
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_UNSUPPORTED_OP);
|
||||
}
|
||||
// ---- verify-envelope (ARCH-002 cut 2) ----
|
||||
// The envelope path refuses everything today. These pin the refusal order,
|
||||
// and that no refusal touches the replay ledger.
|
||||
|
||||
const TEST_TARGET: &str = "agent-123";
|
||||
const ENVELOPE_NOW: i64 = 1_700_000_100;
|
||||
|
||||
fn envelope_json(target: &str, authorization_id: &str, version: u32, expires_at: i64) -> String {
|
||||
format!(
|
||||
r#"{{"manifest":{{"protocol_version":{version},"operation_id":"550e8400-e29b-41d4-a716-446655440010","target_id":"{target}","backend":"pacman","operation":"install","resolved_actions":[{{"kind":"package","identity":"acl@2.3.2-1","payload":"{{\"repository\":\"core\"}}"}}],"evidence":[]}},"authorization":{{"protocol_version":{version},"authorization_id":"{authorization_id}","manifest_hash":"","authority_kind":"fleet-server","authority_id":"redflag-prod","target_id":"{target}","issued_at":1700000000,"not_before":1700000001,"expires_at":{expires_at},"decision":"allow","key_id":"00000000000000000000000000000000","signature":""}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn write_envelope(name: &str, body: &str) -> PathBuf {
|
||||
let dir = trust_fixture(name);
|
||||
let path = dir.join("envelope.json");
|
||||
fs::write(&path, body).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
const GOOD_AUTHORIZATION_ID: &str = "550e8400-e29b-41d4-a716-446655440011";
|
||||
|
||||
#[test]
|
||||
fn envelope_parse_failure_denies_without_identity() {
|
||||
let path = write_envelope("env-parse", "{ not json");
|
||||
let (envelope, denial) = run_envelope(path.to_str().unwrap(), ENVELOPE_NOW);
|
||||
assert!(envelope.is_none());
|
||||
assert_eq!(denial.code, EXIT_BAD_TOKEN);
|
||||
assert_eq!(denial.reason, "envelope_parse_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_unknown_version_fails_closed() {
|
||||
let path = write_envelope(
|
||||
"env-version",
|
||||
&envelope_json(TEST_TARGET, GOOD_AUTHORIZATION_ID, 2, 1_700_000_600),
|
||||
);
|
||||
let denial = run_envelope(path.to_str().unwrap(), ENVELOPE_NOW).1;
|
||||
assert_eq!(denial.code, EXIT_VERSION);
|
||||
assert_eq!(denial.reason, "unsupported_envelope_version");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_authorization_id_shape_is_refused() {
|
||||
let path = write_envelope(
|
||||
"env-uuid",
|
||||
&envelope_json(TEST_TARGET, "req-1", 1, 1_700_000_600),
|
||||
);
|
||||
let denial = run_envelope(path.to_str().unwrap(), ENVELOPE_NOW).1;
|
||||
assert_eq!(denial.code, EXIT_BAD_TOKEN);
|
||||
assert_eq!(denial.reason, "authorization_id_not_uuid_v4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_target_must_match_host_identity() {
|
||||
std::env::set_var("REDFLAG_AGENT_ID", TEST_TARGET);
|
||||
let path = write_envelope(
|
||||
"env-target",
|
||||
&envelope_json("some-other-body", GOOD_AUTHORIZATION_ID, 1, 1_700_000_600),
|
||||
);
|
||||
let denial = run_envelope(path.to_str().unwrap(), ENVELOPE_NOW).1;
|
||||
assert_eq!(denial.code, EXIT_AGENT_MISMATCH);
|
||||
assert_eq!(denial.reason, "envelope_target_mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_lifetime_ceiling_is_enforced() {
|
||||
std::env::set_var("REDFLAG_AGENT_ID", TEST_TARGET);
|
||||
let over = 1_700_000_001 + mutation_protocol::MAX_AUTHORIZATION_LIFETIME_SECS + 1;
|
||||
let path = write_envelope(
|
||||
"env-lifetime",
|
||||
&envelope_json(TEST_TARGET, GOOD_AUTHORIZATION_ID, 1, over),
|
||||
);
|
||||
let denial = run_envelope(path.to_str().unwrap(), ENVELOPE_NOW).1;
|
||||
assert_eq!(denial.code, EXIT_TIME_WINDOW);
|
||||
assert_eq!(denial.reason, "authorization_lifetime_exceeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_timestamp_arithmetic_fails_closed() {
|
||||
std::env::set_var("REDFLAG_AGENT_ID", TEST_TARGET);
|
||||
let body = envelope_json(TEST_TARGET, GOOD_AUTHORIZATION_ID, 1, i64::MAX)
|
||||
.replace("\"not_before\":1700000001", &format!("\"not_before\":{}", i64::MIN));
|
||||
let path = write_envelope("env-time-overflow", &body);
|
||||
let denial = run_envelope(path.to_str().unwrap(), ENVELOPE_NOW).1;
|
||||
assert_eq!(denial.code, EXIT_TIME_WINDOW);
|
||||
assert_eq!(denial.reason, "authorization_time_window_invalid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_backend_payload_must_be_a_json_object() {
|
||||
let manifest = MutationManifest {
|
||||
protocol_version: 1,
|
||||
operation_id: "op".into(),
|
||||
target_id: TEST_TARGET.into(),
|
||||
backend: "pacman".into(),
|
||||
operation: "install".into(),
|
||||
resolved_actions: vec![mutation_protocol::ResolvedAction {
|
||||
kind: "package".into(),
|
||||
identity: "acl@2.3.2-1".into(),
|
||||
payload: "[\"core\"]".into(),
|
||||
}],
|
||||
evidence: vec![],
|
||||
};
|
||||
let denial = validate_backend_payload_shape(&manifest).unwrap_err();
|
||||
assert_eq!(denial.code, EXIT_BAD_TOKEN);
|
||||
assert_eq!(denial.reason, "backend_payload_not_object");
|
||||
}
|
||||
|
||||
// The whole point of cut 2: a well-formed envelope walks the real pipeline
|
||||
// as far as the trusted keyring, and nothing it does burns a replay slot.
|
||||
#[test]
|
||||
fn envelope_reaches_the_keyring_and_never_records_replay() {
|
||||
std::env::set_var("REDFLAG_AGENT_ID", TEST_TARGET);
|
||||
let dir = trust_fixture("env-keyring");
|
||||
let state = dir.join("consumed-tokens");
|
||||
std::env::set_var("REDFLAG_HELPER_KEYRING", dir.join("trusted-keys"));
|
||||
std::env::set_var("REDFLAG_HELPER_STATE", &state);
|
||||
|
||||
let path = write_envelope(
|
||||
"env-pipeline",
|
||||
&envelope_json(TEST_TARGET, GOOD_AUTHORIZATION_ID, 1, 1_700_000_600),
|
||||
);
|
||||
let denial = run_envelope(path.to_str().unwrap(), ENVELOPE_NOW).1;
|
||||
|
||||
// Unprivileged tests cannot own a root keyring, so the keyring step is
|
||||
// where a valid envelope stops — which is the ordering being asserted.
|
||||
assert!(
|
||||
denial.code == EXIT_TRUST_PATH || denial.code == EXIT_KEY_NOT_FOUND,
|
||||
"expected to reach the keyring, got {} ({})",
|
||||
denial.code,
|
||||
denial.reason
|
||||
);
|
||||
assert!(!state.exists(), "envelope path wrote a replay record");
|
||||
|
||||
std::env::remove_var("REDFLAG_HELPER_KEYRING");
|
||||
std::env::remove_var("REDFLAG_HELPER_STATE");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// verify-envelope — dormant mutation-envelope verification (ARCH-002 cut 2)
|
||||
//
|
||||
// Proves the replacement authority object can enter this helper, be understood
|
||||
// independently, and be refused safely. No backend is executable through it, so
|
||||
// every invocation ends in a typed refusal and leaves a receipt.
|
||||
//
|
||||
// Deliberately absent: the replay ledger. Execute mode records a token only
|
||||
// after a plan is built, so an operation that cannot run never burns its slot.
|
||||
// Nothing here can run; nothing here records. Replay stays unproven until the
|
||||
// first backend migrates.
|
||||
//
|
||||
// Also deliberately absent: artifact custody. When a backend carries a local
|
||||
// cached artifact as executor input, the helper must rehash it before use.
|
||||
// Remote repository execution keeps the limits RAF already states.
|
||||
// ============================================================================
|
||||
|
||||
fn read_envelope_from_file(path: &str) -> Result<MutationEnvelope, Denial> {
|
||||
let raw = fs::read_to_string(path).map_err(|e| {
|
||||
Denial::new(EXIT_BAD_TOKEN, "envelope_file_read_failed", format!("{}: {}", path, e))
|
||||
})?;
|
||||
serde_json::from_str(&raw)
|
||||
.map_err(|e| Denial::new(EXIT_BAD_TOKEN, "envelope_parse_failed", e.to_string()))
|
||||
}
|
||||
|
||||
// Enough shape to fail closed without inventing backend semantics: the common
|
||||
// layer never reads inside a payload, but it refuses one that could not be a
|
||||
// backend action in the first place.
|
||||
fn validate_backend_payload_shape(manifest: &MutationManifest) -> Result<(), Denial> {
|
||||
for (index, action) in manifest.resolved_actions.iter().enumerate() {
|
||||
match serde_json::from_str::<serde_json::Value>(&action.payload) {
|
||||
Ok(serde_json::Value::Object(_)) => {}
|
||||
Ok(_) => {
|
||||
return Err(Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"backend_payload_not_object",
|
||||
format!("resolved action {} identity={}", index, action.identity),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"backend_payload_not_json",
|
||||
format!("resolved action {}: {}", index, e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Always denies. Ordered so each refusal gets its own exit code instead of one
|
||||
// opaque verification failure.
|
||||
fn run_envelope(envelope_file: &str, now: i64) -> (Option<MutationEnvelope>, Denial) {
|
||||
let envelope = match read_envelope_from_file(envelope_file) {
|
||||
Ok(e) => e,
|
||||
Err(d) => return (None, d),
|
||||
};
|
||||
|
||||
// Evaluate the denial before moving the envelope into the return — every
|
||||
// detail string reads from it.
|
||||
macro_rules! deny {
|
||||
($d:expr) => {{
|
||||
let denial = $d;
|
||||
return (Some(envelope), denial);
|
||||
}};
|
||||
}
|
||||
|
||||
if envelope.manifest.protocol_version != mutation_protocol::MUTATION_PROTOCOL_VERSION
|
||||
|| envelope.authorization.protocol_version != mutation_protocol::MUTATION_PROTOCOL_VERSION
|
||||
{
|
||||
deny!(Denial::new(
|
||||
EXIT_VERSION,
|
||||
"unsupported_envelope_version",
|
||||
format!(
|
||||
"manifest={} authorization={} supported={}",
|
||||
envelope.manifest.protocol_version,
|
||||
envelope.authorization.protocol_version,
|
||||
mutation_protocol::MUTATION_PROTOCOL_VERSION
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(e) = envelope.manifest.validate() {
|
||||
deny!(Denial::new(EXIT_BAD_TOKEN, "manifest_invalid", e));
|
||||
}
|
||||
|
||||
if !mutation_protocol::is_canonical_uuid_v4(&envelope.authorization.authorization_id) {
|
||||
deny!(Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"authorization_id_not_uuid_v4",
|
||||
"authorization_id must be a canonical UUID v4 before it can key a replay ledger",
|
||||
));
|
||||
}
|
||||
|
||||
// target_id is the RedFlag agent identity. Both copies are signed and the
|
||||
// verifier requires them equal; both are compared anyway, because the cost
|
||||
// is a string compare and the failure would be silent.
|
||||
let local = match local_agent_id() {
|
||||
Ok(v) => v,
|
||||
Err(d) => deny!(d),
|
||||
};
|
||||
let targets = [
|
||||
("manifest", envelope.manifest.target_id.clone()),
|
||||
("authorization", envelope.authorization.target_id.clone()),
|
||||
];
|
||||
for (field, value) in targets {
|
||||
if value != local {
|
||||
deny!(Denial::new(
|
||||
EXIT_AGENT_MISMATCH,
|
||||
"envelope_target_mismatch",
|
||||
format!("{}_target_id={} host_agent_id={}", field, value, local),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if now < envelope.authorization.not_before {
|
||||
deny!(Denial::new(
|
||||
EXIT_TIME_WINDOW,
|
||||
"envelope_not_yet_valid",
|
||||
format!("now={} not_before={}", now, envelope.authorization.not_before),
|
||||
));
|
||||
}
|
||||
if now > envelope.authorization.expires_at {
|
||||
deny!(Denial::new(
|
||||
EXIT_TIME_WINDOW,
|
||||
"envelope_expired",
|
||||
format!("now={} expires_at={}", now, envelope.authorization.expires_at),
|
||||
));
|
||||
}
|
||||
let lifetime = match envelope
|
||||
.authorization
|
||||
.expires_at
|
||||
.checked_sub(envelope.authorization.not_before)
|
||||
{
|
||||
Some(value) if value > 0 => value,
|
||||
_ => deny!(Denial::new(
|
||||
EXIT_TIME_WINDOW,
|
||||
"authorization_time_window_invalid",
|
||||
format!(
|
||||
"not_before={} expires_at={}",
|
||||
envelope.authorization.not_before, envelope.authorization.expires_at
|
||||
),
|
||||
)),
|
||||
};
|
||||
if lifetime > mutation_protocol::MAX_AUTHORIZATION_LIFETIME_SECS {
|
||||
deny!(Denial::new(
|
||||
EXIT_TIME_WINDOW,
|
||||
"authorization_lifetime_exceeded",
|
||||
format!(
|
||||
"lifetime={}s ceiling={}s",
|
||||
lifetime,
|
||||
mutation_protocol::MAX_AUTHORIZATION_LIFETIME_SECS
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if envelope.authorization.decision != "allow" {
|
||||
deny!(Denial::new(
|
||||
EXIT_AUTHORIZATION_DENIED,
|
||||
"authorization_decision_not_allow",
|
||||
format!("decision={}", envelope.authorization.decision),
|
||||
));
|
||||
}
|
||||
|
||||
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
|
||||
let keyring = match load_keyring(&keyring_dir) {
|
||||
Ok(k) => k,
|
||||
Err(d) => deny!(d),
|
||||
};
|
||||
let verifying_key = match keyring
|
||||
.iter()
|
||||
.find(|(id, _)| id == &envelope.authorization.key_id)
|
||||
.map(|(_, vk)| *vk)
|
||||
{
|
||||
Some(vk) => vk,
|
||||
None => deny!(Denial::new(
|
||||
EXIT_KEY_NOT_FOUND,
|
||||
"key_id_not_trusted",
|
||||
format!("key_id={}", envelope.authorization.key_id),
|
||||
)),
|
||||
};
|
||||
|
||||
if let Err(e) = envelope
|
||||
.authorization
|
||||
.verify_for_execution_at(&verifying_key, &envelope.manifest, now)
|
||||
{
|
||||
deny!(Denial::new(EXIT_SIGNATURE, "envelope_verification_failed", e));
|
||||
}
|
||||
|
||||
if let Err(d) = validate_backend_payload_shape(&envelope.manifest) {
|
||||
deny!(d);
|
||||
}
|
||||
|
||||
let backend = envelope.manifest.backend.clone();
|
||||
log_security(&format!(
|
||||
"envelope_verified operation_id={} manifest_hash={} authorization_id={} backend={} target_id={}",
|
||||
envelope.manifest.operation_id,
|
||||
envelope.manifest.hash(),
|
||||
envelope.authorization.authorization_id,
|
||||
backend,
|
||||
envelope.manifest.target_id
|
||||
));
|
||||
deny!(Denial::new(
|
||||
EXIT_UNSUPPORTED_OP,
|
||||
"backend_not_migrated",
|
||||
format!("backend={} has no executable path through the envelope yet", backend),
|
||||
))
|
||||
}
|
||||
|
||||
fn run_verify_envelope_cli(args: &[String]) -> i32 {
|
||||
let mut envelope_file: Option<String> = None;
|
||||
let mut receipt_file: Option<String> = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--envelope-file" => {
|
||||
envelope_file = args.get(i + 1).cloned();
|
||||
i += 2;
|
||||
}
|
||||
"--receipt-file" => {
|
||||
receipt_file = args.get(i + 1).cloned();
|
||||
i += 2;
|
||||
}
|
||||
other => {
|
||||
log_error(&format!("verify-envelope unknown argument {}", other));
|
||||
return EXIT_BAD_TOKEN;
|
||||
}
|
||||
}
|
||||
}
|
||||
let envelope_file = match envelope_file {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
log_error("verify-envelope usage: redflag-helper verify-envelope --envelope-file <path> [--receipt-file <path>]");
|
||||
return EXIT_BAD_TOKEN;
|
||||
}
|
||||
};
|
||||
|
||||
let now = now_unix();
|
||||
let (envelope, denial) = run_envelope(&envelope_file, now);
|
||||
let decision = if denial.code == EXIT_EXEC_FAILED { "failed" } else { "denied" };
|
||||
log_security(&format!(
|
||||
"{} reason={} detail={} exit={}",
|
||||
decision, denial.reason, denial.detail, denial.code
|
||||
));
|
||||
|
||||
let receipt = match envelope {
|
||||
Some(e) => e.receipt_for(
|
||||
MutationOutcome {
|
||||
decision: decision.to_string(),
|
||||
reason: denial.reason.to_string(),
|
||||
executed: false,
|
||||
verified_actions: 0,
|
||||
exit_code: denial.code,
|
||||
detail: denial.detail.clone(),
|
||||
},
|
||||
now,
|
||||
),
|
||||
None => MutationReceipt {
|
||||
protocol_version: mutation_protocol::MUTATION_PROTOCOL_VERSION,
|
||||
operation_id: String::new(),
|
||||
manifest_hash: String::new(),
|
||||
authorization_id: String::new(),
|
||||
target_id: String::new(),
|
||||
backend: String::new(),
|
||||
operation: String::new(),
|
||||
decision: decision.to_string(),
|
||||
reason: denial.reason.to_string(),
|
||||
executed: false,
|
||||
verified_actions: 0,
|
||||
exit_code: denial.code,
|
||||
error: denial.detail.clone(),
|
||||
timestamp: now,
|
||||
},
|
||||
};
|
||||
if let Some(rp) = receipt_file {
|
||||
emit_result_to_file(&receipt, &rp);
|
||||
}
|
||||
emit_result(&receipt);
|
||||
denial.code
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -2190,6 +2586,12 @@ fn main() {
|
|||
std::process::exit(run_mint_cli(&args[2..]));
|
||||
}
|
||||
|
||||
// "verify-envelope" is the dormant mutation-envelope path. It verifies and
|
||||
// refuses; no backend executes through it yet.
|
||||
if args.get(1).map(|s| s.as_str()) == Some("verify-envelope") {
|
||||
std::process::exit(run_verify_envelope_cli(&args[2..]));
|
||||
}
|
||||
|
||||
// --token-file <path> reads the capability token from a file instead of stdin.
|
||||
// This avoids SCM_RIGHTS fd-passing via systemd-run --pipe, which dbus-broker 37
|
||||
// on Fedora 43 drops (MSG_CTRUNC) for new-connection handshake messages.
|
||||
|
|
|
|||
|
|
@ -208,4 +208,21 @@ still produces a receipt, and its emptiness is part of the record.
|
|||
- tamper refusal for provenance, execution location, target, backend, resolved action,
|
||||
authorization metadata, decision, time window, and unknown formats.
|
||||
|
||||
Go tests live in both capability packages. Rust tests live in `helper/src/mutation_protocol.rs`.
|
||||
Go tests live in both capability packages. Rust tests live in
|
||||
`helper/src/mutation_protocol.rs`, and the helper's own envelope-path tests live in
|
||||
`helper/src/main.rs`.
|
||||
|
||||
## Executor status
|
||||
|
||||
`redflag-helper verify-envelope --envelope-file <path> [--receipt-file <path>]` verifies an
|
||||
envelope through the helper's real pipeline — parse, host binding, trusted keyring,
|
||||
signature, time, lifetime ceiling, backend payload shape — and then refuses with
|
||||
`backend_not_migrated`, because no backend executes through this path yet.
|
||||
|
||||
It records **no replay state**. Execute mode records a token only after a plan is built, so
|
||||
an operation that cannot run never burns its slot; nothing here can run, so nothing here
|
||||
records. Replay identity stays unproven until the first backend migrates.
|
||||
|
||||
It also enforces no artifact custody. When a backend carries a local cached artifact as
|
||||
executor input, the helper must rehash it before use. Remote repository execution keeps the
|
||||
limits `RAF/security/05-supply-chain-gate.md` already states.
|
||||
|
|
|
|||
Loading…
Reference in a new issue