Skip to main content

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}