Sealed approvals: the approval attestation and oxy seal --flow b582948f1912
A person's approval (or rejection) of one job of one OxygenFlow run, signed with their own key: attestation kind approval (run, job, job hash, state, sorted input blobs, environment, approve), SignedApproval ([attest, sigs, identity], verifies standalone; covers() matches exactly one statement). oxy seal --flow https://hub/OWNER/NAME/flow/N --job NAME [--reject] fetches the hub's statement, checks it is this repository's, signs and posts it. Spec 14.4. Tests: round trip; another plan, state, job, run, environment or verdict is not covered; a changed or wrongly signed approval fails. Refs andymccall/oxygenhub#235
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 09:45 UTC
- Signature
- ✔ signed by Claude Code
- Hash
b582948f1912885006d8a3c2471bcd6ae0acc2b2172055b53e005846938dd2d0- Depends on
- 4213c41dfc64 5b9c6ad45f6e 75167672ebcd d42369abf180 d443639bcf35 d82e44097323 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d ff1f62fee654
- Depended on by
- 6c3dbe2018e0 642da2636de2 f53cbe7a204e
- In bookmarks
- carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 15 atoms, 2 tree operations, 6 files
Browse the tree at this change
added crates/oxy-core/src/approval.rs +228 −0
| 1 | + | //! A person's approval of one job of one run, signed (oxygenhub#235): the |
|
| 2 | + | //! approval attestation, the signer's signature over it, and the signer's |
|
| 3 | + | //! identity, so it verifies with nothing else. The hub decides whether the |
|
| 4 | + | //! signer may approve; the signature makes the decision theirs, and binds |
|
| 5 | + | //! it to exactly that job, state, plan and environment. |
|
| 6 | + | ||
| 7 | + | use crate::attest::{Attest, AttestBody}; |
|
| 8 | + | use crate::cbor::{self, Value}; |
|
| 9 | + | use crate::hash::ObjectRef; |
|
| 10 | + | use crate::identity::{verify, Domain, Identity, SecretKey, Signature}; |
|
| 11 | + | use crate::objects::{array, bytes, Hlc, ObjectError}; |
|
| 12 | + | ||
| 13 | + | /// What is approved. |
|
| 14 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 15 | + | pub struct Statement { |
|
| 16 | + | pub repo: [u8; 16], |
|
| 17 | + | /// The run, as its URL on the hub. |
|
| 18 | + | pub run: String, |
|
| 19 | + | pub job: String, |
|
| 20 | + | /// The job's hash with its sandbox (OxygenFlow spec 6.2). |
|
| 21 | + | pub job_hash: ObjectRef, |
|
| 22 | + | pub state: ObjectRef, |
|
| 23 | + | /// The blobs the job takes as inputs (a plan), any order. |
|
| 24 | + | pub inputs: Vec<ObjectRef>, |
|
| 25 | + | pub environment: String, |
|
| 26 | + | pub approve: bool, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | #[derive(Debug, thiserror::Error)] |
|
| 30 | + | pub enum ApprovalError { |
|
| 31 | + | #[error("the attestation is not an approval")] |
|
| 32 | + | NotApproval, |
|
| 33 | + | #[error("no signature by the signer {0}")] |
|
| 34 | + | Unsigned(String), |
|
| 35 | + | #[error("the signature does not verify: {0}")] |
|
| 36 | + | BadSignature(String), |
|
| 37 | + | #[error("{0}")] |
|
| 38 | + | Schema(String), |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | impl From<ObjectError> for ApprovalError { |
|
| 42 | + | fn from(e: ObjectError) -> Self { |
|
| 43 | + | ApprovalError::Schema(e.to_string()) |
|
| 44 | + | } |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 48 | + | pub struct SignedApproval { |
|
| 49 | + | pub attest: Attest, |
|
| 50 | + | pub sigs: Vec<Signature>, |
|
| 51 | + | pub signer: Identity, |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | impl SignedApproval { |
|
| 55 | + | pub fn sign(s: &Statement, signer: &Identity, sk: &SecretKey) -> SignedApproval { |
|
| 56 | + | let mut inputs = s.inputs.clone(); |
|
| 57 | + | inputs.sort(); |
|
| 58 | + | inputs.dedup(); |
|
| 59 | + | let attest = Attest { |
|
| 60 | + | repo: s.repo, |
|
| 61 | + | hlc: Hlc::now(), |
|
| 62 | + | body: AttestBody::Approval { |
|
| 63 | + | run: s.run.clone(), |
|
| 64 | + | job: s.job.clone(), |
|
| 65 | + | job_hash: s.job_hash, |
|
| 66 | + | state: s.state, |
|
| 67 | + | inputs, |
|
| 68 | + | environment: s.environment.clone(), |
|
| 69 | + | approve: s.approve, |
|
| 70 | + | }, |
|
| 71 | + | }; |
|
| 72 | + | let sig = sk.sign(Domain::Attest, &signer.hash(), &attest.hash()); |
|
| 73 | + | SignedApproval { |
|
| 74 | + | attest, |
|
| 75 | + | sigs: vec![sig], |
|
| 76 | + | signer: signer.clone(), |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | /// What was approved, read back from the attestation. |
|
| 81 | + | pub fn statement(&self) -> Result<Statement, ApprovalError> { |
|
| 82 | + | let AttestBody::Approval { |
|
| 83 | + | run, |
|
| 84 | + | job, |
|
| 85 | + | job_hash, |
|
| 86 | + | state, |
|
| 87 | + | inputs, |
|
| 88 | + | environment, |
|
| 89 | + | approve, |
|
| 90 | + | } = &self.attest.body |
|
| 91 | + | else { |
|
| 92 | + | return Err(ApprovalError::NotApproval); |
|
| 93 | + | }; |
|
| 94 | + | Ok(Statement { |
|
| 95 | + | repo: self.attest.repo, |
|
| 96 | + | run: run.clone(), |
|
| 97 | + | job: job.clone(), |
|
| 98 | + | job_hash: *job_hash, |
|
| 99 | + | state: *state, |
|
| 100 | + | inputs: inputs.clone(), |
|
| 101 | + | environment: environment.clone(), |
|
| 102 | + | approve: *approve, |
|
| 103 | + | }) |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | /// It is an approval and the signer signed it. Whether the signer may |
|
| 107 | + | /// approve is the reader's call. |
|
| 108 | + | pub fn verify(&self) -> Result<(), ApprovalError> { |
|
| 109 | + | self.statement()?; |
|
| 110 | + | let key = self.signer.hash(); |
|
| 111 | + | let sig = self |
|
| 112 | + | .sigs |
|
| 113 | + | .iter() |
|
| 114 | + | .find(|s| s.key == key) |
|
| 115 | + | .ok_or_else(|| ApprovalError::Unsigned(key.short(12)))?; |
|
| 116 | + | verify(&self.signer, sig, Domain::Attest, &self.attest.hash()) |
|
| 117 | + | .map_err(|e| ApprovalError::BadSignature(e.to_string())) |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | /// Whether `s` is what this approves (the time it was made aside). |
|
| 121 | + | pub fn covers(&self, s: &Statement) -> bool { |
|
| 122 | + | let mut want = s.inputs.clone(); |
|
| 123 | + | want.sort(); |
|
| 124 | + | want.dedup(); |
|
| 125 | + | self.statement().is_ok_and(|mine| { |
|
| 126 | + | mine == Statement { |
|
| 127 | + | inputs: want, |
|
| 128 | + | ..s.clone() |
|
| 129 | + | } |
|
| 130 | + | }) |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | /// `[attest, [Signature], identity]`, each object as its bytes. |
|
| 134 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 135 | + | cbor::encode(&Value::Array(vec![ |
|
| 136 | + | Value::Bytes(self.attest.encode()), |
|
| 137 | + | Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), |
|
| 138 | + | Value::Bytes(self.signer.encode()), |
|
| 139 | + | ])) |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | pub fn decode(b: &[u8]) -> Result<SignedApproval, ApprovalError> { |
|
| 143 | + | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 144 | + | let a = array(&v, "signed approval")?; |
|
| 145 | + | if a.len() != 3 { |
|
| 146 | + | return Err(ApprovalError::Schema( |
|
| 147 | + | "signed approval: expected [attest, sigs, identity]".into(), |
|
| 148 | + | )); |
|
| 149 | + | } |
|
| 150 | + | Ok(SignedApproval { |
|
| 151 | + | attest: Attest::decode(bytes(&a[0], "attest")?)?, |
|
| 152 | + | sigs: array(&a[1], "sigs")? |
|
| 153 | + | .iter() |
|
| 154 | + | .map(Signature::from_value) |
|
| 155 | + | .collect::<Result<_, _>>()?, |
|
| 156 | + | signer: Identity::decode(bytes(&a[2], "identity")?)?, |
|
| 157 | + | }) |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | #[cfg(test)] |
|
| 162 | + | mod tests { |
|
| 163 | + | use super::*; |
|
| 164 | + | use crate::identity::SecretKey; |
|
| 165 | + | ||
| 166 | + | fn statement() -> Statement { |
|
| 167 | + | Statement { |
|
| 168 | + | repo: [7; 16], |
|
| 169 | + | run: "https://hub.example/andy/infra/flow/8".into(), |
|
| 170 | + | job: "apply".into(), |
|
| 171 | + | job_hash: ObjectRef([1; 32]), |
|
| 172 | + | state: ObjectRef([2; 32]), |
|
| 173 | + | inputs: vec![ObjectRef([4; 32]), ObjectRef([3; 32])], |
|
| 174 | + | environment: "prod".into(), |
|
| 175 | + | approve: true, |
|
| 176 | + | } |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | #[test] |
|
| 180 | + | fn an_approval_signs_exactly_one_job_state_plan_and_environment() { |
|
| 181 | + | let sk = SecretKey::generate(); |
|
| 182 | + | let id = sk.identity("Andy", "[email protected]", 1); |
|
| 183 | + | let sa = SignedApproval::sign(&statement(), &id, &sk); |
|
| 184 | + | sa.verify().unwrap(); |
|
| 185 | + | let back = SignedApproval::decode(&sa.encode()).unwrap(); |
|
| 186 | + | assert_eq!(back, sa, "round trip"); |
|
| 187 | + | assert!(back.covers(&statement()), "inputs in any order"); |
|
| 188 | + | for other in [ |
|
| 189 | + | Statement { |
|
| 190 | + | inputs: vec![ObjectRef([9; 32])], |
|
| 191 | + | ..statement() |
|
| 192 | + | }, |
|
| 193 | + | Statement { |
|
| 194 | + | state: ObjectRef([8; 32]), |
|
| 195 | + | ..statement() |
|
| 196 | + | }, |
|
| 197 | + | Statement { |
|
| 198 | + | job_hash: ObjectRef([8; 32]), |
|
| 199 | + | ..statement() |
|
| 200 | + | }, |
|
| 201 | + | Statement { |
|
| 202 | + | run: "https://hub.example/andy/infra/flow/9".into(), |
|
| 203 | + | ..statement() |
|
| 204 | + | }, |
|
| 205 | + | Statement { |
|
| 206 | + | environment: "dev".into(), |
|
| 207 | + | ..statement() |
|
| 208 | + | }, |
|
| 209 | + | Statement { |
|
| 210 | + | approve: false, |
|
| 211 | + | ..statement() |
|
| 212 | + | }, |
|
| 213 | + | ] { |
|
| 214 | + | assert!(!back.covers(&other), "{other:?}"); |
|
| 215 | + | } |
|
| 216 | + | // Changed after signing: the signature no longer holds. |
|
| 217 | + | let mut forged = sa.clone(); |
|
| 218 | + | if let AttestBody::Approval { inputs, .. } = &mut forged.attest.body { |
|
| 219 | + | inputs[0] = ObjectRef([9; 32]); |
|
| 220 | + | } |
|
| 221 | + | assert!(forged.verify().is_err()); |
|
| 222 | + | // Signed by someone else than the identity it carries. |
|
| 223 | + | let other_sk = SecretKey::generate(); |
|
| 224 | + | let mut stolen = SignedApproval::sign(&statement(), &id, &other_sk); |
|
| 225 | + | stolen.signer = id.clone(); |
|
| 226 | + | assert!(stolen.verify().is_err()); |
|
| 227 | + | } |
|
| 228 | + | } |
added src/cli/approve.rs +151 −0
| 1 | + | //! `oxy seal --flow RUN_URL --job NAME [--reject]` (oxygenhub#235): sign an |
|
| 2 | + | //! approval of one job of one run with your key, so the hub can show, and |
|
| 3 | + | //! anyone can check, who released it. The hub says what the job is (its |
|
| 4 | + | //! hash with its sandbox, the state, the plan it takes, the environment); |
|
| 5 | + | //! the signature covers exactly that and nothing else. |
|
| 6 | + | ||
| 7 | + | use super::{open, ui}; |
|
| 8 | + | use anyhow::{anyhow, bail, Context, Result}; |
|
| 9 | + | use oxy_core::approval::{SignedApproval, Statement}; |
|
| 10 | + | use oxy_core::hash::ObjectRef; |
|
| 11 | + | use serde_json::Value; |
|
| 12 | + | ||
| 13 | + | /// `https://hub/OWNER/NAME/flow/N` to the hub's base, `OWNER/NAME`, N and |
|
| 14 | + | /// the repository's URL. |
|
| 15 | + | fn run_url(url: &str) -> Result<(String, String, i64, String)> { |
|
| 16 | + | let url = url.trim_end_matches('/'); |
|
| 17 | + | let (repo_url, n) = url |
|
| 18 | + | .rsplit_once("/flow/") |
|
| 19 | + | .ok_or_else(|| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; |
|
| 20 | + | let number: i64 = n |
|
| 21 | + | .parse() |
|
| 22 | + | .map_err(|_| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; |
|
| 23 | + | let (rest, name) = repo_url |
|
| 24 | + | .rsplit_once('/') |
|
| 25 | + | .ok_or_else(|| anyhow!("{url} is not a run URL"))?; |
|
| 26 | + | let (base, owner) = rest |
|
| 27 | + | .rsplit_once('/') |
|
| 28 | + | .ok_or_else(|| anyhow!("{url} is not a run URL"))?; |
|
| 29 | + | Ok(( |
|
| 30 | + | base.to_string(), |
|
| 31 | + | format!("{owner}/{name}"), |
|
| 32 | + | number, |
|
| 33 | + | repo_url.to_string(), |
|
| 34 | + | )) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | fn to_hex(b: &[u8]) -> String { |
|
| 38 | + | b.iter().map(|x| format!("{x:02x}")).collect() |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | fn oref(v: &Value, key: &str) -> Result<ObjectRef> { |
|
| 42 | + | v.get(key) |
|
| 43 | + | .and_then(Value::as_str) |
|
| 44 | + | .and_then(ObjectRef::from_hex) |
|
| 45 | + | .ok_or_else(|| anyhow!("the hub's statement has no valid {key}")) |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | pub fn seal(url: &str, job: &str, reject: bool, as_json: bool) -> Result<i32> { |
|
| 49 | + | let repo = open()?; |
|
| 50 | + | let (base, slug, number, repo_url) = run_url(url)?; |
|
| 51 | + | let options = repo.options_for_url(&repo_url); |
|
| 52 | + | let api = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/approval"); |
|
| 53 | + | let body = oxy_core::protocol::http_get(&api, &options).map_err(|e| anyhow!("{e}"))?; |
|
| 54 | + | let v: Value = serde_json::from_slice(&body).context("the hub did not answer with JSON")?; |
|
| 55 | + | let repo_hex = v.get("repo").and_then(Value::as_str).unwrap_or(""); |
|
| 56 | + | let mine = to_hex(&repo.id()?); |
|
| 57 | + | if repo_hex != mine { |
|
| 58 | + | bail!("run #{number} belongs to repository {repo_hex}, not this checkout's ({mine}): run oxy seal --flow from a checkout of {slug}"); |
|
| 59 | + | } |
|
| 60 | + | let mut repo_id = [0u8; 16]; |
|
| 61 | + | for (i, b) in repo_id.iter_mut().enumerate() { |
|
| 62 | + | *b = u8::from_str_radix(&repo_hex[i * 2..i * 2 + 2], 16)?; |
|
| 63 | + | } |
|
| 64 | + | let inputs = v |
|
| 65 | + | .get("inputs") |
|
| 66 | + | .and_then(Value::as_array) |
|
| 67 | + | .map(|a| { |
|
| 68 | + | a.iter() |
|
| 69 | + | .filter_map(|x| x.as_str().and_then(ObjectRef::from_hex)) |
|
| 70 | + | .collect::<Vec<_>>() |
|
| 71 | + | }) |
|
| 72 | + | .unwrap_or_default(); |
|
| 73 | + | let statement = Statement { |
|
| 74 | + | repo: repo_id, |
|
| 75 | + | run: v |
|
| 76 | + | .get("run") |
|
| 77 | + | .and_then(Value::as_str) |
|
| 78 | + | .unwrap_or(url) |
|
| 79 | + | .to_string(), |
|
| 80 | + | job: job.to_string(), |
|
| 81 | + | job_hash: oref(&v, "job_hash")?, |
|
| 82 | + | state: oref(&v, "state")?, |
|
| 83 | + | inputs, |
|
| 84 | + | environment: v |
|
| 85 | + | .get("environment") |
|
| 86 | + | .and_then(Value::as_str) |
|
| 87 | + | .unwrap_or("") |
|
| 88 | + | .to_string(), |
|
| 89 | + | approve: !reject, |
|
| 90 | + | }; |
|
| 91 | + | let (key, sk) = repo |
|
| 92 | + | .secret_key()? |
|
| 93 | + | .ok_or_else(|| anyhow!("no signing key: create one with `oxy identity new`"))?; |
|
| 94 | + | let me = repo.identity(&key)?; |
|
| 95 | + | let signed = SignedApproval::sign(&statement, &me, &sk); |
|
| 96 | + | let post = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/seal"); |
|
| 97 | + | let payload = serde_json::json!({ "signed": to_hex(&signed.encode()) }).to_string(); |
|
| 98 | + | let (code, answer) = oxy_core::protocol::http_json("POST", &post, Some(&payload), &options) |
|
| 99 | + | .map_err(|e| anyhow!("{e}"))?; |
|
| 100 | + | if !(200..300).contains(&code) { |
|
| 101 | + | let why = serde_json::from_str::<Value>(&answer) |
|
| 102 | + | .ok() |
|
| 103 | + | .and_then(|v| v.get("error").and_then(Value::as_str).map(String::from)); |
|
| 104 | + | bail!( |
|
| 105 | + | "the hub refused the seal (HTTP {code}): {}", |
|
| 106 | + | why.unwrap_or(answer) |
|
| 107 | + | ); |
|
| 108 | + | } |
|
| 109 | + | if as_json { |
|
| 110 | + | println!("{answer}"); |
|
| 111 | + | } else { |
|
| 112 | + | let verb = if reject { "Rejected" } else { "Approved" }; |
|
| 113 | + | println!( |
|
| 114 | + | "{} {job} of run #{number} with key {}", |
|
| 115 | + | ui::done(verb), |
|
| 116 | + | &key.to_hex()[..12] |
|
| 117 | + | ); |
|
| 118 | + | println!( |
|
| 119 | + | " state {} · job {}", |
|
| 120 | + | &statement.state.to_hex()[..12], |
|
| 121 | + | &statement.job_hash.to_hex()[..12] |
|
| 122 | + | ); |
|
| 123 | + | for i in &statement.inputs { |
|
| 124 | + | println!(" takes {}", &i.to_hex()[..12]); |
|
| 125 | + | } |
|
| 126 | + | if !statement.environment.is_empty() { |
|
| 127 | + | println!(" for environment {}", statement.environment); |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | Ok(0) |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | #[cfg(test)] |
|
| 134 | + | mod tests { |
|
| 135 | + | #[test] |
|
| 136 | + | fn run_urls_are_read() { |
|
| 137 | + | let (base, slug, n, repo) = |
|
| 138 | + | super::run_url("https://oxygenhub.io/andymccall/oxygenhub-gcp/flow/8/").unwrap(); |
|
| 139 | + | assert_eq!( |
|
| 140 | + | (base.as_str(), slug.as_str(), n, repo.as_str()), |
|
| 141 | + | ( |
|
| 142 | + | "https://oxygenhub.io", |
|
| 143 | + | "andymccall/oxygenhub-gcp", |
|
| 144 | + | 8, |
|
| 145 | + | "https://oxygenhub.io/andymccall/oxygenhub-gcp" |
|
| 146 | + | ) |
|
| 147 | + | ); |
|
| 148 | + | assert!(super::run_url("https://oxygenhub.io/andymccall/x/proposal/3").is_err()); |
|
| 149 | + | assert!(super::run_url("https://oxygenhub.io/andymccall/x/flow/abc").is_err()); |
|
| 150 | + | } |
|
| 151 | + | } |
modified crates/oxy-core/src/attest.rs +58 −0
| … | 36 unchanged lines |
||
| 37 | 37 | state: ObjectRef, |
|
| 38 | 38 | run: String, |
|
| 39 | 39 | }, |
|
| 40 | + | /// A person approved (or rejected) one job of one run (OxygenFlow |
|
| 41 | + | /// approval gates; oxygenhub#235): the job as hashed with its sandbox, |
|
| 42 | + | /// on that state, taking exactly those input blobs (a reviewed plan), |
|
| 43 | + | /// for that environment. It approves nothing else. |
|
| 44 | + | Approval { |
|
| 45 | + | run: String, |
|
| 46 | + | job: String, |
|
| 47 | + | job_hash: ObjectRef, |
|
| 48 | + | state: ObjectRef, |
|
| 49 | + | inputs: Vec<ObjectRef>, |
|
| 50 | + | environment: String, |
|
| 51 | + | approve: bool, |
|
| 52 | + | }, |
|
| 40 | 53 | } |
|
| 41 | 54 | ||
| 42 | 55 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| … | 13 unchanged lines |
||
| 56 | 69 | AttestBody::Rotate { .. } => "rotate", |
|
| 57 | 70 | AttestBody::Revoke { .. } => "revoke", |
|
| 58 | 71 | AttestBody::Receipt { .. } => "receipt", |
|
| 72 | + | AttestBody::Approval { .. } => "approval", |
|
| 59 | 73 | } |
|
| 60 | 74 | } |
|
| 61 | 75 | ||
| … | 46 unchanged lines |
||
| 108 | 122 | (0, Value::Bytes(receipt.0.to_vec())), |
|
| 109 | 123 | (1, Value::Bytes(state.0.to_vec())), |
|
| 110 | 124 | (2, Value::Text(run.clone())), |
|
| 125 | + | ]), |
|
| 126 | + | AttestBody::Approval { |
|
| 127 | + | run, |
|
| 128 | + | job, |
|
| 129 | + | job_hash, |
|
| 130 | + | state, |
|
| 131 | + | inputs, |
|
| 132 | + | environment, |
|
| 133 | + | approve, |
|
| 134 | + | } => Value::map([ |
|
| 135 | + | (0, Value::Text(run.clone())), |
|
| 136 | + | (1, Value::Text(job.clone())), |
|
| 137 | + | (2, Value::Bytes(job_hash.0.to_vec())), |
|
| 138 | + | (3, Value::Bytes(state.0.to_vec())), |
|
| 139 | + | ( |
|
| 140 | + | 4, |
|
| 141 | + | Value::Array(inputs.iter().map(|i| Value::Bytes(i.0.to_vec())).collect()), |
|
| 142 | + | ), |
|
| 143 | + | (5, Value::Text(environment.clone())), |
|
| 144 | + | (6, Value::Uint(u64::from(*approve))), |
|
| 111 | 145 | ]), |
|
| 112 | 146 | }; |
|
| 113 | 147 | Value::map([ |
|
| … | 62 unchanged lines |
||
| 176 | 210 | state: oref(field(b, 1, "state")?, "state")?, |
|
| 177 | 211 | run: text(field(b, 2, "run")?, "run")?.to_owned(), |
|
| 178 | 212 | }, |
|
| 213 | + | "approval" => { |
|
| 214 | + | let inputs = array(field(b, 4, "inputs")?, "inputs")? |
|
| 215 | + | .iter() |
|
| 216 | + | .map(|h| oref(h, "input")) |
|
| 217 | + | .collect::<Result<Vec<_>, _>>()?; |
|
| 218 | + | if !inputs.windows(2).all(|w| w[0] < w[1]) { |
|
| 219 | + | return Err(ObjectError::Schema( |
|
| 220 | + | "approval: inputs must be sorted and unique".into(), |
|
| 221 | + | )); |
|
| 222 | + | } |
|
| 223 | + | AttestBody::Approval { |
|
| 224 | + | run: text(field(b, 0, "run")?, "run")?.to_owned(), |
|
| 225 | + | job: text(field(b, 1, "job")?, "job")?.to_owned(), |
|
| 226 | + | job_hash: oref(field(b, 2, "job_hash")?, "job_hash")?, |
|
| 227 | + | state: oref(field(b, 3, "state")?, "state")?, |
|
| 228 | + | inputs, |
|
| 229 | + | environment: text(field(b, 5, "environment")?, "environment")?.to_owned(), |
|
| 230 | + | approve: match uint(field(b, 6, "approve")?, "approve")? { |
|
| 231 | + | 0 => false, |
|
| 232 | + | 1 => true, |
|
| 233 | + | _ => return Err(ObjectError::Schema("approval: approve is 0 or 1".into())), |
|
| 234 | + | }, |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 179 | 237 | other => { |
|
| 180 | 238 | return Err(ObjectError::Schema(format!( |
|
| 181 | 239 | "unknown attestation kind {other:?}" |
|
| … | 68 unchanged lines |
modified crates/oxy-core/src/lib.rs +1 −0
| … | 5 unchanged lines |
||
| 6 | 6 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); |
|
| 7 | 7 | ||
| 8 | 8 | pub mod alloc_stats; |
|
| 9 | + | pub mod approval; |
|
| 9 | 10 | pub mod attest; |
|
| 10 | 11 | pub mod attributes; |
|
| 11 | 12 | pub mod cbor; |
|
| … | 25 unchanged lines |
modified docs/spec.md +5 −1
| … | 624 unchanged lines |
||
| 625 | 625 | ``` |
|
| 626 | 626 | Attest { |
|
| 627 | 627 | version [0]: 1 |
|
| 628 | - | kind [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" |
|
| 628 | + | kind [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" | "approval" |
|
| 629 | 629 | repo [2]: bytes(16) // repository ID, chosen at init |
|
| 630 | 630 | hlc [3]: HLC |
|
| 631 | 631 | body [4]: map // per kind |
|
| … | 5 unchanged lines |
||
| 637 | 637 | revoke: { key: KeyId, reason } |
|
| 638 | 638 | lock: { node: NodeId, path, acquire: bool, expires } |
|
| 639 | 639 | receipt: { receipt: ObjectRef, state: ObjectRef, run: text } // §14.6 |
|
| 640 | + | approval: { run: text, job: text, job_hash: ObjectRef, state: ObjectRef, |
|
| 641 | + | inputs: [ObjectRef], environment: text, approve: 0 | 1 } // sorted, unique inputs |
|
| 640 | 642 | ``` |
|
| 641 | 643 | ||
| 644 | + | An **approval** is a person's decision about one job of one OxygenFlow run (an approval gate, OxygenFlow spec §5.3.2): the job as hashed with its sandbox, on that state, taking exactly those input blobs (for a deploy, the reviewed plan), for that environment. It approves nothing else: another run, state, plan or environment needs another approval. It is handed to the hub as `[attest bytes, [Signature], identity bytes]`, signed in domain `attest` by the approver's own key (`oxy seal --flow URL --job NAME`, `--reject` to reject), so it verifies with nothing else; whether the signer may approve is the hub's rule (a key linked to an account with the gate's role; delegated keys only where the repository allows). The hub keeps it in the repository, so it can be verified without the hub. |
|
| 645 | + | ||
| 642 | 646 | A **seal** is one signature over a batch of change hashes. A composite signature is 3373 bytes, which is larger than a typical change, so signing every change individually roughly doubles the size of history in high-frequency workflows. A seal replaces that with one signature per push. |
|
| 643 | 647 | ||
| 644 | 648 | **Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts. |
|
| … | 401 unchanged lines |
modified src/cli/mod.rs +18 −0
| 1 | 1 | //! Command-line definitions and dispatch. |
|
| 2 | 2 | ||
| 3 | + | mod approve; |
|
| 3 | 4 | mod auth; |
|
| 4 | 5 | mod format; |
|
| 5 | 6 | mod identity; |
|
| … | 408 unchanged lines |
||
| 414 | 415 | /// Seal every change of a hub proposal and push the seal (your approval) |
|
| 415 | 416 | #[arg(long, conflicts_with = "changes")] |
|
| 416 | 417 | proposal: Option<String>, |
|
| 418 | + | /// Approve a job of a hub run that waits for a sealed approval: `--flow https://hub/OWNER/NAME/flow/N --job apply` |
|
| 419 | + | #[arg(long, conflicts_with_all = ["changes", "proposal"], requires = "job")] |
|
| 420 | + | flow: Option<String>, |
|
| 421 | + | /// The job to approve, with --flow |
|
| 422 | + | #[arg(long, requires = "flow")] |
|
| 423 | + | job: Option<String>, |
|
| 424 | + | /// Reject the job instead of approving it, with --flow |
|
| 425 | + | #[arg(long, requires = "flow")] |
|
| 426 | + | reject: bool, |
|
| 417 | 427 | /// Machine-readable output: one JSON document on stdout |
|
| 418 | 428 | #[arg(long)] |
|
| 419 | 429 | json: bool, |
|
| … | 785 unchanged lines |
||
| 1205 | 1215 | PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out), |
|
| 1206 | 1216 | }, |
|
| 1207 | 1217 | Cmd::Seal { |
|
| 1218 | + | flow: Some(url), |
|
| 1219 | + | job: Some(job), |
|
| 1220 | + | reject, |
|
| 1221 | + | json, |
|
| 1222 | + | .. |
|
| 1223 | + | } => approve::seal(&url, &job, reject, json), |
|
| 1224 | + | Cmd::Seal { |
|
| 1208 | 1225 | changes, |
|
| 1209 | 1226 | proposal: Some(url), |
|
| 1210 | 1227 | json, |
|
| 1228 | + | .. |
|
| 1211 | 1229 | } if changes.is_empty() => proposal::seal(&url, json), |
|
| 1212 | 1230 | Cmd::Seal { changes, json, .. } => identity::seal(&changes, json), |
|
| 1213 | 1231 | Cmd::Proposal { cmd } => match cmd { |
|
| … | 45 unchanged lines |