diff --git a/Cargo.lock b/Cargo.lock index 74b616c..4be3eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "anyhow" version = "1.0.95" @@ -35,6 +50,21 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + [[package]] name = "base64ct" version = "1.6.0" @@ -281,6 +311,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + [[package]] name = "hash32" version = "0.2.1" @@ -326,6 +362,45 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + [[package]] name = "pkcs8" version = "0.10.2" @@ -420,9 +495,17 @@ dependencies = [ "rand", "rand_chacha", "serde", + "ssh-encoding", "testresult", + "tokio", ] +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + [[package]] name = "rustc_version" version = "0.4.1" @@ -509,6 +592,17 @@ dependencies = [ "der", ] +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -540,24 +634,34 @@ checksum = "614b328ff036a4ef882c61570f72918f7e9c5bee1da33f8e7f91e01daee7e56c" [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "tokio" +version = "1.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +dependencies = [ + "backtrace", + "pin-project-lite", +] + [[package]] name = "typenum" version = "1.17.0" @@ -588,6 +692,70 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index 68c7c9d..5d698c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,15 @@ hex = "0.4.3" postcard = { version = "1.1.1", features = ["use-std"] } rand = "0.8.5" serde = { version = "1.0.217", features = ["derive"] } +ssh-encoding = { version = "0.2.0", features = ["pem", "std"], optional = true } +tokio = { version = "1", optional = true, features = ["fs"] } [dev-dependencies] bitfields = "0.12.4" rand_chacha = "0.3.1" testresult = "0.4.1" + +[features] +default = [] +fs = ["pem", "dep:tokio"] +pem = ["dep:ssh-encoding"] diff --git a/src/chain.rs b/src/chain.rs new file mode 100644 index 0000000..e0f187c --- /dev/null +++ b/src/chain.rs @@ -0,0 +1,128 @@ +use std::time::Duration; + +use anyhow::{bail, ensure, Context, Result}; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::{Authorizer, Capability, Expires, Rcan, VERSION}; + +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] +pub struct RcanChain(Vec>); + +impl Default for RcanChain { + fn default() -> Self { + Self(Vec::new()) + } +} + +impl RcanChain { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn from_rcan(rcan: Rcan) -> Self { + Self(vec![rcan]) + } + + pub fn iter(&self) -> impl Iterator> + '_ { + self.into_iter() + } + + pub fn first_issuer(&self) -> Result<&VerifyingKey> { + self.0 + .first() + .map(|rcan| rcan.issuer()) + .context("rcan chain is empty") + } + + pub fn final_audience(&self) -> Result<&VerifyingKey> { + self.0 + .last() + .map(|rcan| rcan.audience()) + .context("rcan chain is empty") + } + + pub fn final_capability(&self) -> Result<&C> { + self.0 + .last() + .map(|rcan| rcan.capability()) + .context("rcan chain is empty") + } + + pub fn verify_chain(&self) -> Result<()> { + self.check_invocation_from( + *self.first_issuer()?, + *self.final_audience()?, + self.final_capability()?, + ) + } + + pub fn check_invocation_from( + &self, + root_issuer: VerifyingKey, + invoker: VerifyingKey, + capability: &C, + ) -> Result<()> { + if self.first_issuer()? != &root_issuer { + bail!("invocation failed: root issuer does not match"); + } + Authorizer::new(root_issuer) + .check_invocation_from(invoker, capability, self) + .context("not authorized")?; + Ok(()) + } + + pub fn with_delegation( + &self, + issuer: &SigningKey, + audience: VerifyingKey, + capability: C, + max_age: Duration, + ) -> Result + where + C: Clone, + { + let root_issuer = self.first_issuer()?; + self.check_invocation_from(*root_issuer, issuer.verifying_key(), &capability)?; + + let can = Rcan::delegating_builder(&issuer, audience, *root_issuer, capability) + .sign(Expires::valid_for(max_age)); + let mut next_chain = self.0.clone(); + next_chain.push(can); + Ok(Self(next_chain)) + } + + pub fn encode(&self) -> Vec { + postcard::to_extend(self, vec![VERSION]).expect("vec") + } + + pub fn encoded_len(&self) -> usize { + postcard::experimental::serialized_size(self).unwrap() + 1 + } + + pub fn decode(bytes: &[u8]) -> Result + where + C: DeserializeOwned, + { + let Some(version) = bytes.first() else { + bail!("cannot decode, token is empty"); + }; + ensure!(*version == VERSION, "invalid version: {}", version); + let out: Self = postcard::from_bytes(&bytes[1..]).context("decoding")?; + Ok(out) + } +} + +impl<'a, C> IntoIterator for &'a RcanChain { + type Item = &'a Rcan; + + type IntoIter = std::slice::Iter<'a, Rcan>; + + fn into_iter(self) -> Self::IntoIter { + self.0[..].into_iter() + } +} diff --git a/src/lib.rs b/src/lib.rs index dfa606a..e68a021 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,10 @@ use ed25519_dalek::{ }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +pub mod chain; +#[cfg(feature = "pem")] +pub mod pem; + pub const VERSION: u8 = 1; /// Domain seperation tag @@ -56,21 +60,21 @@ impl Authorizer { /// /// Make sure to verify that the `invoker` signed and authenticated the /// message containing the `capability`. - pub fn check_invocation_from( - &self, + pub fn check_invocation_from<'a, C: Capability + 'a>( + &'a self, invoker: VerifyingKey, - capability: C, - proof_chain: &[&Rcan], + capability: &'a C, + proof_chain: impl IntoIterator>, ) -> Result<()> { let now = SystemTime::now(); // We require that proof chains are provided "back-to-front". // So they start with the owner of the capability, then // proceed with the next item in the chain. - let mut current_issuer_target = &self.identity; + let mut current_issuer_target = self.identity; for proof in proof_chain { // Verify proof chain issuer/audience integrity: - let issuer = &proof.payload.issuer; - let audience = &proof.payload.audience; + let issuer = proof.payload.issuer; + let audience = proof.payload.audience; ensure!( issuer == current_issuer_target, "invocation failed: expected proof to be issued by {}, but was issued by {}", @@ -104,7 +108,7 @@ impl Authorizer { } ensure!( - &invoker == current_issuer_target, + invoker == current_issuer_target, "invocation failed: expected delegation chain to end in the connection's owner {}, but the connection is authenticated by {} instead", hex::encode(invoker), hex::encode(current_issuer_target), @@ -211,6 +215,13 @@ impl Rcan { postcard::to_extend(self, vec![VERSION]).expect("vec") } + pub fn encoded_len(&self) -> usize + where + C: Serialize, + { + postcard::experimental::serialized_size(self).unwrap() + 1 + } + pub fn decode(bytes: &[u8]) -> Result where C: DeserializeOwned, @@ -308,7 +319,7 @@ mod test { use super::*; - #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] + #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] enum Rpc { Read, ReadWrite, @@ -397,8 +408,8 @@ mod test { assert!(service_auth .check_invocation_from( bob.verifying_key(), - Rpc::Read, - &[&service_rcan, &friend_rcan], + &Rpc::Read, + [&service_rcan, &friend_rcan] ) .is_ok()); @@ -406,8 +417,8 @@ mod test { assert!(service_auth .check_invocation_from( bob.verifying_key(), - Rpc::ReadWrite, - &[&service_rcan, &friend_rcan] + &Rpc::ReadWrite, + [&service_rcan, &friend_rcan] ) .is_err()); diff --git a/src/pem.rs b/src/pem.rs new file mode 100644 index 0000000..2b0efd1 --- /dev/null +++ b/src/pem.rs @@ -0,0 +1,65 @@ +use anyhow::Result; +use serde::de::DeserializeOwned; +use ssh_encoding::{pem::PemLabel, Decode, DecodePem, Encode, EncodePem}; + +use crate::{chain::RcanChain, Capability}; + +impl RcanChain { + pub fn from_pem(s: &str) -> Result + where + C: DeserializeOwned, + { + let token = RcanChain::decode_pem(&s)?; + Ok(token) + } + + pub fn to_pem(&self) -> Result { + let s = self.encode_pem_string(Default::default())?; + Ok(s) + } + + #[cfg(feature = "fs")] + pub async fn read_from_file(path: impl AsRef) -> Result + where + C: DeserializeOwned, + { + let s = tokio::fs::read_to_string(path).await?; + Self::from_pem(&s) + } + + #[cfg(feature = "fs")] + pub async fn write_to_file(&self, path: impl AsRef) -> Result<()> { + let s = self.to_pem()?; + tokio::fs::write(path, s).await?; + Ok(()) + } +} + +impl Encode for RcanChain { + fn encoded_len(&self) -> std::result::Result { + Ok(RcanChain::encoded_len(self)) + } + + fn encode( + &self, + writer: &mut impl ssh_encoding::Writer, + ) -> std::result::Result<(), ssh_encoding::Error> { + let bytes = RcanChain::encode(self); + writer.write(&bytes) + } +} + +impl Decode for RcanChain { + type Error = anyhow::Error; + + fn decode(reader: &mut impl ssh_encoding::Reader) -> Result { + let len = reader.remaining_len(); + let mut bytes = vec![0u8; len]; + reader.read(&mut bytes)?; + RcanChain::decode(&bytes) + } +} + +impl PemLabel for RcanChain { + const PEM_LABEL: &'static str = "RCAN CHAIN V1"; +}