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