Skip to main content

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