This repository has no description
1use std::net::IpAddr;
2use std::sync::Arc;
3
4use knot_atproto::ClaimedKeys;
5use knot_index::{Coverage, Resolved};
6use knot_runtime::{Clock, HttpTransport};
7use knot_types::{AccountDid, OfferedKey, OwnerRef};
8
9use crate::SshState;
10
11#[derive(Clone)]
12pub(crate) enum Credential {
13 Identified(AccountDid),
14 Offered(OfferedKey),
15}
16
17pub(crate) struct Asserted {
18 claim: OwnerRef,
19 outcome: Claimed,
20}
21
22enum Claimed {
23 Publishes {
24 did: AccountDid,
25 keys: Vec<OfferedKey>,
26 },
27 Unreadable,
28}
29
30pub(crate) enum Verdict {
31 Identified(AccountDid),
32 Offered,
33 Refused,
34}
35
36pub(crate) async fn verify<H: HttpTransport, C: Clock>(
37 state: &Arc<SshState<H, C>>,
38 claim: Option<OwnerRef>,
39 key: &OfferedKey,
40 peer: Option<IpAddr>,
41 asserted: &mut Option<Asserted>,
42) -> Verdict {
43 match claim {
44 Some(claim) => match against_claim(state, claim, key, peer, asserted).await {
45 Some(verdict) => verdict,
46 None => against_key_set(state, key, peer),
47 },
48 None => against_key_set(state, key, peer),
49 }
50}
51
52fn against_key_set<H: HttpTransport, C: Clock>(
53 state: &Arc<SshState<H, C>>,
54 key: &OfferedKey,
55 peer: Option<IpAddr>,
56) -> Verdict {
57 let now = state.atproto.now().seconds();
58 match (
59 state.index.owner_of_key(key, now),
60 state.index.keys().coverage(),
61 ) {
62 (Resolved::Ready(Some(_)), _) => Verdict::Offered,
63 (_, Coverage::Warming) => Verdict::Offered,
64 (_, Coverage::Ready) => match state.index.keys().any_unheld() {
65 true => Verdict::Offered,
66 false => {
67 if miss_worth_a_reread(state, peer) {
68 state.index.keys().note_miss();
69 }
70 Verdict::Refused
71 }
72 },
73 }
74}
75
76fn miss_worth_a_reread<H: HttpTransport, C: Clock>(
77 state: &Arc<SshState<H, C>>,
78 peer: Option<IpAddr>,
79) -> bool {
80 peer.is_none_or(|peer| state.miss_pace.reserve_now(&peer, state.atproto.now()))
81}
82
83async fn against_claim<H: HttpTransport, C: Clock>(
84 state: &Arc<SshState<H, C>>,
85 claim: OwnerRef,
86 key: &OfferedKey,
87 peer: Option<IpAddr>,
88 asserted: &mut Option<Asserted>,
89) -> Option<Verdict> {
90 let known = match asserted.take().filter(|known| known.claim == claim) {
91 Some(known) => known,
92 None => Asserted {
93 outcome: resolve_claim(state, &claim, peer).await,
94 claim,
95 },
96 };
97 let verdict = match &known.outcome {
98 Claimed::Unreadable => None,
99 Claimed::Publishes { did, keys } if keys.contains(key) => {
100 Some(Verdict::Identified(did.clone()))
101 }
102 Claimed::Publishes { did, keys } => {
103 tracing::debug!(
104 ?peer,
105 did = did.as_str(),
106 published = keys.len(),
107 "ssh auth refused a key the asserted account doesn't publish"
108 );
109 Some(Verdict::Refused)
110 }
111 };
112 *asserted = Some(known);
113 verdict
114}
115
116async fn resolve_claim<H: HttpTransport, C: Clock>(
117 state: &Arc<SshState<H, C>>,
118 claim: &OwnerRef,
119 peer: Option<IpAddr>,
120) -> Claimed {
121 let Ok(_peer_guard) = state.lookup_peers.admit(peer, state.atproto.now()) else {
122 tracing::debug!(?peer, "ssh auth couldn't check a claim, peer budget spent");
123 return Claimed::Unreadable;
124 };
125 let did = match claim {
126 OwnerRef::Did(did) => AccountDid::from(did.clone()),
127 OwnerRef::Handle(handle) => match state.atproto.resolve_handle_to_did(handle).await {
128 Ok(did) => did,
129 Err(error) => {
130 tracing::warn!(
131 ?peer,
132 handle = handle.as_str(),
133 %error,
134 "ssh auth couldn't resolve the handle in the login name"
135 );
136 return Claimed::Unreadable;
137 }
138 },
139 };
140 match state.atproto.claimed_pubkeys(&did).await {
141 ClaimedKeys::Published(keys) => Claimed::Publishes { did, keys },
142 ClaimedKeys::Unread(error) => {
143 tracing::warn!(
144 ?peer,
145 did = did.as_str(),
146 %error,
147 "ssh auth couldn't read the asserted account's published keys"
148 );
149 Claimed::Unreadable
150 }
151 }
152}