Skip to main content

core_api/host/
auth.rs

1//! Offline Client–Host authorization. No Space, cloud session or transport ownership.
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4
5pub const KEY_REFRESH_INTERVAL_SECONDS: u64 = 24 * 60 * 60;
6pub const REQUEST_WINDOW_SECONDS: u64 = 60;
7pub const ENROLLMENT_PROOF_TTL_SECONDS: u64 = 300;
8pub const PROTOCOL: &str = "meow-host-auth-v1";
9
10/// Public P-256 point; x/y are exactly 32 bytes, base64url without padding.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase", deny_unknown_fields)]
13pub struct PublicKey {
14    pub x: String,
15    pub y: String,
16}
17
18/// Verification-only permits an overlap. Disabled never accepts proofs or bindings.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub enum SigningKeyStatus {
22    Active,
23    VerifyOnly,
24    Disabled,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct SigningKey {
30    pub kid: String,
31    pub status: SigningKeyStatus,
32    /// RS256 JWK modulus and exponent, base64url without padding.
33    pub n: String,
34    pub e: String,
35}
36
37/// A complete authoritative snapshot from the configured HTTPS issuer.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct SigningKeySet {
41    pub issuer: String,
42    pub version: u64,
43    pub keys: Vec<SigningKey>,
44}
45
46impl SigningKeySet {
47    pub fn validate_update(
48        &self,
49        issuer: &str,
50        previous: Option<&Self>,
51    ) -> Result<(), &'static str> {
52        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
53        if self.issuer != issuer || self.version == 0 || self.version > 9_007_199_254_740_991 {
54            return Err("invalid issuer or key set version");
55        }
56        if self.keys.is_empty() || self.keys.len() > 128 {
57            return Err("invalid key set size");
58        }
59        let mut kids = BTreeSet::new();
60        let mut active = 0;
61        for key in &self.keys {
62            if key.kid.is_empty() || key.kid.len() > 128 || !kids.insert(&key.kid) {
63                return Err("invalid or duplicate signing key ID");
64            }
65            let n = URL_SAFE_NO_PAD
66                .decode(&key.n)
67                .map_err(|_| "invalid RSA modulus")?;
68            let e = URL_SAFE_NO_PAD
69                .decode(&key.e)
70                .map_err(|_| "invalid RSA exponent")?;
71            if !(256..=512).contains(&n.len())
72                || n.first().is_some_and(|v| *v < 128)
73                || e != [1, 0, 1]
74            {
75                return Err("unsupported RSA public key");
76            }
77            active += usize::from(key.status == SigningKeyStatus::Active);
78        }
79        if active != 1 {
80            return Err("exactly one active signing key is required");
81        }
82        if let Some(old) = previous {
83            if old.issuer != self.issuer || self.version < old.version {
84                return Err("key set rollback");
85            }
86            let index: BTreeMap<_, _> = old.keys.iter().map(|k| (&k.kid, k)).collect();
87            for key in &self.keys {
88                if let Some(prior) = index.get(&key.kid) {
89                    if key.n != prior.n || key.e != prior.e {
90                        return Err("signing key ID was reused");
91                    }
92                    if prior.status == SigningKeyStatus::Disabled
93                        && key.status != SigningKeyStatus::Disabled
94                    {
95                        return Err("disabled signing key was reactivated");
96                    }
97                }
98            }
99            let next: BTreeMap<_, _> = self.keys.iter().map(|k| (&k.kid, k)).collect();
100            if self.version == old.version && next != index {
101                return Err("conflicting key set version");
102            }
103            // Keep tombstones: an offline Host must learn emergency disablement too.
104            if old
105                .keys
106                .iter()
107                .any(|k| k.status == SigningKeyStatus::Disabled && !next.contains_key(&k.kid))
108            {
109                return Err("disabled signing key tombstone was removed");
110            }
111        }
112        Ok(())
113    }
114
115    pub fn permits_binding(&self, kid: &str) -> bool {
116        self.keys
117            .iter()
118            .any(|key| key.kid == kid && key.status != SigningKeyStatus::Disabled)
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
124pub enum ErrorCode {
125    HostReauthRequired,
126    HostEnrollmentRequired,
127    HostUserNotAuthorized,
128    HostContextChanged,
129    HostReplayDetected,
130    HostAuthUnavailable,
131    HostInvalidProof,
132}
133
134/// Only authenticated responses may drive refresh/retry. Rejections precede dispatch.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct AuthError {
138    pub code: ErrorCode,
139    pub host_id: String,
140    pub boot_id: String,
141    pub request_id: String,
142    pub key_set_version: u64,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147pub struct ContextRequest {
148    pub probe_id: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase", deny_unknown_fields)]
153pub struct HostContext {
154    pub protocol: String,
155    pub host_id: String,
156    pub boot_id: String,
157    pub probe_id: String,
158    pub server_time: u64,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct EnrollmentRequest {
164    pub proof: String,
165    pub client_signature: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase", deny_unknown_fields)]
170pub struct EnrollmentChallengeRequest {
171    pub issuer: String,
172    pub user_id: String,
173    pub client_public_key: PublicKey,
174    pub probe_id: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase", deny_unknown_fields)]
179pub struct EnrollmentChallenge {
180    pub host_id: String,
181    pub boot_id: String,
182    pub issuer: String,
183    pub user_id: String,
184    pub client_public_key: PublicKey,
185    pub probe_id: String,
186    pub challenge: String,
187    pub expires_at: u64,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192pub struct ClientBinding {
193    pub issuer: String,
194    pub user_id: String,
195    pub client_public_key: PublicKey,
196    pub enrollment_kid: String,
197}
198
199/// Cloud claims are short-lived evidence for registration, never LAN bearer credentials.
200#[derive(Clone, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase", deny_unknown_fields)]
202pub struct Grant {
203    pub iss: String,
204    pub sub: String,
205    pub aud: String,
206    pub jti: String,
207    pub iat: u64,
208    pub exp: u64,
209    pub host_id: String,
210    pub host_public_key: PublicKey,
211    pub client_public_key: Option<PublicKey>,
212    pub challenge: String,
213    pub boot_id: Option<String>,
214}
215#[derive(Serialize, Deserialize)]
216#[serde(rename_all = "camelCase", deny_unknown_fields)]
217pub struct LocalAuthorization {
218    pub host_id: String,
219    pub host_name: String,
220    pub host_public_key: PublicKey,
221    pub transaction_id: String,
222    pub expires_at: u64,
223}
224#[derive(Serialize, Deserialize)]
225#[serde(rename_all = "camelCase", deny_unknown_fields)]
226pub struct Possession {
227    pub proof_digest: String,
228}
229#[derive(Serialize, Deserialize)]
230#[serde(rename_all = "camelCase", deny_unknown_fields)]
231pub struct AuthorizationReceipt {
232    pub proof: String,
233    pub host_id: String,
234    pub user_id: String,
235}