newt_core/ssh_caveats.rs
1//! `SshCaveats` — the custom OCAP surface for SSH (the long-haul transport and the
2//! carrier for git network ops). Sibling of [`GitCaveats`](crate::git_caveats::GitCaveats):
3//! a separate capability lattice composed *alongside* the signed `Caveats`, by `meet`.
4//!
5//! It gates the SSH **transport** by **hosts + keys** (per the directive — an
6//! `actions` axis comes later). SSH is the network, so even [`SshCaveats::top`] is
7//! the *ceiling*, not "always allowed": at the transport layer it is still gated by
8//! the OCAP deviation ratchet (`b1-os-isolation` — `newt-core::ocap`), exactly like
9//! git's network verbs.
10//!
11//! ## The read/write split for git-over-SSH (why disabling SSH blocks push *and* pull)
12//!
13//! git push/pull/fetch ride SSH, so two lattices compose:
14//!
15//! - **`SshCaveats`** gates the **transport** — *which hosts/keys* you may reach.
16//! Disable it (`hosts = none`) and **all** git network is blocked: push *and* pull.
17//! - **`GitCaveats`** gates the git **verb** — `fetch` vs `push` are separate gates
18//! ([`GitCaveats`](crate::git_caveats::GitCaveats)). That is the read/write split:
19//! grant `fetch` but not `push` to allow pulls while blocking pushes.
20//!
21//! A git network op is permitted **iff** `GitCaveats` permits the verb **and**
22//! `SshCaveats` permits the host — see [`git_over_ssh_permitted`]. So:
23//!
24//! | want | `SshCaveats.hosts` | `GitCaveats` |
25//! |---|---|---|
26//! | block all SSH (push + pull) | `none` | (anything) |
27//! | allow pull, block push | host allowed | `fetch=true, push=false` |
28//! | full git over SSH | host allowed | `fetch=true, push=true` |
29//!
30//! A future `SshCaveats` `actions` axis can *also* split at the SSH-command level
31//! (`git-upload-pack` = read vs `git-receive-pack` = write) for defense in depth;
32//! today the verb split lives in `GitCaveats`.
33
34use crate::caveats::{Scope, ScopeExt};
35use crate::git_caveats::GitCaveats;
36use serde::{Deserialize, Serialize};
37
38/// A capability surface for SSH: which **hosts** the agent may reach and which
39/// **keys** it may use/accept. Composed with — never merged into — the session
40/// `Caveats`. Default is fail-closed (`none`).
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct SshCaveats {
43 /// Hosts this surface may SSH to (git remotes, mesh peers, bastions).
44 pub hosts: Scope<String>,
45 /// Keys (by fingerprint, e.g. `SHA256:…`) this surface may use or accept.
46 pub keys: Scope<String>,
47}
48
49impl SshCaveats {
50 /// The ceiling: all hosts + keys. Still gated by the OCAP ratchet at the
51 /// transport layer — `top()` is the most authority a grant could confer, not
52 /// an unconditional allow.
53 #[must_use]
54 pub fn top() -> Self {
55 Self {
56 hosts: Scope::top(),
57 keys: Scope::top(),
58 }
59 }
60
61 /// `⊥` — no SSH at all (every host + key denied). The fail-closed default.
62 #[must_use]
63 pub fn none() -> Self {
64 Self {
65 hosts: Scope::none(),
66 keys: Scope::none(),
67 }
68 }
69
70 /// `self ⊓ other` — greatest lower bound, per axis (attenuation only).
71 #[must_use]
72 pub fn meet(&self, other: &Self) -> Self {
73 Self {
74 hosts: self.hosts.meet(&other.hosts),
75 keys: self.keys.meet(&other.keys),
76 }
77 }
78
79 /// `self ⊑ other` — grants no more than `other` on every axis.
80 #[must_use]
81 pub fn leq(&self, other: &Self) -> bool {
82 self.hosts.leq(&other.hosts) && self.keys.leq(&other.keys)
83 }
84
85 /// May the agent open an SSH connection to `host`?
86 #[must_use]
87 pub fn permits_host(&self, host: &str) -> bool {
88 self.hosts.permits(&host.to_string())
89 }
90
91 /// May the agent use / accept the key with fingerprint `fp`?
92 #[must_use]
93 pub fn permits_key(&self, fp: &str) -> bool {
94 self.keys.permits(&fp.to_string())
95 }
96}
97
98impl Default for SshCaveats {
99 /// Fail-closed: no SSH until explicitly granted.
100 fn default() -> Self {
101 Self::none()
102 }
103}
104
105/// A git network verb (each rides SSH).
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum GitNetVerb {
108 /// Read from a remote (`git fetch`/`pull`) — `git-upload-pack` on the wire.
109 Fetch,
110 /// Write to a remote (`git push`) — `git-receive-pack` on the wire.
111 Push,
112 /// Clone a remote.
113 Clone,
114}
115
116/// Is a git-over-SSH network op permitted? The **read/write split**: the git
117/// `verb` (via `GitCaveats`) AND the SSH `host` (via `SshCaveats`) must *both*
118/// allow it. `remote` is the git remote NAME (e.g. `origin`); `host` is the
119/// network host (e.g. `github.com`).
120#[must_use]
121pub fn git_over_ssh_permitted(
122 git: &GitCaveats,
123 ssh: &SshCaveats,
124 verb: GitNetVerb,
125 remote: &str,
126 host: &str,
127) -> bool {
128 let git_ok = match verb {
129 GitNetVerb::Fetch => git.permits_fetch(remote),
130 GitNetVerb::Push => git.permits_push(remote),
131 GitNetVerb::Clone => git.permits_clone(),
132 };
133 git_ok && ssh.permits_host(host)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn full_git() -> GitCaveats {
141 // A git surface that names origin and allows both verbs.
142 GitCaveats {
143 remote: Scope::only(["origin".to_string()]),
144 fetch: true,
145 push: true,
146 ..GitCaveats::top()
147 }
148 }
149
150 #[test]
151 fn default_and_none_are_fail_closed() {
152 assert_eq!(SshCaveats::default(), SshCaveats::none());
153 let n = SshCaveats::none();
154 assert!(!n.permits_host("github.com"));
155 assert!(!n.permits_key("SHA256:abc"));
156 }
157
158 #[test]
159 fn top_permits_hosts_and_keys() {
160 let t = SshCaveats::top();
161 assert!(t.permits_host("github.com"));
162 assert!(t.permits_key("SHA256:abc"));
163 }
164
165 #[test]
166 fn host_scope_is_bounded() {
167 let s = SshCaveats {
168 hosts: Scope::only(["github.com".to_string()]),
169 keys: Scope::top(),
170 };
171 assert!(s.permits_host("github.com"));
172 assert!(!s.permits_host("evil.example.com"));
173 }
174
175 #[test]
176 fn meet_attenuates_and_leq_orders() {
177 let bounded = SshCaveats {
178 hosts: Scope::only(["github.com".to_string()]),
179 keys: Scope::none(),
180 };
181 let m = SshCaveats::top().meet(&bounded);
182 assert!(m.permits_host("github.com") && !m.permits_host("x"));
183 assert!(!m.permits_key("SHA256:abc"));
184 assert!(m.leq(&SshCaveats::top()));
185 assert!(SshCaveats::none().leq(&bounded));
186 }
187
188 // --- the read/write split (the operator's requirement) ---
189
190 #[test]
191 fn disabling_ssh_blocks_push_and_pull() {
192 let git = full_git();
193 let ssh = SshCaveats::none(); // human disabled SSH entirely
194 assert!(!git_over_ssh_permitted(
195 &git,
196 &ssh,
197 GitNetVerb::Fetch,
198 "origin",
199 "github.com"
200 ));
201 assert!(!git_over_ssh_permitted(
202 &git,
203 &ssh,
204 GitNetVerb::Push,
205 "origin",
206 "github.com"
207 ));
208 }
209
210 #[test]
211 fn allow_pull_block_push() {
212 // host reachable over SSH, but the git surface grants fetch and NOT push.
213 let ssh = SshCaveats {
214 hosts: Scope::only(["github.com".to_string()]),
215 keys: Scope::top(),
216 };
217 let read_git = GitCaveats {
218 remote: Scope::only(["origin".to_string()]),
219 fetch: true,
220 push: false,
221 ..GitCaveats::top()
222 };
223 assert!(
224 git_over_ssh_permitted(&read_git, &ssh, GitNetVerb::Fetch, "origin", "github.com"),
225 "fetch/pull is allowed"
226 );
227 assert!(
228 !git_over_ssh_permitted(&read_git, &ssh, GitNetVerb::Push, "origin", "github.com"),
229 "push is blocked"
230 );
231 }
232
233 #[test]
234 fn full_grant_allows_both() {
235 let ssh = SshCaveats {
236 hosts: Scope::only(["github.com".to_string()]),
237 keys: Scope::top(),
238 };
239 assert!(git_over_ssh_permitted(
240 &full_git(),
241 &ssh,
242 GitNetVerb::Fetch,
243 "origin",
244 "github.com"
245 ));
246 assert!(git_over_ssh_permitted(
247 &full_git(),
248 &ssh,
249 GitNetVerb::Push,
250 "origin",
251 "github.com"
252 ));
253 }
254
255 #[test]
256 fn host_must_be_in_scope_even_with_git_grant() {
257 // git allows push, but the SSH host isn't permitted -> blocked (transport gate).
258 let ssh = SshCaveats {
259 hosts: Scope::only(["github.com".to_string()]),
260 keys: Scope::top(),
261 };
262 assert!(!git_over_ssh_permitted(
263 &full_git(),
264 &ssh,
265 GitNetVerb::Push,
266 "origin",
267 "gitlab.com"
268 ));
269 }
270}