Oregami
Repositories/oxedyne/ore

oxedyne/ore/cli/src/guard.rs

9.9 KiB, 25 runs

created by r2848102244:736, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Refusing to record a credential, while there is still nothing to take back.
2//!
3//! # Why the guard is inside the capture
4//!
5//! Git's answer to this is a `pre-commit` hook, and a hook has somewhere to stand because a commit
6//! is a thing a person asks for. Here there is no such moment. Every verb but `init` compares the
7//! working copy with the history and appends whatever differs before it answers, so `ore log`
8//! writes and `ore who` writes, and a key merely standing in the tree is recorded by the next
9//! question anybody asks. A guard anywhere later than [`crate::capture::capture`] would be a guard
10//! the next question walked straight past.
11//!
12//! # Why refusing is the whole of the remedy
13//!
14//! Nothing leaves an Ore history. There is no rewrite, no prune, no repack and no `--force`: the
15//! log only grows, each operation sits inside a signed envelope, and a sync hands what is written
16//! to every replica and to the git mirror. So a credential recorded here is recorded on every
17//! machine the repository ever reaches, and a report after the event would be telling somebody
18//! about something they cannot undo. The capture is refused instead, nothing at all is appended,
19//! and the working copy is left exactly as it stood.
20//!
21//! The refusal is deliberately absolute: while the value is in the tree, every verb refuses,
22//! because every verb captures. That is a repository nobody can use until the key is moved, which
23//! is the intended cost -- on 2026-07-10 a key was written into an example as a fallback default
24//! and reached a public repository, and three sessions read the file inside the following nine
25//! days without stopping it. What was learned is that the machine has to be the one that refuses.
26//!
27//! There is no flag, no environment variable and no configuration field that turns it off. Git's
28//! `--no-verify` is survivable because a rewrite exists; here it would not be. The marker that
29//! [`oxedyne_fe2o3_text::secret`] reads is the whole of the escape hatch.
30//!
31//! One finding stands outside even that, and the refusal says so rather than giving advice that
32//! cannot be taken. The rule for a private key written as raw DER reads the key's own structure
33//! and nothing around it, so there is nowhere in such a file to write a marker that it would look
34//! at, and neither appending to the file nor writing something in front of the key makes it stop
35//! being a key. The remedy is the one the key wanted in the first place: keep it outside the tree
36//! and read it from there.
37//!
38//! # The three routes into a log, and what stands on each
39//!
40//! This guard stands in one place, the capture, and a credential can reach a log by three roads.
41//! Which of them are answered, and where, is the whole of what a reader needs before trusting any
42//! of it.
43//!
44//! **Capture.** Everything this working copy records comes through [`crate::capture::capture`],
45//! and this guard refuses it there.
46//!
47//! **Import.** `ore import` authors operations straight from a git stream and never captures, so
48//! the guard had to be put on that path by name, and was: a credential still at a branch tip
49//! refuses the import outright, and one that only the history carries is reported, naming the
50//! file, the line, the shape and the mark. See [`crate::gitimport`]. The split is not a
51//! compromise -- a key at a tip is live, and a key an old commit carries is already wherever it
52//! got to, so refusing the second would block an import while protecting nothing.
53//!
54//! **Sync.** This one cannot be answered here at all, and that is not an oversight. Operations
55//! arriving from a peer were authored and signed on their machine and arrive whole; refusing one
56//! would be refusing their history, and there is no form of that which leaves the two logs holding
57//! the same operations. So a peer can put a credential into your log, and nothing in this file
58//! will stop them.
59//!
60//! What to do about that is worth saying plainly rather than gesturing at, because the honest
61//! answer is short. Revoke the key, and tell whoever's replica sent it so that they revoke it too.
62//! The bytes stay -- in your log, in theirs, and in every replica either of you has since synced
63//! with -- and no verb removes them, because no verb removes anything. Revocation is the fix; the
64//! rest is bookkeeping.
65//!
66//! And since sync is the one road with nothing standing on it, it is the road the next section is
67//! for.
68//!
69//! # What the history already holds is not refused again
70//!
71//! A finding on a line the same file already carried in the history is passed over. Those bytes
72//! are in the log whatever happens next, and refusing them would remove them from nowhere.
73//!
74//! What that is for is the sync above. A peer's operation inserts a credential into a file, it is
75//! in your history and your working copy, and you could not have refused it. While you leave the
76//! file alone nothing scans it -- a capture skips a file whose bytes are what the history says --
77//! so the whole question falls on your next edit to that file. Without this carve-out that edit is
78//! refused, and so is every edit after it: a peer would be able to forbid you, permanently and
79//! from across a relay, from touching any file they put a key in. With it, the line they sent is
80//! not a line you are introducing, and you carry on.
81//!
82//! A line that is *new* is refused, in that file or any other, which is what keeps this a carve-out
83//! and not a hole. The match is on the exact line, and nothing looser: "the same file" or "the same
84//! shape of credential" would let the value itself be edited freely, and an edited key is a new
85//! key.
86//!
87//! What "already" means is the state the history says the file is in, and not every state it has
88//! ever been in, which has one consequence worth knowing before it is met. `ore back` into a state
89//! whose file held a credential writes those bytes into the working copy, and they are then new
90//! against the state the history stands at, so the next verb refuses -- `ore back` included, since
91//! it captures before it moves. The way out is to take the line out of the file by hand, which is
92//! the same thing the refusal asks for in every other case. Reading the whole history instead
93//! would answer it, and would cost a walk of every state on every capture that found anything.
94
95use oxedyne_fe2o3_core::prelude::*;
96use oxedyne_fe2o3_text::secret;
97
98use std::collections::BTreeSet;
99
100
101/// One credential a capture was about to write into the history.
102#[derive(Clone, Debug)]
103pub struct Caught {
104 pub path: Vec<u8>, // as the working copy holds it
105 pub line: usize, // 1-based, in the bytes on disk
106 pub kind: secret::Kind,
107}
108
109/// What one file about to be recorded holds that must not be recorded.
110///
111/// `had` is what the history says the file held, where it held anything: a line already in there
112/// is already in the log, so it is not what this command would be introducing and is passed over.
113pub fn inspect(path: &[u8], want: &[u8], had: Option<&[u8]>) -> Vec<Caught> {
114 let found = secret::scan(want);
115 if found.is_empty() {
116 return Vec::new();
117 }
118 let lines: Vec<&[u8]> = want.split(|b| *b == b'\n').collect();
119 let old: BTreeSet<&[u8]> = match had {
120 Some(bytes) => bytes.split(|b| *b == b'\n').collect(),
121 None => BTreeSet::new(),
122 };
123 let mut out = Vec::new();
124 for find in found {
125 match lines.get(find.line - 1) {
126 Some(line) if old.contains(line) => continue,
127 _ => (),
128 }
129 out.push(Caught {
130 path: path.to_vec(),
131 line: find.line,
132 kind: find.kind,
133 });
134 }
135 out
136}
137
138/// What to add to a report where one of the findings is a file that begins with a key.
139///
140/// Empty otherwise. The messages below both end by offering the marker, and offering it for a raw
141/// DER key would be telling somebody to do something that cannot be done: see this module's header.
142pub fn unmarkable(caught: &[Caught]) -> &'static str {
143 if caught.iter().any(|c| c.kind == secret::Kind::DerKey) {
144 "\n\nOne of those findings is a file that holds a private key. No marker excuses it: the rule \
145 reads the key's own structure and nothing around it, so there is nowhere to write one that \
146 it would look at, and neither appending to the file nor writing something in front of the \
147 key makes it stop being a key. Keep the key outside the tree and read it from there."
148 } else {
149 ""
150 }
151}
152
153/// Fails the command where anything was caught, naming every finding and saying what to do.
154///
155/// The value itself is never in the message. A refusal is read out of a terminal, pasted into a
156/// note and mailed to somebody, and a guard that copied the key into all three would be the second
157/// way it escaped.
158pub fn refuse(caught: &[Caught])
159 -> Outcome<()>
160{
161 if caught.is_empty() {
162 return Ok(());
163 }
164 let mut said = String::new();
165 for c in caught {
166 said.push_str(&fmt!("\n {}:{} {}",
167 String::from_utf8_lossy(&c.path), c.line, c.kind.label()));
168 }
169 Err(err!(
170 "a credential is in what this command was about to record, so nothing was recorded and \
171 the working copy is untouched:\n{}\n\n\
172 An Ore history only grows. There is no rewrite, no prune and no --force, and a sync hands \
173 what is written to every replica and to the git mirror, so this is not something a later \
174 command could take back. The value is not printed above; open the file to see it.\n\n\
175 Read a credential from the environment and fail loudly when it is absent, including where \
176 the temptation is a fallback default: an example that runs with no configuration is how a \
177 live key reaches a public repository.\n\n\
178 Nothing here will run until the value is out of the working copy, because every verb \
179 captures. Take the line out of the file by hand: read the value from the environment \
180 instead, or delete the line outright, which is what is wanted where `ore back` put it \
181 there rather than you. Where a match is genuinely a fixture, put `{}` in a comment on \
182 that line or on the one above it, which is the marker the git pre-commit hook takes as \
183 well.{}",
184 said, secret::MARKER, unmarkable(caught);
185 Security, Key, Permanent))
186}