pub mod cli;
pub mod owner;
pub mod remote;
pub mod spec;
pub mod store;
use std::time::{SystemTime, UNIX_EPOCH};
use mkit_attest::grant::{
AcceptedSchemes, EpochStatement, GrantError, OwnerScheme, RelyingParty, SignedHeader,
VerifiedEpoch, VerifiedVisibility, VerifierConfig, verify_epoch_statement, verify_grant_owner,
verify_visibility_statement,
};
use mkit_attest::grant::{Grant, RepositoryIdentity};
const OFFLINE_AUDIENCE: &str = "https://mkit-client.invalid";
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum HeaderError {
#[error("{0}")]
Rejected(GrantError),
#[error(
"webauthn-p256 signatures need a pinned relying party: set `grant.webauthn_rp = <rp_id> <origin>` in the user config"
)]
WebAuthnNotPinned,
}
impl From<GrantError> for HeaderError {
fn from(error: GrantError) -> Self {
Self::Rejected(error)
}
}
pub fn parse_relying_parties(entries: &[String]) -> Result<Vec<RelyingParty>, String> {
let mut out: Vec<RelyingParty> = Vec::new();
for value in entries {
for entry in value.split('|') {
let mut parts = entry.split_whitespace();
let Some(id) = parts.next() else {
continue;
};
let origins: Vec<&str> = parts.collect();
let rp = RelyingParty::new(id, origins.iter().copied()).map_err(|e| {
format!(
"relying party `{id}`: {e} (expected `<rp_id> <origin>...`, e.g. `example.com https://example.com`)"
)
})?;
if out.iter().any(|other| other.id() == rp.id()) {
return Err(format!("relying party `{id}` is pinned twice"));
}
out.push(rp);
}
}
Ok(out)
}
fn verifier_config(audience: &str, rps: &[RelyingParty]) -> Result<VerifierConfig, GrantError> {
let mut schemes = vec![OwnerScheme::Ed25519, OwnerScheme::Secp256k1Eip191];
if !rps.is_empty() {
schemes.push(OwnerScheme::WebAuthnP256);
}
VerifierConfig::new_allowing_loopback(audience, AcceptedSchemes::of(&schemes), rps.to_vec())
}
fn check_pinned(header: &str, rps: &[RelyingParty]) -> Result<(), HeaderError> {
if rps.is_empty()
&& let Ok(signed) = SignedHeader::parse(header)
&& signed.scheme == OwnerScheme::WebAuthnP256
{
return Err(HeaderError::WebAuthnNotPinned);
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct VerifiedGrantHeader {
pub grant: Grant,
pub id: [u8; 32],
pub scheme: OwnerScheme,
}
pub fn verify_grant_header(
header: &str,
rps: &[RelyingParty],
) -> Result<VerifiedGrantHeader, HeaderError> {
check_pinned(header, rps)?;
let cfg = verifier_config(OFFLINE_AUDIENCE, rps)?;
let verified = verify_grant_owner(&cfg, header)?;
Ok(VerifiedGrantHeader {
grant: verified.statement().clone(),
id: *verified.id(),
scheme: verified.scheme(),
})
}
fn first_audience_config(
header: &str,
rps: &[RelyingParty],
audiences: impl FnOnce(&[u8]) -> Result<Vec<String>, GrantError>,
) -> Result<VerifierConfig, HeaderError> {
check_pinned(header, rps)?;
let signed = SignedHeader::parse(header)?;
let first = audiences(&signed.statement)?
.into_iter()
.next()
.ok_or(GrantError::AudienceCount)?;
Ok(verifier_config(&first, rps)?)
}
pub fn read_bounded(reader: impl std::io::Read, max: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read as _;
let mut bytes = Vec::new();
reader.take(max + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > max {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("larger than {max} bytes"),
));
}
Ok(bytes)
}
pub fn verify_epoch_header(
header: &str,
rps: &[RelyingParty],
now_ms: i64,
) -> Result<VerifiedEpoch, HeaderError> {
let cfg = first_audience_config(header, rps, |statement| {
EpochStatement::parse(statement).map(|s| s.audiences)
})?;
Ok(verify_epoch_statement(&cfg, header, now_ms)?)
}
pub fn verify_visibility_header(
header: &str,
repository: &RepositoryIdentity,
rps: &[RelyingParty],
now_ms: i64,
) -> Result<VerifiedVisibility, HeaderError> {
let cfg = first_audience_config(header, rps, |statement| {
mkit_attest::grant::VisibilityStatement::parse(statement).map(|s| s.audiences)
})?;
Ok(verify_visibility_statement(
&cfg, header, repository, now_ms,
)?)
}
#[must_use]
pub fn scope_text(grant: &Grant) -> String {
match &grant.scope {
mkit_attest::grant::RepoScope::Namespace => format!("{}/*", grant.namespace),
mkit_attest::grant::RepoScope::Repository(id) => id.to_string(),
}
}
#[must_use]
pub fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_millis()).ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relying_party_entries_parse_and_reject_duplicates() {
let rps = parse_relying_parties(&[
"example.com https://example.com https://app.example.com | other.test https://other.test"
.to_owned(),
])
.unwrap();
assert_eq!(rps.len(), 2);
assert_eq!(rps[0].id(), "example.com");
assert_eq!(rps[0].origins().len(), 2);
assert!(parse_relying_parties(&["example.com".to_owned()]).is_err());
assert!(
parse_relying_parties(&[
"example.com https://a.test".to_owned(),
"example.com https://b.test".to_owned(),
])
.is_err()
);
assert!(parse_relying_parties(&[String::new()]).unwrap().is_empty());
}
}
#[cfg(test)]
pub(crate) mod testutil {
use std::sync::Arc;
use mkit_attest::grant::{Capabilities, Grant, RepoScope};
use mkit_core::hash::to_hex_bytes;
use mkit_transport_connect::EnvelopeSigner;
use super::owner::{Kind, NativeOwner, Plan, Produced, produce};
pub(crate) struct DalekSigner(pub ed25519_dalek::SigningKey);
impl EnvelopeSigner for DalekSigner {
fn public_key_hex(&self) -> String {
to_hex_bytes(&self.0.verifying_key().to_bytes())
}
fn sign_hex(&self, message: &[u8; 32]) -> Result<String, String> {
use ed25519_dalek::Signer as _;
Ok(to_hex_bytes(&self.0.sign(message).to_bytes()))
}
}
pub(crate) fn owner(seed: u8) -> NativeOwner {
NativeOwner::ed25519(Arc::new(DalekSigner(
ed25519_dalek::SigningKey::from_bytes(&[seed; 32]),
)))
.unwrap()
}
pub(crate) fn signed_grant(seed: u8, nonce: u8, epoch: u64, audience: &str) -> String {
let now = super::now_ms();
let Produced::Signed(signed) = produce(
Plan::Native(owner(seed)),
|ns| {
Grant {
namespace: *ns,
scope: RepoScope::Namespace,
grantee: [7; 32],
capabilities: Capabilities::Read,
audiences: vec![audience.to_owned()],
ref_scopes: None,
epoch,
created_ms: now,
expiry_ms: now + 3_600_000,
nonce: [nonce; 32],
}
.encode()
.map_err(|e| e.to_string())
},
Kind::Grant,
&[],
now,
)
.unwrap() else {
panic!("expected a signed grant")
};
signed.header
}
}