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 | |
| 95 | use oxedyne_fe2o3_core::prelude::*; |
| 96 | use oxedyne_fe2o3_text::secret; |
| 97 | |
| 98 | use std::collections::BTreeSet; |
| 99 | |
| 100 | |
| 101 | /// One credential a capture was about to write into the history. |
| 102 | #[derive(Clone, Debug)] |
| 103 | pub 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. |
| 113 | pub 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. |
| 142 | pub 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. |
| 158 | pub 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 | } |