Skip to main content

mkit_cli/grants/
mod.rs

1//! Client side of SPEC-WRITE-GRANTS: the user grant store, owner signing and
2//! the local statement checks behind `mkit grant`, `mkit epoch` and
3//! `mkit visibility`.
4//!
5//! Nothing here is repository-scoped. The store lives beside the user
6//! config, the relying-party pins come from the user config, and every
7//! header is verified with the `mkit-attest` verifier before it is stored or
8//! sent (SPEC-CONFIG-SECURITY; WP-2.13).
9
10pub mod cli;
11pub mod owner;
12pub mod remote;
13pub mod spec;
14pub mod store;
15
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use mkit_attest::grant::{
19    AcceptedSchemes, EpochStatement, GrantError, OwnerScheme, RelyingParty, SignedHeader,
20    VerifiedEpoch, VerifiedVisibility, VerifierConfig, verify_epoch_statement, verify_grant_owner,
21    verify_visibility_statement,
22};
23use mkit_attest::grant::{Grant, RepositoryIdentity};
24
25/// Audience used when only the owner signature matters. The grant checks
26/// that involve an audience run on the server; the client never compares it.
27const OFFLINE_AUDIENCE: &str = "https://mkit-client.invalid";
28
29/// Why a header could not be verified locally.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
31pub enum HeaderError {
32    /// The attest verifier rejected it; the text is its rule name.
33    #[error("{0}")]
34    Rejected(GrantError),
35    /// A `webauthn-p256` signature, and no relying party is pinned.
36    #[error(
37        "webauthn-p256 signatures need a pinned relying party: set `grant.webauthn_rp = <rp_id> <origin>` in the user config"
38    )]
39    WebAuthnNotPinned,
40}
41
42impl From<GrantError> for HeaderError {
43    fn from(error: GrantError) -> Self {
44        Self::Rejected(error)
45    }
46}
47
48/// Parse `grant.webauthn_rp` entries (`<rp_id> <origin>...`, several entries
49/// separated by `|`).
50///
51/// # Errors
52/// A malformed entry, or two entries with the same relying-party id.
53pub fn parse_relying_parties(entries: &[String]) -> Result<Vec<RelyingParty>, String> {
54    let mut out: Vec<RelyingParty> = Vec::new();
55    for value in entries {
56        for entry in value.split('|') {
57            let mut parts = entry.split_whitespace();
58            let Some(id) = parts.next() else {
59                continue;
60            };
61            let origins: Vec<&str> = parts.collect();
62            let rp = RelyingParty::new(id, origins.iter().copied()).map_err(|e| {
63                format!(
64                    "relying party `{id}`: {e} (expected `<rp_id> <origin>...`, e.g. `example.com https://example.com`)"
65                )
66            })?;
67            if out.iter().any(|other| other.id() == rp.id()) {
68                return Err(format!("relying party `{id}` is pinned twice"));
69            }
70            out.push(rp);
71        }
72    }
73    Ok(out)
74}
75
76/// A verifier that accepts `ed25519` and `secp256k1-eip191`, plus
77/// `webauthn-p256` when relying parties are pinned. Loopback audiences and
78/// relying parties are allowed: the CLI has no deployment to protect, and the
79/// server applies its own production rules.
80fn verifier_config(audience: &str, rps: &[RelyingParty]) -> Result<VerifierConfig, GrantError> {
81    let mut schemes = vec![OwnerScheme::Ed25519, OwnerScheme::Secp256k1Eip191];
82    if !rps.is_empty() {
83        schemes.push(OwnerScheme::WebAuthnP256);
84    }
85    VerifierConfig::new_allowing_loopback(audience, AcceptedSchemes::of(&schemes), rps.to_vec())
86}
87
88fn check_pinned(header: &str, rps: &[RelyingParty]) -> Result<(), HeaderError> {
89    if rps.is_empty()
90        && let Ok(signed) = SignedHeader::parse(header)
91        && signed.scheme == OwnerScheme::WebAuthnP256
92    {
93        return Err(HeaderError::WebAuthnNotPinned);
94    }
95    Ok(())
96}
97
98/// A grant header whose owner signature verified.
99#[derive(Debug, Clone)]
100pub struct VerifiedGrantHeader {
101    pub grant: Grant,
102    pub id: [u8; 32],
103    pub scheme: OwnerScheme,
104}
105
106/// SPEC-WRITE-GRANTS §7 steps 1, 3 and 4 for a grant header.
107///
108/// # Errors
109/// The rule that failed.
110pub fn verify_grant_header(
111    header: &str,
112    rps: &[RelyingParty],
113) -> Result<VerifiedGrantHeader, HeaderError> {
114    check_pinned(header, rps)?;
115    let cfg = verifier_config(OFFLINE_AUDIENCE, rps)?;
116    let verified = verify_grant_owner(&cfg, header)?;
117    Ok(VerifiedGrantHeader {
118        grant: verified.statement().clone(),
119        id: *verified.id(),
120        scheme: verified.scheme(),
121    })
122}
123
124/// The verifier config for a statement header, bound to the first audience
125/// that `audiences` reads out of the statement bytes.
126fn first_audience_config(
127    header: &str,
128    rps: &[RelyingParty],
129    audiences: impl FnOnce(&[u8]) -> Result<Vec<String>, GrantError>,
130) -> Result<VerifierConfig, HeaderError> {
131    check_pinned(header, rps)?;
132    let signed = SignedHeader::parse(header)?;
133    let first = audiences(&signed.statement)?
134        .into_iter()
135        .next()
136        .ok_or(GrantError::AudienceCount)?;
137    Ok(verifier_config(&first, rps)?)
138}
139
140/// Read at most `max` bytes from `reader`.
141///
142/// # Errors
143/// A read error, or more than `max` bytes (`InvalidData`).
144pub fn read_bounded(reader: impl std::io::Read, max: u64) -> std::io::Result<Vec<u8>> {
145    use std::io::Read as _;
146    let mut bytes = Vec::new();
147    reader.take(max + 1).read_to_end(&mut bytes)?;
148    if bytes.len() as u64 > max {
149        return Err(std::io::Error::new(
150            std::io::ErrorKind::InvalidData,
151            format!("larger than {max} bytes"),
152        ));
153    }
154    Ok(bytes)
155}
156
157/// SPEC-WRITE-GRANTS §5.2 checks 1–5 against the statement's first audience.
158///
159/// # Errors
160/// The rule that failed.
161pub fn verify_epoch_header(
162    header: &str,
163    rps: &[RelyingParty],
164    now_ms: i64,
165) -> Result<VerifiedEpoch, HeaderError> {
166    let cfg = first_audience_config(header, rps, |statement| {
167        EpochStatement::parse(statement).map(|s| s.audiences)
168    })?;
169    Ok(verify_epoch_statement(&cfg, header, now_ms)?)
170}
171
172/// SPEC-WRITE-GRANTS §9.1 checks for a visibility statement sent for
173/// `repository`, against the statement's first audience.
174///
175/// # Errors
176/// The rule that failed.
177pub fn verify_visibility_header(
178    header: &str,
179    repository: &RepositoryIdentity,
180    rps: &[RelyingParty],
181    now_ms: i64,
182) -> Result<VerifiedVisibility, HeaderError> {
183    let cfg = first_audience_config(header, rps, |statement| {
184        mkit_attest::grant::VisibilityStatement::parse(statement).map(|s| s.audiences)
185    })?;
186    Ok(verify_visibility_statement(
187        &cfg, header, repository, now_ms,
188    )?)
189}
190
191/// `<namespace>/*` or `<namespace>/<name>`: the repositories a grant covers.
192#[must_use]
193pub fn scope_text(grant: &Grant) -> String {
194    match &grant.scope {
195        mkit_attest::grant::RepoScope::Namespace => format!("{}/*", grant.namespace),
196        mkit_attest::grant::RepoScope::Repository(id) => id.to_string(),
197    }
198}
199
200/// Milliseconds since the Unix epoch.
201#[must_use]
202pub fn now_ms() -> i64 {
203    SystemTime::now()
204        .duration_since(UNIX_EPOCH)
205        .ok()
206        .and_then(|d| i64::try_from(d.as_millis()).ok())
207        .unwrap_or(0)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn relying_party_entries_parse_and_reject_duplicates() {
216        let rps = parse_relying_parties(&[
217            "example.com https://example.com https://app.example.com | other.test https://other.test"
218                .to_owned(),
219        ])
220        .unwrap();
221        assert_eq!(rps.len(), 2);
222        assert_eq!(rps[0].id(), "example.com");
223        assert_eq!(rps[0].origins().len(), 2);
224        assert!(parse_relying_parties(&["example.com".to_owned()]).is_err());
225        assert!(
226            parse_relying_parties(&[
227                "example.com https://a.test".to_owned(),
228                "example.com https://b.test".to_owned(),
229            ])
230            .is_err()
231        );
232        assert!(parse_relying_parties(&[String::new()]).unwrap().is_empty());
233    }
234}
235
236/// Test helpers shared by the store and command tests.
237#[cfg(test)]
238pub(crate) mod testutil {
239    use std::sync::Arc;
240
241    use mkit_attest::grant::{Capabilities, Grant, RepoScope};
242    use mkit_core::hash::to_hex_bytes;
243    use mkit_transport_connect::EnvelopeSigner;
244
245    use super::owner::{Kind, NativeOwner, Plan, Produced, produce};
246
247    pub(crate) struct DalekSigner(pub ed25519_dalek::SigningKey);
248    impl EnvelopeSigner for DalekSigner {
249        fn public_key_hex(&self) -> String {
250            to_hex_bytes(&self.0.verifying_key().to_bytes())
251        }
252        fn sign_hex(&self, message: &[u8; 32]) -> Result<String, String> {
253            use ed25519_dalek::Signer as _;
254            Ok(to_hex_bytes(&self.0.sign(message).to_bytes()))
255        }
256    }
257
258    pub(crate) fn owner(seed: u8) -> NativeOwner {
259        NativeOwner::ed25519(Arc::new(DalekSigner(
260            ed25519_dalek::SigningKey::from_bytes(&[seed; 32]),
261        )))
262        .unwrap()
263    }
264
265    /// A real owner-signed read grant for `audience`, distinct per `nonce`.
266    pub(crate) fn signed_grant(seed: u8, nonce: u8, epoch: u64, audience: &str) -> String {
267        let now = super::now_ms();
268        let Produced::Signed(signed) = produce(
269            Plan::Native(owner(seed)),
270            |ns| {
271                Grant {
272                    namespace: *ns,
273                    scope: RepoScope::Namespace,
274                    grantee: [7; 32],
275                    capabilities: Capabilities::Read,
276                    audiences: vec![audience.to_owned()],
277                    ref_scopes: None,
278                    epoch,
279                    created_ms: now,
280                    expiry_ms: now + 3_600_000,
281                    nonce: [nonce; 32],
282                }
283                .encode()
284                .map_err(|e| e.to_string())
285            },
286            Kind::Grant,
287            &[],
288            now,
289        )
290        .unwrap() else {
291            panic!("expected a signed grant")
292        };
293        signed.header
294    }
295}