1pub 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
25const OFFLINE_AUDIENCE: &str = "https://mkit-client.invalid";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
31pub enum HeaderError {
32 #[error("{0}")]
34 Rejected(GrantError),
35 #[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
48pub 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
76fn 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#[derive(Debug, Clone)]
100pub struct VerifiedGrantHeader {
101 pub grant: Grant,
102 pub id: [u8; 32],
103 pub scheme: OwnerScheme,
104}
105
106pub 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
124fn 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
140pub 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
157pub 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
172pub 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#[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#[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#[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 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}