git_attest/format.rs
1//! The attestation format: the payload's fields, the note's blocks, and the
2//! names every producer and verifier shares (SPEC.md, "The note").
3
4/// First token of every payload. A verifier that does not recognise this
5/// string must treat the note as absent: an unknown version may mean anything,
6/// and "run the tests" is the only safe reading of anything.
7pub const FORMAT: &str = "amont-attest-v2";
8
9/// The notes ref, as `git notes --ref` wants it.
10pub const NOTES_REF: &str = "amont-attest";
11
12/// The second notes ref, keyed by input fingerprint rather than by object.
13/// Every key there is a synthetic oid that is not an object, which is why it
14/// is a separate ref: `git notes prune` on it would drop everything, and the
15/// main ref keeps its 1.x invariant that every key is real.
16pub const INPUTS_REF: &str = "amont-attest-inputs";
17
18/// Where `sign.sh --no-push` keeps blocks that never reached origin, under
19/// `refs/notes/`, prefixed to either ref name above. Origin cannot revoke
20/// them, so a verifier reads them only when asked (`--include-local`).
21pub const LOCAL_PREFIX: &str = "attest-local/";
22
23/// The `ssh-keygen -Y` namespace. Namespaces exist so a signature minted for
24/// one purpose cannot be replayed for another; an `allowed_signers` entry
25/// pinned to this namespace accepts nothing else.
26pub const NAMESPACE: &str = "amont-attest";
27
28/// How many blocks of one note are read. Every block costs two `ssh-keygen`
29/// runs, and the notes ref is writable by anyone with push access.
30pub const MAX_BLOCKS: usize = 32;
31
32pub(crate) const BEGIN: &str = "-----BEGIN SSH SIGNATURE-----";
33pub(crate) const END: &str = "-----END SSH SIGNATURE-----";
34
35/// Where a suite ran, as `<arch>-<os>`. Coarser than a target triple on
36/// purpose: the libc flavour is not something `std` can answer, and the
37/// question a CI matrix actually asks is "did this run on MY leg".
38pub fn platform() -> String {
39 format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
40}
41
42/// The platform a signer principal is bound to: the text after its last `@`
43/// when that is `<arch>-<os>` over the closed sets below, else `None`. A
44/// principal that names no platform (`ci@relais`, an email address) is
45/// unbound, and so is one whose suffix has any other shape
46/// (`x@x86_64-linux-gnu`, `x@riscv64-linux`).
47pub fn bound_platform(principal: &str) -> Option<&str> {
48 const ARCHES: [&str; 2] = ["x86_64", "aarch64"];
49 const SYSTEMS: [&str; 3] = ["linux", "macos", "windows"];
50 let (_, suffix) = principal.rsplit_once('@')?;
51 let (arch, system) = suffix.split_once('-')?;
52 (ARCHES.contains(&arch) && SYSTEMS.contains(&system)).then_some(suffix)
53}
54
55/// One signed statement out of a note: the exact bytes that were signed
56/// (trailing newline included) and the armored signature over them.
57#[derive(Debug, PartialEq)]
58pub struct Block {
59 pub payload: String,
60 pub signature: String,
61}
62
63/// A note into its blocks. Since 1.2.0 a note may hold several — a laptop's
64/// and a CI job's, each on its own platform — separated by blank lines, which
65/// is exactly what `git notes append` produces.
66///
67/// The grammar (SPEC.md): skip blank lines; a payload runs to the first blank
68/// line; skip blank lines; the next line must be the BEGIN marker, or parsing
69/// STOPS and what was collected so far stands; the signature runs to the END
70/// marker, and end of input closes an open one. Lines are split on LF only;
71/// `evaluate` rejects any note containing a carriage return before this runs.
72///
73/// Returns the blocks and whether more than `MAX_BLOCKS` were present.
74pub fn split_blocks(body: &str) -> (Vec<Block>, bool) {
75 enum State {
76 Between,
77 Payload,
78 WaitingForSignature,
79 Signature,
80 }
81 let mut blocks = Vec::new();
82 let mut state = State::Between;
83 let mut payload = String::new();
84 let mut signature = String::new();
85 let close = |blocks: &mut Vec<Block>, payload: &str, signature: &str| -> bool {
86 if blocks.len() == MAX_BLOCKS {
87 return true;
88 }
89 blocks.push(Block {
90 payload: payload.to_string(),
91 signature: signature.to_string(),
92 });
93 false
94 };
95 for line in body.split('\n') {
96 match state {
97 State::Between => {
98 if line.is_empty() {
99 continue;
100 }
101 payload.clear();
102 payload.push_str(line);
103 payload.push('\n');
104 state = State::Payload;
105 }
106 State::Payload => {
107 if line.is_empty() {
108 state = State::WaitingForSignature;
109 } else {
110 payload.push_str(line);
111 payload.push('\n');
112 }
113 }
114 State::WaitingForSignature => {
115 if line.is_empty() {
116 continue;
117 }
118 if line != BEGIN {
119 return (blocks, false);
120 }
121 signature.clear();
122 signature.push_str(line);
123 signature.push('\n');
124 state = State::Signature;
125 }
126 State::Signature => {
127 signature.push_str(line);
128 signature.push('\n');
129 if line == END {
130 if close(&mut blocks, &payload, &signature) {
131 return (blocks, true);
132 }
133 state = State::Between;
134 }
135 }
136 }
137 }
138 if let State::Signature = state {
139 if close(&mut blocks, &payload, &signature) {
140 return (blocks, true);
141 }
142 }
143 (blocks, false)
144}
145
146/// The first block of a note, as payload and signature.
147pub fn split_note(body: &str) -> Option<(String, String)> {
148 split_blocks(body)
149 .0
150 .into_iter()
151 .next()
152 .map(|b| (b.payload, b.signature))
153}
154
155/// Does an attestation minted on `ran_on` satisfy what the caller asked for?
156///
157/// `None` is "anywhere". A value with a dash is an exact `<arch>-<os>`. A value
158/// without one is an OS alone, compared to the part after the LAST dash of
159/// `ran_on` — never as a substring, so `inux` matches nothing and `x86_64`
160/// does not match `x86_64-linux`.
161pub fn platform_matches(want: Option<&str>, ran_on: &str) -> bool {
162 match want {
163 None => true,
164 Some(want) if want.contains('-') => want == ran_on,
165 Some(want) => ran_on.rsplit_once('-').is_some_and(|(_, os)| os == want),
166 }
167}
168
169/// Read a payload field BY PREFIX, never by line position: the payload has
170/// grown a line once already (v1 -> v2 added `platform`), and a positional
171/// reader silently mis-assigns every field after an insertion rather than
172/// failing. First match wins, so a second `gates` line cannot smuggle a value
173/// past the caller.
174///
175/// Byte-strict, like `verify.sh`: lines are split on LF only and nothing is
176/// trimmed but the separating spaces, so a `\r` or a trailing space is part
177/// of the value and a `tree` line that carries one matches nothing.
178pub fn field<'a>(payload: &'a str, name: &str) -> Option<&'a str> {
179 payload
180 .split('\n')
181 .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
182 .map(|v| v.trim_start_matches(' '))
183}
184
185/// A 40- or 64-hex object id, as `hash-object` prints one.
186pub fn is_oid(s: &str) -> bool {
187 (s.len() == 40 || s.len() == 64)
188 && s.bytes()
189 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
190}
191
192/// The fingerprint a block claims for `gate`: an `input <gate> <fp>` line of
193/// exactly three blank-separated fields, first occurrence wins. Byte-strict
194/// like [`field`]; a fourth field or a malformed oid is not a claim.
195pub fn input_fp<'a>(payload: &'a str, gate: &str) -> Option<&'a str> {
196 payload.split('\n').find_map(|line| {
197 let f: Vec<&str> = line.split([' ', '\t']).filter(|t| !t.is_empty()).collect();
198 (f.len() == 3 && f[0] == "input" && f[1] == gate && is_oid(f[2])).then(|| f[2])
199 })
200}
201
202/// The canonical fingerprint a block claims: a `canon <oid>` line of exactly
203/// two blank-separated fields, first occurrence wins. A malformed oid is not
204/// a claim.
205pub fn canon_claim(payload: &str) -> Option<&str> {
206 payload.split('\n').find_map(|line| {
207 let f: Vec<&str> = line.split([' ', '\t']).filter(|t| !t.is_empty()).collect();
208 (f.len() == 2 && f[0] == "canon" && is_oid(f[1])).then(|| f[1])
209 })
210}
211
212/// The payload of an attestation, in the exact shape SPEC.md gives: `tree`,
213/// `gates`, `platform`, the `input` lines in spec order, `canon`, then the
214/// producer line, each followed by LF. The same inputs give the same bytes,
215/// so a re-run recognises its own block.
216pub fn payload(
217 tree: &str,
218 gates: &str,
219 platform: &str,
220 inputs: &[(String, String)],
221 canon: Option<&str>,
222 producer: &str,
223) -> String {
224 let mut out = format!("{FORMAT}\ntree {tree}\ngates {gates}\nplatform {platform}\n");
225 for (gate, fp) in inputs {
226 out.push_str(&format!("input {gate} {fp}\n"));
227 }
228 if let Some(c) = canon {
229 out.push_str(&format!("canon {c}\n"));
230 }
231 out.push_str(&format!("amont {producer}\n"));
232 out
233}
234
235/// A signed block as a note holds it: the payload without its trailing
236/// newline, a blank line, the armored signature without its own.
237pub fn block(payload: &str, signature: &str) -> String {
238 format!(
239 "{}\n\n{}",
240 payload.trim_end_matches('\n'),
241 signature.trim_end_matches('\n')
242 )
243}