diff --git a/ucan-key-support/.cargo/config b/.cargo/config similarity index 100% rename from ucan-key-support/.cargo/config rename to .cargo/config diff --git a/.github/workflows/run_test_suite.yaml b/.github/workflows/run_test_suite.yaml index 1c15eb28..901899f1 100644 --- a/.github/workflows/run_test_suite.yaml +++ b/.github/workflows/run_test_suite.yaml @@ -14,31 +14,39 @@ jobs: ~/.cargo ./target key: ${{ runner.os }}-cargo-artifacts-${{ hashFiles('**/Cargo.toml') }} + - name: 'Install environment packages' + run: | + sudo apt-get update -qqy + sudo apt-get install jq - name: 'Run tests' run: cargo test shell: bash - - name: 'Setup Browserstack environment' - uses: 'browserstack/github-actions/setup-env@master' - with: - username: ${{ secrets.BROWSERSTACK_USERNAME }} - access-key: ${{ secrets.BROWSERSTACK_TOKEN }} - build-name: BUILD_INFO - project-name: REPO_NAME - - name: 'Open connection to Browserstack' - uses: 'browserstack/github-actions/setup-local@master' - with: - local-testing: start - local-identifier: random - - name: 'Run browser tests for ucan-key-support crate' + - name: 'Install Rust/WASM test dependencies' run: | - cd ./ucan-key-support - BROWSERSTACK=1 ./scripts/run_browser_tests.sh + rustup target install wasm32-unknown-unknown + cargo install toml-cli + WASM_BINDGEN_VERSION=`toml get ./Cargo.lock . | jq '.package | map(select(.name == "wasm-bindgen"))[0].version' | xargs echo` + cargo install wasm-bindgen-cli --vers "$WASM_BINDGEN_VERSION" + shell: bash + # See: https://github.com/SeleniumHQ/selenium/blob/5d108f9a679634af0bbc387e7e3811bc1565912b/.github/actions/setup-chrome/action.yml + - name: 'Setup Chrome and chromedriver' + run: | + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update -qqy + sudo apt-get -qqy install google-chrome-stable + CHROME_VERSION=$(google-chrome-stable --version) + CHROME_FULL_VERSION=${CHROME_VERSION%%.*} + CHROME_MAJOR_VERSION=${CHROME_FULL_VERSION//[!0-9]} + sudo rm /etc/apt/sources.list.d/google-chrome.list + export CHROMEDRIVER_VERSION=`curl -s https://chromedriver.storage.googleapis.com/LATEST_RELEASE_${CHROME_MAJOR_VERSION%%.*}` + curl -L -O "https://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" + unzip chromedriver_linux64.zip && chmod +x chromedriver && sudo mv chromedriver /usr/local/bin + chromedriver -version + shell: bash + - name: 'Run Rust headless browser tests' + run: CHROMEDRIVER=/usr/local/bin/chromedriver cargo test --target wasm32-unknown-unknown shell: bash - - name: 'Close connection to Browserstack' - uses: browserstack/github-actions/setup-local@master - if: always() - with: - local-testing: stop publish-release: runs-on: ubuntu-latest needs: [run-test-suite] diff --git a/ucan-key-support/Cargo.toml b/ucan-key-support/Cargo.toml index 22062981..e2565387 100644 --- a/ucan-key-support/Cargo.toml +++ b/ucan-key-support/Cargo.toml @@ -18,7 +18,6 @@ version = "0.4.0-alpha.1" [features] default = [] -web = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "ucan/web", "getrandom/js"] [dependencies] ucan = {path = "../ucan", version = "0.6.0-alpha.1" } @@ -34,20 +33,21 @@ log = "0.4" npm_rs = "0.2.1" [dev-dependencies] -rand = "0.8" +rand = "~0.8" # NOTE: This is needed so that rand can be included in WASM builds -getrandom = { version = "0.2.5", features = ["js"] } -wasm-bindgen-test = "0.3" +getrandom = { version = "~0.2", features = ["js"] } +wasm-bindgen-test = "~0.3" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { version = "^1", features = ["macros", "rt"] } [target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = { version = "0.2", optional = true } -wasm-bindgen-futures = { version = "0.4", optional = true } -js-sys = { version = "0.3", optional = true } +wasm-bindgen = { version = "0.2" } +wasm-bindgen-futures = { version = "0.4" } +js-sys = { version = "0.3" } -[target.'cfg(target_arch="wasm32")'.dependencies.web-sys] +[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys] version = "0.3" -optional = true features = [ 'Window', 'SubtleCrypto', @@ -57,5 +57,5 @@ features = [ 'DedicatedWorkerGlobalScope' ] -[target.'cfg(target_arch="wasm32")'.dev-dependencies] +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] pollster = "0.2.5" \ No newline at end of file diff --git a/ucan-key-support/scripts/run_browser_tests.sh b/ucan-key-support/scripts/run_browser_tests.sh deleted file mode 100755 index fbe986cd..00000000 --- a/ucan-key-support/scripts/run_browser_tests.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash - - -if [ -z `which rustup` ]; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > ./install_rust.sh - sh ./install_rust.sh -y -fi - - -rustup target install wasm32-unknown-unknown - -cargo install toml-cli - -WASM_BINDGEN_VERSION=`toml get ./Cargo.toml 'target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen.version' | xargs echo` - -# See: https://github.com/rustwasm/wasm-bindgen/issues/2841 -# cargo install wasm-bindgen-cli --vers $WASM_BINDGEN_VERSION -cargo install --git https://github.com/cdata/wasm-bindgen wasm-bindgen-cli - -if [ -z ${BROWSERSTACK+x} ]; then - cargo install webdriver-install - webdriver_install --install chromedriver - - CHROMEDRIVER=~/.webdrivers/chromedriver \ - cargo test --target wasm32-unknown-unknown --features web -else - - set -x - - if [ -z `which jq` ]; then - sudo apt install jq - fi - - BROWSERSTACK_SESSION="{ - \"build\": \"$BROWSERSTACK_BUILD_NAME\", - \"project\": \"$BROWSERSTACK_PROJECT_NAME\", - \"browserstack.local\": \"true\", - \"browserstack.localIdentifier\": \"$BROWSERSTACK_LOCAL_IDENTIFIER\", - \"browserstack.user\": \"$BROWSERSTACK_USERNAME\", - \"browserstack.key\": \"$BROWSERSTACK_ACCESS_KEY\" -}" - - # TODO: Locate webdriver.json relative to script being invoked.. - WEBDRIVER="`cat ./webdriver.json`" - echo $WEBDRIVER | jq ". + $BROWSERSTACK_SESSION" > webdriver.json - - cargo build --target wasm32-unknown-unknown --features web - CHROMEDRIVER_REMOTE=https://hub-cloud.browserstack.com/wd/hub/ \ - cargo test --target wasm32-unknown-unknown --features web - - set +x -fi diff --git a/ucan-key-support/src/ed25519.rs b/ucan-key-support/src/ed25519.rs index 0905a149..e975b429 100644 --- a/ucan-key-support/src/ed25519.rs +++ b/ucan-key-support/src/ed25519.rs @@ -7,7 +7,8 @@ use ed25519_zebra::{ use ucan::crypto::KeyMaterial; -pub const ED25519_MAGIC_BYTES: [u8; 2] = [0xed, 0x01]; +pub use ucan::crypto::did::ED25519_MAGIC_BYTES; +pub use ucan::crypto::JwtSignatureAlgorithm; pub fn bytes_to_ed25519_key(bytes: Vec) -> Result> { let public_key = Ed25519PublicKey::try_from(bytes.as_slice())?; @@ -17,15 +18,15 @@ pub fn bytes_to_ed25519_key(bytes: Vec) -> Result> { #[derive(Clone)] pub struct Ed25519KeyMaterial(pub Ed25519PublicKey, pub Option); -#[cfg_attr(all(target_arch="wasm32", feature = "web"), async_trait(?Send))] -#[cfg_attr(any(not(target_arch = "wasm32"), not(feature = "web")), async_trait)] +#[cfg_attr(target_arch="wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl KeyMaterial for Ed25519KeyMaterial { fn get_jwt_algorithm_name(&self) -> String { - "EdDSA".into() + JwtSignatureAlgorithm::EdDSA.to_string() } async fn get_did(&self) -> Result { - let bytes = [ED25519_MAGIC_BYTES.as_slice(), self.0.as_ref()].concat(); + let bytes = [ED25519_MAGIC_BYTES, self.0.as_ref()].concat(); Ok(format!("did:key:z{}", bs58::encode(bytes).into_string())) } @@ -58,7 +59,14 @@ mod tests { ucan::Ucan, }; - #[tokio::test] + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_can_sign_and_verify_a_ucan() { let rng = rand::thread_rng(); let private_key = Ed25519PrivateKey::new(rng); diff --git a/ucan-key-support/src/lib.rs b/ucan-key-support/src/lib.rs index 78c0e226..766b47dd 100644 --- a/ucan-key-support/src/lib.rs +++ b/ucan-key-support/src/lib.rs @@ -1,7 +1,7 @@ #[macro_use] extern crate log; -#[cfg(all(target_arch = "wasm32", feature = "web"))] +#[cfg(target_arch = "wasm32")] pub mod web_crypto; pub mod ed25519; diff --git a/ucan-key-support/src/rsa.rs b/ucan-key-support/src/rsa.rs index 39172c3a..40081dbc 100644 --- a/ucan-key-support/src/rsa.rs +++ b/ucan-key-support/src/rsa.rs @@ -1,17 +1,14 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; -use rsa::{ - Hash, PaddingScheme, PublicKey, RsaPrivateKey, RsaPublicKey, -}; -use rsa::pkcs1::{DecodeRsaPublicKey, EncodeRsaPublicKey}; use rsa::pkcs1::der::{Document, Encodable}; +use rsa::pkcs1::{DecodeRsaPublicKey, EncodeRsaPublicKey}; +use rsa::{Hash, PaddingScheme, PublicKey, RsaPrivateKey, RsaPublicKey}; use sha2::{Digest, Sha256}; -use ucan::crypto::KeyMaterial; +use ucan::crypto::{JwtSignatureAlgorithm, KeyMaterial}; -pub const RSA_MAGIC_BYTES: [u8; 2] = [0x85, 0x24]; -pub const RSA_ALGORITHM: &str = "RSASSA-PKCS1-v1_5"; +pub use ucan::crypto::did::RSA_MAGIC_BYTES; pub fn bytes_to_rsa_key(bytes: Vec) -> Result> { // NOTE: DID bytes are PKCS1, but we are using PKCS8, so do the conversion here.. @@ -25,16 +22,16 @@ pub fn bytes_to_rsa_key(bytes: Vec) -> Result> { #[derive(Clone)] pub struct RsaKeyMaterial(pub RsaPublicKey, pub Option); -#[cfg_attr(all(target_arch="wasm32", feature = "web"), async_trait(?Send))] -#[cfg_attr(any(not(target_arch = "wasm32"), not(feature = "web")), async_trait)] +#[cfg_attr(target_arch="wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl KeyMaterial for RsaKeyMaterial { fn get_jwt_algorithm_name(&self) -> String { - RSA_ALGORITHM.into() + JwtSignatureAlgorithm::RS256.to_string() } async fn get_did(&self) -> Result { let bytes = match self.0.to_pkcs1_der() { - Ok(document) => [RSA_MAGIC_BYTES.as_slice(), document.as_der()].concat(), + Ok(document) => [RSA_MAGIC_BYTES, document.as_der()].concat(), Err(error) => { // TODO: Probably shouldn't swallow this error... warn!("Could not get RSA public key bytes for DID: {:?}", error); @@ -95,7 +92,14 @@ mod tests { use ucan::crypto::KeyMaterial; use ucan::ucan::Ucan; - #[tokio::test] + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_can_sign_and_verify_a_ucan() { let private_key = RsaPrivateKey::from_pkcs8_der(include_bytes!("./fixtures/rsa_key.pk8")).unwrap(); diff --git a/ucan-key-support/src/web_crypto.rs b/ucan-key-support/src/web_crypto.rs index 3b482104..d9b4606c 100644 --- a/ucan-key-support/src/web_crypto.rs +++ b/ucan-key-support/src/web_crypto.rs @@ -1,4 +1,4 @@ -use crate::rsa::{RsaKeyMaterial, RSA_ALGORITHM}; +use crate::rsa::{RsaKeyMaterial}; use anyhow::{anyhow, Result}; use async_trait::async_trait; use js_sys::{Array, ArrayBuffer, Boolean, Object, Reflect, Uint8Array}; @@ -6,6 +6,7 @@ use rsa::RsaPublicKey; use rsa::pkcs1::DecodeRsaPublicKey; use rsa::pkcs1::der::Encodable; use ucan::crypto::KeyMaterial; +use ucan::crypto::JwtSignatureAlgorithm; use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::{Crypto, CryptoKey, CryptoKeyPair, SubtleCrypto}; @@ -17,6 +18,8 @@ pub fn convert_spki_to_rsa_public_key(spki_bytes: &[u8]) -> Result> { Ok(Vec::from(&spki_bytes[24..])) } +pub const WEB_CRYPTO_RSA_ALGORITHM: &str = "RSASSA-PKCS1-v1_5"; + #[derive(Debug)] pub struct WebCryptoRsaKeyMaterial(pub CryptoKey, pub Option); @@ -46,7 +49,7 @@ impl WebCryptoRsaKeyMaterial { Reflect::set( &algorithm, &JsValue::from("name"), - &JsValue::from(RSA_ALGORITHM), + &JsValue::from(WEB_CRYPTO_RSA_ALGORITHM), ) .map_err(|error| anyhow!("{:?}", error))?; @@ -105,7 +108,7 @@ impl WebCryptoRsaKeyMaterial { #[async_trait(?Send)] impl KeyMaterial for WebCryptoRsaKeyMaterial { fn get_jwt_algorithm_name(&self) -> String { - RSA_ALGORITHM.into() + JwtSignatureAlgorithm::RS256.to_string() } async fn get_did(&self) -> Result { @@ -141,7 +144,7 @@ impl KeyMaterial for WebCryptoRsaKeyMaterial { Reflect::set( &algorithm, &JsValue::from("name"), - &JsValue::from(RSA_ALGORITHM), + &JsValue::from(WEB_CRYPTO_RSA_ALGORITHM), ) .map_err(|error| anyhow!("{:?}", error))?; @@ -175,7 +178,7 @@ impl KeyMaterial for WebCryptoRsaKeyMaterial { Reflect::set( &algorithm, &JsValue::from("name"), - &JsValue::from(RSA_ALGORITHM), + &JsValue::from(WEB_CRYPTO_RSA_ALGORITHM), ) .map_err(|error| anyhow!("{:?}", error))?; Reflect::set( diff --git a/ucan/Cargo.toml b/ucan/Cargo.toml index 6c7e152b..0ee60ee7 100644 --- a/ucan/Cargo.toml +++ b/ucan/Cargo.toml @@ -18,24 +18,35 @@ edition = "2021" [features] default = [] -web = ["instant/wasm-bindgen"] [dependencies] +cid = "~0.8" anyhow = "^1" -async-trait = "0.1" +async-trait = "~0.1" async-recursion = "^1" async-std = "^1" serde_json = "^1" serde = { version = "^1", features = ["derive"] } -base64 = "0.13" -textnonce = "^1" -log = "0.4" +base64 = "~0.13" +log = "~0.4" url = "^2" -bs58 = "0.4" +bs58 = "~0.4" +unsigned-varint = "~0.7" +libipld-core = { version = "~0.14", features = ["serde-codec", "serde"] } +libipld-json = "~0.14" +strum = "~0.24" +strum_macros = "~0.24" +instant = { version = "0.1", features = ["wasm-bindgen", "stdweb"] } +rand = "~0.8" [target.'cfg(target_arch = "wasm32")'.dependencies] -instant = { version = "0.1" } +# NOTE: This is needed so that rand can be included in WASM builds +getrandom = { version = "~0.2", features = ["js"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "^1", features = ["macros", "test-util"] } [dev-dependencies] did-key = "0.1" -tokio = { version = "^1", features = ["macros", "rt"] } \ No newline at end of file +serde_ipld_dagcbor = "~0.2" +wasm-bindgen-test = "~0.3" \ No newline at end of file diff --git a/ucan/src/block.rs b/ucan/src/block.rs new file mode 100644 index 00000000..e69de29b diff --git a/ucan/src/builder.rs b/ucan/src/builder.rs index d80f664d..a43ef62e 100644 --- a/ucan/src/builder.rs +++ b/ucan/src/builder.rs @@ -1,17 +1,21 @@ +use std::convert::TryFrom; + use crate::{ capability::{ - proof::ProofDelegationSemantics, Action, Capability, CapabilitySemantics, RawCapability, + proof::ProofDelegationSemantics, Action, Capability, CapabilityIpld, CapabilitySemantics, Scope, }, crypto::KeyMaterial, + serde::Base64Encode, time::now, - ucan::{UcanHeader, UcanPayload}, + ucan::{UcanHeader, UcanPayload, UCAN_VERSION}, }; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; +use cid::Cid; use log::warn; +use rand::Rng; use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; -use textnonce::TextNonce; use crate::ucan::Ucan; @@ -27,7 +31,7 @@ where pub issuer: &'a K, pub audience: String, - pub capabilities: Vec, + pub capabilities: Vec, pub expiration: u64, pub not_before: Option, @@ -41,21 +45,22 @@ impl<'a, K> Signable<'a, K> where K: KeyMaterial, { - pub const UCAN_VERSION: &'static str = "0.8.1"; - /// The header field components of the UCAN JWT pub fn ucan_header(&self) -> UcanHeader { UcanHeader { alg: self.issuer.get_jwt_algorithm_name(), typ: "JWT".into(), - ucv: Self::UCAN_VERSION.into(), + ucv: UCAN_VERSION.into(), } } /// The payload field components of the UCAN JWT pub async fn ucan_payload(&self) -> Result { let nonce = match self.add_nonce { - true => Some(TextNonce::new().to_string()), + true => Some(base64::encode_config( + &rand::thread_rng().gen::<[u8; 32]>(), + base64::URL_SAFE_NO_PAD, + )), false => None, }; @@ -80,17 +85,12 @@ where .await .expect("Unable to generate UCAN payload"); - let header_base64 = match serde_json::to_string(&header) { - Ok(json) => base64::encode_config(json.as_bytes(), base64::URL_SAFE_NO_PAD), - Err(error) => return Err(error).context("Unable to serialize UCAN header as JSON"), - }; + let header_base64 = header.jwt_base64_encode()?; + let payload_base64 = payload.jwt_base64_encode()?; - let payload_base64 = match serde_json::to_string(&payload) { - Ok(json) => base64::encode_config(json.as_bytes(), base64::URL_SAFE_NO_PAD), - Err(error) => return Err(error).context("Unable to serialize UCAN payload as JSON"), - }; - - let data_to_sign = Vec::from(format!("{}.{}", header_base64, payload_base64).as_bytes()); + let data_to_sign = format!("{}.{}", header_base64, payload_base64) + .as_bytes() + .to_vec(); let signature = self.issuer.sign(data_to_sign.as_slice()).await?; Ok(Ucan::new(header, payload, data_to_sign, signature)) @@ -106,7 +106,7 @@ where issuer: Option<&'a K>, audience: Option, - capabilities: Vec, + capabilities: Vec, lifetime: Option, expiration: Option, @@ -207,8 +207,8 @@ where /// Note that the proof's audience must match this UCAN's issuer /// or else the proof chain will be invalidated! pub fn witnessed_by(mut self, authority: &Ucan) -> Self { - match authority.encode() { - Ok(proof) => self.proofs.push(proof), + match Cid::try_from(authority) { + Ok(proof) => self.proofs.push(proof.to_string()), Err(error) => warn!("Failed to add authority to proofs: {}", error), } @@ -222,35 +222,24 @@ where S: Scope, A: Action, { - let raw_capability: RawCapability = capability.clone().into(); - - match serde_json::to_value(raw_capability) { - Ok(value) => self.capabilities.push(value), - Err(error) => warn!("UCAN could not claim capability: {}", error), - } + self.capabilities.push(CapabilityIpld::from(capability)); self } /// Delegate all capabilities from a given proof to the audience of the UCAN /// you're building pub fn delegating_from(mut self, authority: &Ucan) -> Self { - match authority.encode() { + match Cid::try_from(authority) { Ok(proof) => { - self.proofs.push(proof); + self.proofs.push(proof.to_string()); let proof_index = self.proofs.len() - 1; let proof_delegation = ProofDelegationSemantics {}; let capability = - proof_delegation.parse(format!("prf:{}", proof_index), "ucan/DELEGATE".into()); + proof_delegation.parse(&format!("prf:{}", proof_index), "ucan/DELEGATE"); match capability { Some(capability) => { - let raw_capability: RawCapability = capability.into(); - match serde_json::to_value(raw_capability) { - Ok(value) => self.capabilities.push(value), - Err(error) => { - warn!("Unable to convert capability to JSON value: {:?}", error); - } - } + self.capabilities.push(CapabilityIpld::from(&capability)); } None => warn!("Could not produce delegation capability"), } diff --git a/ucan/src/capability/iterator.rs b/ucan/src/capability/iterator.rs index af429525..c386aea0 100644 --- a/ucan/src/capability/iterator.rs +++ b/ucan/src/capability/iterator.rs @@ -2,7 +2,7 @@ use std::marker::PhantomData; use crate::ucan::Ucan; -use super::{Action, Capability, CapabilitySemantics, RawCapability, Scope}; +use super::{Action, Capability, CapabilityIpld, CapabilitySemantics, Scope}; pub struct CapabilityIterator<'a, Semantics, S, A> where Semantics: CapabilitySemantics, @@ -40,15 +40,11 @@ where type Item = Capability; fn next(&mut self) -> Option { - while let Some(capability_json) = self.ucan.attenuation().get(self.index) { + // TODO(#22): Full support for 0.9 and the nb field + while let Some(CapabilityIpld { with, can, .. }) = self.ucan.attenuation().get(self.index) { self.index += 1; - let (raw_with, raw_can) = match serde_json::from_value(capability_json.clone()) { - Ok(RawCapability { with, can }) => (with, can), - _ => continue, - }; - - match self.semantics.parse(raw_with, raw_can) { + match self.semantics.parse(with.as_str(), can.as_str()) { Some(capability) => return Some(capability), None => continue, }; diff --git a/ucan/src/capability/semantics.rs b/ucan/src/capability/semantics.rs index db30d026..1e9ea93d 100644 --- a/ucan/src/capability/semantics.rs +++ b/ucan/src/capability/semantics.rs @@ -1,22 +1,58 @@ +use anyhow::anyhow; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::fmt::Debug; use url::Url; -#[derive(Serialize, Deserialize)] -pub struct RawCapability { +use crate::serde::ser_to_lower_case; + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct CapabilityIpld { pub with: String, + #[serde(serialize_with = "ser_to_lower_case")] pub can: String, + pub nb: Option, } -impl From> for RawCapability -where - S: Scope, - A: Action, -{ - fn from(capability: Capability) -> Self { - RawCapability { - with: capability.with.to_string(), - can: capability.can.to_string(), +impl From<&Capability> for CapabilityIpld { + fn from(capability: &Capability) -> Self { + CapabilityIpld { + with: capability.with().to_string(), + can: capability.can().to_string(), + + // TODO(#22): Full support for 0.9 and the nb field + nb: None, + } + } +} + +impl TryFrom<&Value> for CapabilityIpld { + type Error = anyhow::Error; + + fn try_from(value: &Value) -> Result { + match value { + Value::Object(map) => { + let with = map + .get("with") + .ok_or_else(|| anyhow!("Missing 'with' field"))?; + let can = map + .get("can") + .ok_or_else(|| anyhow!("Missing 'can' field"))?; + let nb = map.get("nb").cloned(); + + let with = match with { + Value::String(with) => with.clone(), + _ => return Err(anyhow!("The 'with' field must be a string")), + }; + + let can = match can { + Value::String(can) => can.to_lowercase(), + _ => return Err(anyhow!("The 'can' field must be a string")), + }; + + Ok(CapabilityIpld { with, can, nb }) + } + _ => Err(anyhow!("Not a valid capability: {}", value)), } } } @@ -159,8 +195,8 @@ where }) } - fn parse(&self, with: String, can: String) -> Option> { - let uri = Url::parse(with.as_str()).ok()?; + fn parse(&self, with: &str, can: &str) -> Option> { + let uri = Url::parse(with).ok()?; let resource = match uri.scheme() { "my" => With::My { diff --git a/ucan/src/chain.rs b/ucan/src/chain.rs index 5b0638b1..273456e2 100644 --- a/ucan/src/chain.rs +++ b/ucan/src/chain.rs @@ -1,4 +1,5 @@ use async_recursion::async_recursion; +use cid::Cid; use std::collections::BTreeSet; use std::fmt::Debug; @@ -8,6 +9,7 @@ use crate::{ Action, Capability, CapabilityIterator, CapabilitySemantics, Resource, Scope, With, }, crypto::did::DidParser, + store::UcanJwtStore, ucan::Ucan, }; use anyhow::{anyhow, Result}; @@ -38,6 +40,7 @@ where } /// A deserialized chain of ancestral proofs that are linked to a UCAN +#[derive(Debug)] pub struct ProofChain { ucan: Ucan, proofs: Vec, @@ -45,18 +48,24 @@ pub struct ProofChain { } impl ProofChain { - #[cfg_attr(all(target_arch="wasm32", feature = "web"), async_recursion(?Send))] - #[cfg_attr( - any(not(target_arch = "wasm32"), not(feature = "web")), - async_recursion - )] - pub async fn from_ucan(ucan: Ucan, did_parser: &mut DidParser) -> Result { + #[cfg_attr(target_arch = "wasm32", async_recursion(?Send))] + #[cfg_attr(not(target_arch = "wasm32"), async_recursion)] + pub async fn from_ucan( + ucan: Ucan, + did_parser: &mut DidParser, + store: &S, + ) -> Result + where + S: UcanJwtStore, + { ucan.validate(did_parser).await?; let mut proofs: Vec = Vec::new(); - for proof_string in ucan.proofs().iter() { - let proof_chain = Self::try_from_token_string(proof_string, did_parser).await?; + for cid_string in ucan.proofs().iter() { + let cid = Cid::try_from(cid_string.as_str())?; + let ucan_token = store.require_token(&cid).await?; + let proof_chain = Self::try_from_token_string(&ucan_token, did_parser, store).await?; proof_chain.validate_link_to(&ucan)?; proofs.push(proof_chain); } @@ -99,12 +108,16 @@ impl ProofChain { todo!("Resolving a proof from a CID not yet implemented") } - pub async fn try_from_token_string<'a>( + pub async fn try_from_token_string<'a, S>( ucan_token_string: &str, did_parser: &mut DidParser, - ) -> Result { + store: &S, + ) -> Result + where + S: UcanJwtStore, + { let ucan = Ucan::try_from_token_string(ucan_token_string)?; - Self::from_ucan(ucan, did_parser).await + Self::from_ucan(ucan, did_parser, store).await } fn validate_link_to(&self, ucan: &Ucan) -> Result<()> { @@ -170,8 +183,8 @@ impl ProofChain { .map(|mut info| { // Redelegated capabilities should be attenuated by // this UCAN's lifetime - info.not_before = *self.ucan.not_before(); - info.expires_at = *self.ucan.expires_at(); + info.not_before = self.ucan.not_before().clone(); + info.expires_at = self.ucan.expires_at().clone(); info }) }) diff --git a/ucan/src/crypto/did.rs b/ucan/src/crypto/did.rs index 46f00e92..a3178bef 100644 --- a/ucan/src/crypto/did.rs +++ b/ucan/src/crypto/did.rs @@ -2,13 +2,21 @@ use super::KeyMaterial; use anyhow::{anyhow, Result}; use std::{collections::BTreeMap, sync::Arc}; -pub type DidPrefix = [u8; 2]; +pub type DidPrefix = &'static [u8]; pub type BytesToKey = fn(Vec) -> Result>; pub type KeyConstructors = BTreeMap; pub type KeyConstructorSlice = [(DidPrefix, BytesToKey)]; pub type KeyCache = BTreeMap>>; -pub const BASE58_DID_PREFIX: &str = "did:key:z"; +pub const DID_PREFIX: &str = "did:"; +pub const DID_KEY_PREFIX: &str = "did:key:z"; + +pub const ED25519_MAGIC_BYTES: &[u8] = &[0xed, 0x01]; +pub const RSA_MAGIC_BYTES: &[u8] = &[0x85, 0x24]; +pub const BLS12381G1_MAGIC_BYTES: &[u8] = &[0xea, 0x01]; +pub const BLS12381G2_MAGIC_BYTES: &[u8] = &[0xeb, 0x01]; +pub const P256_MAGIC_BYTES: &[u8] = &[0x80, 0x24]; +pub const SECP256K1_MAGIC_BYTES: &[u8] = &[0xe7, 0x1]; /// A parser that is able to convert from a DID string into a corresponding /// [`crypto::SigningKey`] implementation. The parser extracts the signature @@ -32,8 +40,8 @@ impl DidParser { } pub fn parse(&mut self, did: &str) -> Result>> { - if !did.starts_with(BASE58_DID_PREFIX) { - return Err(anyhow!("Not a DID: {}", did)); + if !did.starts_with(DID_KEY_PREFIX) { + return Err(anyhow!("Expected valid did:key, got: {}", did)); } let did = did.to_owned(); @@ -41,7 +49,7 @@ impl DidParser { return Ok(key.clone()); } - let did_bytes = bs58::decode(&did[BASE58_DID_PREFIX.len()..]).into_vec()?; + let did_bytes = bs58::decode(&did[DID_KEY_PREFIX.len()..]).into_vec()?; let magic_bytes = &did_bytes[0..2]; match self.key_constructors.get(magic_bytes) { Some(ctor) => { diff --git a/ucan/src/crypto/key.rs b/ucan/src/crypto/key.rs index 15a11dfe..242f96ff 100644 --- a/ucan/src/crypto/key.rs +++ b/ucan/src/crypto/key.rs @@ -1,40 +1,35 @@ +use anyhow::Result; +use async_trait::async_trait; + +#[cfg(not(target_arch = "wasm32"))] +pub trait KeyMaterialConditionalSendSync: Send + Sync {} + +#[cfg(not(target_arch = "wasm32"))] +impl KeyMaterialConditionalSendSync for K where K: KeyMaterial + Send + Sync {} + +#[cfg(target_arch = "wasm32")] +pub trait KeyMaterialConditionalSendSync {} + +#[cfg(target_arch = "wasm32")] +impl KeyMaterialConditionalSendSync for K where K: KeyMaterial {} + /// This trait must be implemented by a struct that encapsulates cryptographic /// keypair data. The trait represent the minimum required API capability for /// producing a signed UCAN from a cryptographic keypair, and verifying such /// signatures. -#[cfg(all(target_arch = "wasm32", feature = "web"))] -mod internal { - use anyhow::Result; - use async_trait::async_trait; - - #[async_trait(?Send)] - pub trait KeyMaterial { - fn get_jwt_algorithm_name(&self) -> String; - async fn get_did(&self) -> Result; - - /// Sign some data with this key - async fn sign(&self, payload: &[u8]) -> Result>; - - /// Verify the alleged signature of some data against this key - async fn verify(&self, payload: &[u8], signature: &[u8]) -> Result<()>; - } -} -#[cfg(any(not(target_arch = "wasm32"), not(feature = "web")))] -mod internal { - use anyhow::Result; - use async_trait::async_trait; - - #[async_trait] - pub trait KeyMaterial: Sync + Send { - fn get_jwt_algorithm_name(&self) -> String; - async fn get_did(&self) -> Result; - - /// Sign some data with this key - async fn sign(&self, payload: &[u8]) -> Result>; - - /// Verify the alleged signature of some data against this key - async fn verify(&self, payload: &[u8], signature: &[u8]) -> Result<()>; - } -} +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +pub trait KeyMaterial: KeyMaterialConditionalSendSync { + /// The algorithm that will be used to produce the signature returned by the + /// sign method in this implementation + fn get_jwt_algorithm_name(&self) -> String; + + /// Provides a valid DID that can be used to solve the key + async fn get_did(&self) -> Result; -pub use internal::*; + /// Sign some data with this key + async fn sign(&self, payload: &[u8]) -> Result>; + + /// Verify the alleged signature of some data against this key + async fn verify(&self, payload: &[u8], signature: &[u8]) -> Result<()>; +} diff --git a/ucan/src/crypto/mod.rs b/ucan/src/crypto/mod.rs index 4a6e913b..75ac72f0 100644 --- a/ucan/src/crypto/mod.rs +++ b/ucan/src/crypto/mod.rs @@ -1,3 +1,6 @@ pub mod did; mod key; +mod signature; + pub use key::*; +pub use signature::*; diff --git a/ucan/src/crypto/signature.rs b/ucan/src/crypto/signature.rs new file mode 100644 index 00000000..27c6f4e2 --- /dev/null +++ b/ucan/src/crypto/signature.rs @@ -0,0 +1,12 @@ +use strum_macros::{Display, EnumString}; + +// See: https://www.rfc-editor.org/rfc/rfc7518 +// See: https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A.4 +#[derive(Debug, Display, EnumString, PartialEq)] +pub enum JwtSignatureAlgorithm { + EdDSA, + RS256, + ES256, + ES384, + ES512, +} diff --git a/ucan/src/ipld/capability.rs b/ucan/src/ipld/capability.rs new file mode 100644 index 00000000..070bc278 --- /dev/null +++ b/ucan/src/ipld/capability.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::serde::ser_to_lower_case; + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct CapabilityIpld { + pub with: String, + #[serde(serialize_with = "ser_to_lower_case")] + pub can: String, + pub nb: Option, +} + +#[cfg(test)] +mod tests { + use super::CapabilityIpld; + use crate::tests::helpers::dag_cbor_roundtrip; + use serde_json::json; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn it_lower_cases_capability_can_field() { + let capability = dag_cbor_roundtrip(&CapabilityIpld { + with: "foo:bar".into(), + can: "Baz".into(), + nb: None, + }) + .unwrap(); + + assert_eq!(capability.can, "baz"); + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn it_round_trips_a_capability_with_nb() { + let capability = dag_cbor_roundtrip(&CapabilityIpld { + with: "foo:bar".into(), + can: "Baz".into(), + nb: Some(json!({ "foo": "bar" })), + }) + .unwrap(); + + assert_eq!(capability.nb, Some(json!({ "foo": "bar" }))); + } +} diff --git a/ucan/src/ipld/mod.rs b/ucan/src/ipld/mod.rs new file mode 100644 index 00000000..f65403ee --- /dev/null +++ b/ucan/src/ipld/mod.rs @@ -0,0 +1,9 @@ +mod capability; +mod principle; +mod signature; +mod ucan; + +pub use self::ucan::*; +pub use capability::*; +pub use principle::*; +pub use signature::*; diff --git a/ucan/src/ipld/principle.rs b/ucan/src/ipld/principle.rs new file mode 100644 index 00000000..dda1860c --- /dev/null +++ b/ucan/src/ipld/principle.rs @@ -0,0 +1,73 @@ +use anyhow::anyhow; + +use serde::{Deserialize, Serialize}; + +use std::{fmt::Display, str::FromStr}; + +use crate::crypto::did::{DID_KEY_PREFIX, DID_PREFIX}; + +// Note: varint encoding of 0x0d1d +pub const DID_IPLD_PREFIX: &[u8] = &[0x9d, 0x1a]; + +#[repr(transparent)] +#[derive(Serialize, Deserialize)] +pub struct Principle(Vec); + +impl FromStr for Principle { + type Err = anyhow::Error; + + fn from_str(input: &str) -> Result { + if input.starts_with(DID_KEY_PREFIX) { + Ok(Principle( + bs58::decode(&input[DID_KEY_PREFIX.len()..]).into_vec()?, + )) + } else if input.starts_with(DID_PREFIX) { + Ok(Principle( + [DID_IPLD_PREFIX, input[DID_PREFIX.len()..].as_bytes()].concat(), + )) + } else { + Err(anyhow!("This is not a DID: {}", input)) + } + } +} + +impl Display for Principle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let bytes = &self.0; + let did_content = match &bytes[0..2] { + DID_IPLD_PREFIX => [ + DID_PREFIX, + &std::str::from_utf8(&bytes[2..]).map_err(|_| std::fmt::Error)?, + ] + .concat(), + _ => [DID_KEY_PREFIX, &bs58::encode(bytes).into_string()].concat(), + }; + + write!(f, "{}", did_content) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::{ipld::Principle, tests::helpers::dag_cbor_roundtrip}; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn it_round_trips_a_principle_did() { + let did_string = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; + let principle = dag_cbor_roundtrip(&Principle::from_str(&did_string).unwrap()).unwrap(); + assert_eq!(did_string, principle.to_string()); + + let did_string = "did:web:example.com"; + let principle = dag_cbor_roundtrip(&Principle::from_str(&did_string).unwrap()).unwrap(); + assert_eq!(did_string, principle.to_string()); + } +} diff --git a/ucan/src/ipld/signature.rs b/ucan/src/ipld/signature.rs new file mode 100644 index 00000000..5324221d --- /dev/null +++ b/ucan/src/ipld/signature.rs @@ -0,0 +1,193 @@ +use anyhow::{anyhow, Result}; +use std::{convert::TryFrom, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +use crate::crypto::JwtSignatureAlgorithm; + +// See https://github.com/ucan-wg/ts-ucan/blob/99c9fc4f89fc917cf08d7fb09685705876b960f4/packages/default-plugins/src/prefixes.ts#L1-L6 +// See https://github.com/multiformats/unsigned-varint +const NONSTANDARD_VARSIG_PREFIX: u64 = 0xd000; +const ES256K_VARSIG_PREFIX: u64 = 0xd0e7; +const BLS12381G1_VARSIG_PREFIX: u64 = 0xd0ea; +const BLS12381G2_VARSIG_PREFIX: u64 = 0xd0eb; +const EDDSA_VARSIG_PREFIX: u64 = 0xd0ed; +const ES256_VARSIG_PREFIX: u64 = 0xd01200; +const ES384_VARSIG_PREFIX: u64 = 0xd01201; +const ES512_VARSIG_PREFIX: u64 = 0xd01202; +const RS256_VARSIG_PREFIX: u64 = 0xd01205; +const EIP191_VARSIG_PREFIX: u64 = 0xd191; + +/// A helper for transforming signatures used in JWTs to their UCAN-IPLD +/// counterpart representation and vice-versa +/// Note, not all valid JWT signature algorithms are represented by this +/// library, nor are all valid varsig prefixes +/// See https://github.com/ucan-wg/ucan-ipld#25-signature +#[derive(Debug, PartialEq)] +pub enum VarsigPrefix { + NonStandard, + ES256K, + BLS12381G1, + BLS12381G2, + EdDSA, + ES256, + ES384, + ES512, + RS256, + EIP191, +} + +impl TryFrom for VarsigPrefix { + type Error = anyhow::Error; + + fn try_from(value: JwtSignatureAlgorithm) -> Result { + Ok(match value { + JwtSignatureAlgorithm::EdDSA => VarsigPrefix::EdDSA, + JwtSignatureAlgorithm::RS256 => VarsigPrefix::RS256, + JwtSignatureAlgorithm::ES256 => VarsigPrefix::ES256, + JwtSignatureAlgorithm::ES384 => VarsigPrefix::ES384, + JwtSignatureAlgorithm::ES512 => VarsigPrefix::ES512, + }) + } +} + +impl TryFrom for JwtSignatureAlgorithm { + type Error = anyhow::Error; + + fn try_from(value: VarsigPrefix) -> Result { + Ok(match value { + VarsigPrefix::EdDSA => JwtSignatureAlgorithm::EdDSA, + VarsigPrefix::RS256 => JwtSignatureAlgorithm::RS256, + VarsigPrefix::ES256 => JwtSignatureAlgorithm::ES256, + VarsigPrefix::ES384 => JwtSignatureAlgorithm::ES384, + VarsigPrefix::ES512 => JwtSignatureAlgorithm::ES512, + _ => { + return Err(anyhow!( + "JWT signature algorithm name for {:?} is not known", + value + )) + } + }) + } +} + +impl FromStr for VarsigPrefix { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + VarsigPrefix::try_from(JwtSignatureAlgorithm::from_str(s)?) + } +} + +impl From for u64 { + fn from(value: VarsigPrefix) -> Self { + match value { + VarsigPrefix::NonStandard { .. } => NONSTANDARD_VARSIG_PREFIX, + VarsigPrefix::ES256K => ES256K_VARSIG_PREFIX, + VarsigPrefix::BLS12381G1 => BLS12381G1_VARSIG_PREFIX, + VarsigPrefix::BLS12381G2 => BLS12381G2_VARSIG_PREFIX, + VarsigPrefix::EdDSA => EDDSA_VARSIG_PREFIX, + VarsigPrefix::ES256 => ES256_VARSIG_PREFIX, + VarsigPrefix::ES384 => ES384_VARSIG_PREFIX, + VarsigPrefix::ES512 => ES512_VARSIG_PREFIX, + VarsigPrefix::RS256 => RS256_VARSIG_PREFIX, + VarsigPrefix::EIP191 => EIP191_VARSIG_PREFIX, + } + } +} + +impl TryFrom for VarsigPrefix { + type Error = anyhow::Error; + + fn try_from(value: u64) -> Result { + Ok(match value { + EDDSA_VARSIG_PREFIX => VarsigPrefix::EdDSA, + RS256_VARSIG_PREFIX => VarsigPrefix::RS256, + ES256K_VARSIG_PREFIX => VarsigPrefix::ES256K, + BLS12381G1_VARSIG_PREFIX => VarsigPrefix::BLS12381G1, + BLS12381G2_VARSIG_PREFIX => VarsigPrefix::BLS12381G2, + EIP191_VARSIG_PREFIX => VarsigPrefix::EIP191, + ES256_VARSIG_PREFIX => VarsigPrefix::ES256, + ES384_VARSIG_PREFIX => VarsigPrefix::ES384, + ES512_VARSIG_PREFIX => VarsigPrefix::ES512, + NONSTANDARD_VARSIG_PREFIX => VarsigPrefix::NonStandard, + _ => return Err(anyhow!("Signature does not have a recognized prefix")), + }) + } +} + +/// An envelope for the UCAN-IPLD-equivalent of a UCAN's JWT signature, which +/// is a specified prefix in front of the raw signature bytes +/// See: https://github.com/ucan-wg/ucan-ipld#25-signature +#[repr(transparent)] +#[derive(Serialize, Deserialize)] +pub struct Signature(pub Vec); + +impl Signature { + pub fn decode(&self) -> Result<(JwtSignatureAlgorithm, Vec)> { + let buffer = self.0.as_slice(); + let (prefix, buffer) = unsigned_varint::decode::u64(buffer)?; + let (signature_length, buffer) = unsigned_varint::decode::usize(buffer)?; + + // TODO: Non-standard algorithm support here... + + let algorithm = JwtSignatureAlgorithm::try_from(VarsigPrefix::try_from(prefix)?)?; + let signature = buffer[..signature_length].to_vec(); + + Ok((algorithm, signature)) + } +} + +// TODO: Support non-standard signature algorithms for experimental purposes +// Note that non-standard signatures should additionally have the signature name +// appended after the signature bytes in the varsig representation +impl> TryFrom<(JwtSignatureAlgorithm, T)> for Signature { + type Error = anyhow::Error; + + fn try_from((algorithm, signature): (JwtSignatureAlgorithm, T)) -> Result { + // TODO: Non-standard JWT algorithm support here + let signature_bytes = signature.as_ref(); + let prefix = VarsigPrefix::try_from(algorithm)?; + let mut prefix_buffer = unsigned_varint::encode::u64_buffer(); + let prefix_bytes = unsigned_varint::encode::u64(prefix.into(), &mut prefix_buffer); + let mut size_buffer = unsigned_varint::encode::usize_buffer(); + + let size_bytes = unsigned_varint::encode::usize(signature_bytes.len(), &mut size_buffer); + + Ok(Signature( + [prefix_bytes, size_bytes, signature_bytes].concat(), + )) + } +} + +#[cfg(test)] +mod tests { + use crate::{crypto::JwtSignatureAlgorithm, ipld::Signature}; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn it_can_convert_between_jwt_and_bytesprefix_form() { + let token_signature = "Ab-xfYRoqYEHuo-252MKXDSiOZkLD-h1gHt8gKBP0AVdJZ6Jruv49TLZOvgWy9QkCpiwKUeGVbHodKcVx-azCQ"; + let signature_bytes = + base64::decode_config(token_signature, base64::URL_SAFE_NO_PAD).unwrap(); + + let bytesprefix_signature = + Signature::try_from((JwtSignatureAlgorithm::EdDSA, &signature_bytes)).unwrap(); + + let (decoded_algorithm, decoded_signature_bytes) = bytesprefix_signature.decode().unwrap(); + + assert_eq!(decoded_algorithm, JwtSignatureAlgorithm::EdDSA); + assert_eq!(decoded_signature_bytes, signature_bytes); + } + + // #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[ignore = "Support non-standard signature algorithms"] + fn it_can_convert_between_jwt_and_bytesprefix_for_nonstandard_signatures() {} +} diff --git a/ucan/src/ipld/ucan.rs b/ucan/src/ipld/ucan.rs new file mode 100644 index 00000000..97be2ea9 --- /dev/null +++ b/ucan/src/ipld/ucan.rs @@ -0,0 +1,182 @@ +use cid::Cid; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{convert::TryFrom, str::FromStr}; + +use crate::crypto::JwtSignatureAlgorithm; +use crate::serde::Base64Encode; +use crate::{capability::CapabilityIpld, ucan::Ucan}; + +use crate::ipld::{Principle, Signature}; +use crate::ucan::UcanPayload; +use crate::ucan::{UcanHeader, UCAN_VERSION}; + +#[derive(Serialize, Deserialize)] +pub struct UcanIpld { + pub v: String, + + pub iss: Principle, + pub aud: Principle, + pub s: Signature, + + pub att: Vec, + pub prf: Vec, + pub exp: u64, + pub fct: Vec, + + pub nnc: Option, + pub nbf: Option, +} + +impl TryFrom<&Ucan> for UcanIpld { + type Error = anyhow::Error; + + fn try_from(ucan: &Ucan) -> Result { + let mut prf = Vec::new(); + for cid_string in ucan.proofs() { + prf.push(Cid::try_from(cid_string.as_str())?); + } + + Ok(UcanIpld { + v: ucan.version().to_string(), + iss: Principle::from_str(ucan.issuer())?, + aud: Principle::from_str(ucan.audience())?, + s: Signature::try_from(( + JwtSignatureAlgorithm::from_str(ucan.algorithm())?, + ucan.signature(), + ))?, + att: ucan.attenuation().clone(), + prf, + exp: ucan.expires_at().clone(), + fct: ucan.facts().clone(), + nnc: ucan.nonce().as_ref().cloned(), + nbf: ucan.not_before().clone(), + }) + } +} + +impl TryFrom<&UcanIpld> for Ucan { + type Error = anyhow::Error; + + fn try_from(value: &UcanIpld) -> Result { + let (algorithm, signature) = value.s.decode()?; + + let header = UcanHeader { + alg: algorithm.to_string(), + typ: "JWT".into(), + ucv: UCAN_VERSION.into(), + }; + + let payload = UcanPayload { + iss: value.iss.to_string(), + aud: value.aud.to_string(), + exp: value.exp, + nbf: value.nbf, + nnc: value.nnc.clone(), + att: value.att.clone(), + fct: value.fct.clone(), + prf: value.prf.iter().map(|cid| cid.to_string()).collect(), + }; + + let signed_data = format!( + "{}.{}", + header.jwt_base64_encode()?, + payload.jwt_base64_encode()? + ) + .as_bytes() + .to_vec(); + + Ok(Ucan::new(header, payload, signed_data, signature)) + } +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + use serde_json::json; + + use crate::{ + tests::{ + fixtures::Identities, + helpers::{dag_cbor_roundtrip, scaffold_ucan_builder}, + }, + Ucan, + }; + + use super::UcanIpld; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] + async fn it_produces_canonical_jwt_despite_json_ambiguity() { + let identities = Identities::new().await; + let canon_builder = scaffold_ucan_builder(&identities).await.unwrap(); + let other_builder = scaffold_ucan_builder(&identities).await.unwrap(); + + let canon_jwt = canon_builder + .with_fact(json!({ + "baz": true, + "foo": "bar" + })) + .build() + .unwrap() + .sign() + .await + .unwrap() + .encode() + .unwrap(); + + let other_jwt = other_builder + .with_fact(json!({ + "foo": "bar", + "baz": true + })) + .build() + .unwrap() + .sign() + .await + .unwrap() + .encode() + .unwrap(); + + assert_eq!(canon_jwt, other_jwt); + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] + async fn it_stays_canonical_when_converting_between_jwt_and_ipld() { + let identities = Identities::new().await; + let builder = scaffold_ucan_builder(&identities).await.unwrap(); + + let jwt = builder + .with_fact(json!({ + "baz": true, + "foo": "bar" + })) + .with_nonce() + .build() + .unwrap() + .sign() + .await + .unwrap() + .encode() + .unwrap(); + + let ucan = Ucan::try_from_token_string(&jwt).unwrap(); + let ucan_ipld = UcanIpld::try_from(&ucan).unwrap(); + + let decoded_ucan_ipld = dag_cbor_roundtrip(&ucan_ipld).unwrap(); + + let decoded_ucan = Ucan::try_from(&decoded_ucan_ipld).unwrap(); + + let decoded_jwt = decoded_ucan.encode().unwrap(); + + assert_eq!(jwt, decoded_jwt); + } +} diff --git a/ucan/src/lib.rs b/ucan/src/lib.rs index cd381abc..3d22677a 100644 --- a/ucan/src/lib.rs +++ b/ucan/src/lib.rs @@ -44,22 +44,24 @@ //! use ucan::{ //! chain::{ProofChain, CapabilityInfo}, //! capability::{CapabilitySemantics, Scope, Action}, -//! crypto::did::{DidParser, KeyConstructorSlice} +//! crypto::did::{DidParser, KeyConstructorSlice}, +//! store::UcanJwtStore //! }; //! //! const SUPPORTED_KEY_TYPES: &KeyConstructorSlice = &[ //! // You must bring your own key support //! ]; //! -//! async fn get_capabilities<'a, Semantics, S, A>(ucan_token: &'a str, semantics: &'a Semantics) -> Result>, anyhow::Error> +//! async fn get_capabilities<'a, Semantics, S, A, Store>(ucan_token: &'a str, semantics: &'a Semantics, store: &'a Store) -> Result>, anyhow::Error> //! where //! Semantics: CapabilitySemantics, //! S: Scope, -//! A: Action +//! A: Action, +//! Store: UcanJwtStore //! { //! let mut did_parser = DidParser::new(SUPPORTED_KEY_TYPES); //! -//! Ok(ProofChain::try_from_token_string(ucan_token, &mut did_parser).await? +//! Ok(ProofChain::try_from_token_string(ucan_token, &mut did_parser, store).await? //! .reduce_capabilities(semantics)) //! } //! ``` @@ -77,10 +79,15 @@ pub mod crypto; pub mod time; +pub mod block; pub mod builder; pub mod capability; pub mod chain; +pub mod ipld; +pub mod serde; +pub mod store; pub mod ucan; +pub use self::ucan::Ucan; #[cfg(test)] mod tests; diff --git a/ucan/src/serde.rs b/ucan/src/serde.rs new file mode 100644 index 00000000..2a693463 --- /dev/null +++ b/ucan/src/serde.rs @@ -0,0 +1,50 @@ +use std::io::Cursor; + +use anyhow::Result; +use libipld_core::ipld::Ipld; +use libipld_core::serde::from_ipld; +use libipld_core::{ + codec::{Decode, Encode}, + serde::to_ipld, +}; +use libipld_json::DagJsonCodec; +use serde::{de::DeserializeOwned, Serialize, Serializer}; + +/// Utility function to enforce lower-case string values when serializing +pub fn ser_to_lower_case(string: &String, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&string.to_lowercase()) +} + +/// Helper trait to ser/de any serde-implementing value to/from DAG-JSON +pub trait DagJson: Serialize + DeserializeOwned { + fn to_dag_json(&self) -> Result> { + let ipld = to_ipld(self)?; + let mut json_bytes = Vec::new(); + + ipld.encode(DagJsonCodec, &mut json_bytes)?; + + Ok(json_bytes) + } + + fn from_dag_json(json_bytes: &[u8]) -> Result { + let ipld = Ipld::decode(DagJsonCodec, &mut Cursor::new(json_bytes))?; + Ok(from_ipld(ipld)?) + } +} + +impl DagJson for T where T: Serialize + DeserializeOwned {} + +/// Helper trait to encode structs as base64 as part of creating a JWT +pub trait Base64Encode: DagJson { + fn jwt_base64_encode(&self) -> Result { + Ok(base64::encode_config( + self.to_dag_json()?, + base64::URL_SAFE_NO_PAD, + )) + } +} + +impl Base64Encode for T where T: DagJson {} diff --git a/ucan/src/store.rs b/ucan/src/store.rs new file mode 100644 index 00000000..fe0de952 --- /dev/null +++ b/ucan/src/store.rs @@ -0,0 +1,124 @@ +use std::{collections::HashMap, io::Cursor, sync::Arc}; + +use anyhow::{anyhow, Result}; +use async_std::sync::Mutex; +use async_trait::async_trait; +use cid::{ + multihash::{Code, MultihashDigest}, + Cid, +}; +use libipld_core::{ + codec::{Codec, Decode, Encode}, + ipld::Ipld, + raw::RawCodec, +}; + +#[cfg(not(target_arch = "wasm32"))] +pub trait UcanStoreConditionalSend: Send {} + +#[cfg(not(target_arch = "wasm32"))] +impl UcanStoreConditionalSend for U where U: Send {} + +#[cfg(target_arch = "wasm32")] +pub trait UcanStoreConditionalSend {} + +#[cfg(target_arch = "wasm32")] +impl UcanStoreConditionalSend for U {} + +#[cfg(not(target_arch = "wasm32"))] +pub trait UcanStoreConditionalSendSync: Send + Sync {} + +#[cfg(not(target_arch = "wasm32"))] +impl UcanStoreConditionalSendSync for U where U: Send + Sync {} + +#[cfg(target_arch = "wasm32")] +pub trait UcanStoreConditionalSendSync {} + +#[cfg(target_arch = "wasm32")] +impl UcanStoreConditionalSendSync for U {} + +/// This trait is meant to be implemented by a storage backend suitable for +/// persisting UCAN tokens that may be referenced as proofs by other UCANs +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +pub trait UcanStore: UcanStoreConditionalSendSync { + async fn read>(&self, cid: &Cid) -> Result>; + + async fn write + UcanStoreConditionalSend + core::fmt::Debug>( + &self, + token: T, + ) -> Result; +} + +/// This trait is sugar over the UcanStore trait to add convenience methods +/// for the case of storing JWT-encoded UCAN strings using the 'raw' codec +/// which is the only combination strictly required by the UCAN spec +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +pub trait UcanJwtStore: UcanStore { + async fn require_token(&self, cid: &Cid) -> Result { + match self.read_token(cid).await? { + Some(token) => Ok(token), + None => Err(anyhow!("No token found for CID {}", cid.to_string())), + } + } + + async fn read_token(&self, cid: &Cid) -> Result> { + let codec = RawCodec::default(); + + if cid.codec() != u64::from(codec) { + return Err(anyhow!( + "Only 'raw' codec supported, but CID refers to {:#x}", + cid.codec() + )); + } + + match self.read::(cid).await? { + Some(Ipld::Bytes(bytes)) => Ok(Some(std::str::from_utf8(&bytes)?.to_string())), + _ => Err(anyhow!("No UCAN was found for CID {:?}", cid)), + } + } + + async fn write_token(&self, token: &str) -> Result { + self.write(Ipld::Bytes(token.as_bytes().to_vec())).await + } +} + +impl UcanJwtStore for U where U: UcanStore {} + +/// A basic in-memory store that implements UcanStore for the 'raw' +/// codec. This will serve for basic use cases and tests, but it is +/// recommended that a store that persists to disk be used in most +/// practical use cases. +#[derive(Clone, Default, Debug)] +pub struct MemoryStore { + dags: Arc>>>, +} + +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +impl UcanStore for MemoryStore { + async fn read>(&self, cid: &Cid) -> Result> { + let codec = RawCodec::default(); + let dags = self.dags.lock().await; + + Ok(match dags.get(cid) { + Some(bytes) => Some(T::decode(codec, &mut Cursor::new(bytes))?), + None => None, + }) + } + + async fn write + UcanStoreConditionalSend + core::fmt::Debug>( + &self, + token: T, + ) -> Result { + let codec = RawCodec::default(); + let block = codec.encode(&token)?; + let cid = Cid::new_v1(codec.into(), Code::Sha2_256.digest(&block)); + + let mut dags = self.dags.lock().await; + dags.insert(cid.clone(), block); + + Ok(cid) + } +} diff --git a/ucan/src/tests/attenuation.rs b/ucan/src/tests/attenuation.rs index 871d2f9a..f4b5ca66 100644 --- a/ucan/src/tests/attenuation.rs +++ b/ucan/src/tests/attenuation.rs @@ -1,21 +1,29 @@ use std::collections::BTreeSet; use super::fixtures::{EmailSemantics, Identities, SUPPORTED_KEYS}; -use crate::capability::CapabilitySemantics; use crate::{ builder::UcanBuilder, + capability::CapabilitySemantics, chain::{CapabilityInfo, ProofChain}, crypto::did::DidParser, + store::{MemoryStore, UcanJwtStore}, }; -#[tokio::test] +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test_configure!(run_in_browser); + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] pub async fn it_works_with_a_simple_example() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); let email_semantics = EmailSemantics {}; let send_email_as_alice = email_semantics - .parse("mailto:alice@email.com".into(), "email/SEND".into()) + .parse("mailto:alice@email.com", "email/send") .unwrap(); let leaf_ucan = UcanBuilder::default() @@ -43,10 +51,17 @@ pub async fn it_works_with_a_simple_example() { .encode() .unwrap(); - let chain = ProofChain::try_from_token_string(attenuated_token.as_str(), &mut did_parser) + let store = MemoryStore::default(); + store + .write_token(&leaf_ucan.encode().unwrap()) .await .unwrap(); + let chain = + ProofChain::try_from_token_string(attenuated_token.as_str(), &mut did_parser, &store) + .await + .unwrap(); + let capability_infos = chain.reduce_capabilities(&email_semantics); assert_eq!(capability_infos.len(), 1); @@ -57,17 +72,18 @@ pub async fn it_works_with_a_simple_example() { info.capability.with().to_string().as_str(), "mailto:alice@email.com", ); - assert_eq!(info.capability.can().to_string().as_str(), "email/SEND"); + assert_eq!(info.capability.can().to_string().as_str(), "email/send"); } -#[tokio::test] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] pub async fn it_reports_the_first_issuer_in_the_chain_as_originator() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); let email_semantics = EmailSemantics {}; let send_email_as_bob = email_semantics - .parse("mailto:bob@email.com".into(), "email/SEND".into()) + .parse("mailto:bob@email.com".into(), "email/send".into()) .unwrap(); let leaf_ucan = UcanBuilder::default() @@ -94,7 +110,13 @@ pub async fn it_reports_the_first_issuer_in_the_chain_as_originator() { .encode() .unwrap(); - let capability_infos = ProofChain::try_from_token_string(&ucan_token, &mut did_parser) + let store = MemoryStore::default(); + store + .write_token(&leaf_ucan.encode().unwrap()) + .await + .unwrap(); + + let capability_infos = ProofChain::try_from_token_string(&ucan_token, &mut did_parser, &store) .await .unwrap() .reduce_capabilities(&email_semantics); @@ -110,17 +132,18 @@ pub async fn it_reports_the_first_issuer_in_the_chain_as_originator() { assert_eq!(info.capability, send_email_as_bob); } -#[tokio::test] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] pub async fn it_finds_the_right_proof_chain_for_the_originator() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); let email_semantics = EmailSemantics {}; let send_email_as_bob = email_semantics - .parse("mailto:bob@email.com".into(), "email/SEND".into()) + .parse("mailto:bob@email.com".into(), "email/send".into()) .unwrap(); let send_email_as_alice = email_semantics - .parse("mailto:alice@email.com".into(), "email/SEND".into()) + .parse("mailto:alice@email.com".into(), "email/send".into()) .unwrap(); let leaf_ucan_alice = UcanBuilder::default() @@ -161,7 +184,17 @@ pub async fn it_finds_the_right_proof_chain_for_the_originator() { let ucan_token = ucan.encode().unwrap(); - let proof_chain = ProofChain::try_from_token_string(&ucan_token, &mut did_parser) + let store = MemoryStore::default(); + store + .write_token(&leaf_ucan_alice.encode().unwrap()) + .await + .unwrap(); + store + .write_token(&leaf_ucan_bob.encode().unwrap()) + .await + .unwrap(); + + let proof_chain = ProofChain::try_from_token_string(&ucan_token, &mut did_parser, &store) .await .unwrap(); let capability_infos = proof_chain.reduce_capabilities(&email_semantics); @@ -192,14 +225,15 @@ pub async fn it_finds_the_right_proof_chain_for_the_originator() { ); } -#[tokio::test] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] pub async fn it_reports_all_chain_options() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); let email_semantics = EmailSemantics {}; let send_email_as_alice = email_semantics - .parse("mailto:alice@email.com".into(), "email/SEND".into()) + .parse("mailto:alice@email.com".into(), "email/send".into()) .unwrap(); let leaf_ucan_alice = UcanBuilder::default() @@ -239,7 +273,17 @@ pub async fn it_reports_all_chain_options() { let ucan_token = ucan.encode().unwrap(); - let proof_chain = ProofChain::try_from_token_string(&ucan_token, &mut did_parser) + let store = MemoryStore::default(); + store + .write_token(&leaf_ucan_alice.encode().unwrap()) + .await + .unwrap(); + store + .write_token(&leaf_ucan_bob.encode().unwrap()) + .await + .unwrap(); + + let proof_chain = ProofChain::try_from_token_string(&ucan_token, &mut did_parser, &store) .await .unwrap(); let capability_infos = proof_chain.reduce_capabilities(&email_semantics); diff --git a/ucan/src/tests/builder.rs b/ucan/src/tests/builder.rs index ed8ecef0..10c4aedb 100644 --- a/ucan/src/tests/builder.rs +++ b/ucan/src/tests/builder.rs @@ -1,12 +1,20 @@ use crate::{ builder::UcanBuilder, - capability::{CapabilitySemantics, RawCapability}, + capability::{CapabilityIpld, CapabilitySemantics}, tests::fixtures::{EmailSemantics, Identities, WNFSSemantics}, time::now, }; +use cid::Cid; use serde_json::json; -#[tokio::test] +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test_configure!(run_in_browser); + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_builds_with_a_simple_example() { let identities = Identities::new().await; @@ -23,14 +31,11 @@ async fn it_builds_with_a_simple_example() { let wnfs_semantics = WNFSSemantics {}; let cap_1 = email_semantics - .parse("mailto:alice@gmail.com".into(), "email/SEND".into()) + .parse("mailto:alice@gmail.com", "email/send") .unwrap(); let cap_2 = wnfs_semantics - .parse( - "wnfs://alice.fission.name/public".into(), - "wnfs/SUPER_USER".into(), - ) + .parse("wnfs://alice.fission.name/public", "wnfs/super_user") .unwrap(); let expiration = now() + 30; @@ -51,23 +56,22 @@ async fn it_builds_with_a_simple_example() { let ucan = token.sign().await.unwrap(); - assert_eq!(*ucan.issuer(), identities.alice_did); - assert_eq!(*ucan.audience(), identities.bob_did); - assert_eq!(*ucan.expires_at(), expiration); + assert_eq!(ucan.issuer(), identities.alice_did); + assert_eq!(ucan.audience(), identities.bob_did); + assert_eq!(ucan.expires_at(), &expiration); assert!(ucan.not_before().is_some()); assert_eq!(ucan.not_before().unwrap(), not_before); - assert_eq!(*ucan.facts(), Vec::from([fact_1, fact_2])); + assert_eq!(ucan.facts(), &vec![fact_1, fact_2]); - let expected_attenuations = Vec::from([ - serde_json::to_value(RawCapability::from(cap_1)).unwrap(), - serde_json::to_value(RawCapability::from(cap_2)).unwrap(), - ]); + let expected_attenuations = + Vec::from([CapabilityIpld::from(&cap_1), CapabilityIpld::from(&cap_2)]); - assert_eq!(*ucan.attenuation(), expected_attenuations); + assert_eq!(ucan.attenuation(), &expected_attenuations); assert!(ucan.nonce().is_some()); } -#[tokio::test] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_builds_with_lifetime_in_seconds() { let identities = Identities::new().await; @@ -84,15 +88,13 @@ async fn it_builds_with_lifetime_in_seconds() { assert!(*ucan.expires_at() > (now() + 290)); } -#[tokio::test] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_prevents_duplicate_proofs() { let wnfs_semantics = WNFSSemantics {}; let parent_cap = wnfs_semantics - .parse( - "wnfs://alice.fission.name/public".into(), - "wnfs/SUPER_USER".into(), - ) + .parse("wnfs://alice.fission.name/public", "wnfs/super_user") .unwrap(); let identities = Identities::new().await; @@ -108,17 +110,11 @@ async fn it_prevents_duplicate_proofs() { .unwrap(); let attenuated_cap_1 = wnfs_semantics - .parse( - "wnfs://alice.fission.name/public/Apps".into(), - "wnfs/CREATE".into(), - ) + .parse("wnfs://alice.fission.name/public/Apps", "wnfs/create") .unwrap(); let attenuated_cap_2 = wnfs_semantics - .parse( - "wnfs://alice.fission.name/public/Domains".into(), - "wnfs/CREATE".into(), - ) + .parse("wnfs://alice.fission.name/public/Domains", "wnfs/create") .unwrap(); let next_ucan = UcanBuilder::default() @@ -134,5 +130,8 @@ async fn it_prevents_duplicate_proofs() { .await .unwrap(); - assert_eq!(*next_ucan.proofs(), Vec::from([ucan.encode().unwrap()])) + assert_eq!( + next_ucan.proofs(), + &vec![Cid::try_from(ucan).unwrap().to_string()] + ) } diff --git a/ucan/src/tests/chain.rs b/ucan/src/tests/chain.rs index 3c70b5e0..52de6e4b 100644 --- a/ucan/src/tests/chain.rs +++ b/ucan/src/tests/chain.rs @@ -1,8 +1,20 @@ -use crate::{builder::UcanBuilder, chain::ProofChain, crypto::did::DidParser}; +use crate::{ + builder::UcanBuilder, + chain::ProofChain, + crypto::did::DidParser, + store::{MemoryStore, UcanJwtStore}, +}; use super::fixtures::{Identities, SUPPORTED_KEYS}; -#[tokio::test] +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test_configure!(run_in_browser); + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] pub async fn it_decodes_deep_ucan_chains() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); @@ -30,10 +42,17 @@ pub async fn it_decodes_deep_ucan_chains() { .encode() .unwrap(); - let chain = ProofChain::try_from_token_string(delegated_token.as_str(), &mut did_parser) + let store = MemoryStore::default(); + store + .write_token(&leaf_ucan.encode().unwrap()) .await .unwrap(); + let chain = + ProofChain::try_from_token_string(delegated_token.as_str(), &mut did_parser, &store) + .await + .unwrap(); + assert_eq!(chain.ucan().audience(), &identities.mallory_did); assert_eq!( chain.proofs().get(0).unwrap().ucan().issuer(), @@ -41,7 +60,8 @@ pub async fn it_decodes_deep_ucan_chains() { ); } -#[tokio::test] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] pub async fn it_fails_with_incorrect_chaining() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); @@ -69,13 +89,20 @@ pub async fn it_fails_with_incorrect_chaining() { .encode() .unwrap(); + let store = MemoryStore::default(); + store + .write_token(&leaf_ucan.encode().unwrap()) + .await + .unwrap(); + let parse_token_result = - ProofChain::try_from_token_string(delegated_token.as_str(), &mut did_parser).await; + ProofChain::try_from_token_string(delegated_token.as_str(), &mut did_parser, &store).await; assert!(parse_token_result.is_err()); } -#[tokio::test] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] pub async fn it_can_handle_multiple_leaves() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); @@ -114,7 +141,17 @@ pub async fn it_can_handle_multiple_leaves() { .encode() .unwrap(); - ProofChain::try_from_token_string(&delegated_token, &mut did_parser) + let store = MemoryStore::default(); + store + .write_token(&leaf_ucan_1.encode().unwrap()) + .await + .unwrap(); + store + .write_token(&leaf_ucan_2.encode().unwrap()) + .await + .unwrap(); + + ProofChain::try_from_token_string(&delegated_token, &mut did_parser, &store) .await .unwrap(); } diff --git a/ucan/src/tests/crypto.rs b/ucan/src/tests/crypto.rs index fafe2790..03816e73 100644 --- a/ucan/src/tests/crypto.rs +++ b/ucan/src/tests/crypto.rs @@ -3,26 +3,14 @@ mod did_from_keypair { use crate::crypto::KeyMaterial; - #[cfg(feature = "rsa_support")] - #[test] - fn it_handles_rsa_keys() { - use crate::crypto::rsa::RsaKeyPair; - use rsa::pkcs8::FromPublicKey; - use rsa::RsaPublicKey; - - let pub_key = base64::decode("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnzyis1ZjfNB0bBgKFMSvvkTtwlvBsaJq7S5wA+kzeVOVpVWwkWdVha4s38XM/pa/yr47av7+z3VTmvDRyAHcaT92whREFpLv9cj5lTeJSibyr/Mrm/YtjCZVWgaOYIhwrXwKLqPr/11inWsAkfIytvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47XUu0t8Y0e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWbV6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9MwIDAQAB").unwrap(); - let keypair = RsaKeyPair( - RsaPublicKey::from_public_key_der(pub_key.as_slice()).unwrap(), - None, - ); - - let expected_did = "did:key:z4MXj1wBzi9jUstyNvmiK5WLRRL4rr9UvzPxhry1CudCLKWLyMbP1WoTwDfttBTpxDKf5hAJEjqNbeYx2EEvrJmSWHAu7TJRPTrE3QodbMfRvRNRDyYvaN1FSQus2ziS1rWXwAi5Gpc16bY3JwjyLCPJLfdRWHZhRXiay5FWEkfoSKy6aftnzAvqNkKBg2AxgzGMinR6d1WiH4w5mEXFtUeZkeo4uwtRTd8rD9BoVaHVkGwJkksDybE23CsBNXiNfbweFVRcwfTMhcQsTsYhUWDcSC6QE3zt9h4Rsrj7XRYdwYSK5bc1qFRsg5HULKBp2uZ1gcayiW2FqHFcMRjBieC4LnSMSD1AZB1WUncVRbPpVkn1UGhCU"; - let result_did = keypair.get_did(); + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; - assert_eq!(expected_did, result_did.as_str()); - } + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); - #[tokio::test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_handles_ed25519_keys() { let pub_key = base64::decode("Hv+AVRD2WUjUFOsSNbsmrp9fokuwrUnjBcr92f0kxw4=").unwrap(); let keypair = KeyPair::Ed25519(Ed25519KeyPair::from_public_key(&pub_key)); @@ -33,8 +21,9 @@ mod did_from_keypair { assert_eq!(expected_did, result_did.as_str()); } - #[tokio::test] + // #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] #[ignore = "Public key is allegedly invalid size"] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_handles_bls12381_keys() { let pub_key = base64::decode("Hv+AVRD2WUjUFOsSNbsmrp9fokuwrUnjBcr92f0kxw4=").unwrap(); let keypair = KeyPair::Bls12381G1G2(Bls12381KeyPairs::from_public_key(&pub_key)); diff --git a/ucan/src/tests/fixtures/capabilities/email.rs b/ucan/src/tests/fixtures/capabilities/email.rs index 2fa60acb..55f7878c 100644 --- a/ucan/src/tests/fixtures/capabilities/email.rs +++ b/ucan/src/tests/fixtures/capabilities/email.rs @@ -41,7 +41,7 @@ impl Action for EmailAction {} impl ToString for EmailAction { fn to_string(&self) -> String { match self { - EmailAction::Send => "email/SEND", + EmailAction::Send => "email/send", } .into() } @@ -52,7 +52,7 @@ impl TryFrom for EmailAction { fn try_from(value: String) -> Result { match value.as_str() { - "email/SEND" => Ok(EmailAction::Send), + "email/send" => Ok(EmailAction::Send), _ => Err(anyhow!("Unrecognized action: {}", value)), } } diff --git a/ucan/src/tests/fixtures/capabilities/wnfs.rs b/ucan/src/tests/fixtures/capabilities/wnfs.rs index 5acebcd1..b4fc98dd 100644 --- a/ucan/src/tests/fixtures/capabilities/wnfs.rs +++ b/ucan/src/tests/fixtures/capabilities/wnfs.rs @@ -19,11 +19,11 @@ impl TryFrom for WNFSCapLevel { fn try_from(value: String) -> Result { Ok(match value.as_str() { - "wnfs/CREATE" => WNFSCapLevel::Create, - "wnfs/REVISE" => WNFSCapLevel::Revise, - "wnfs/SOFT_DELETE" => WNFSCapLevel::SoftDelete, - "wnfs/OVERWRITE" => WNFSCapLevel::Overwrite, - "wnfs/SUPER_USER" => WNFSCapLevel::SuperUser, + "wnfs/create" => WNFSCapLevel::Create, + "wnfs/revise" => WNFSCapLevel::Revise, + "wnfs/soft_delete" => WNFSCapLevel::SoftDelete, + "wnfs/overwrite" => WNFSCapLevel::Overwrite, + "wnfs/super_user" => WNFSCapLevel::SuperUser, _ => return Err(anyhow!("No such WNFS capability level: {}", value)), }) } @@ -32,11 +32,11 @@ impl TryFrom for WNFSCapLevel { impl ToString for WNFSCapLevel { fn to_string(&self) -> String { match self { - WNFSCapLevel::Create => "wnfs/CREATE", - WNFSCapLevel::Revise => "wnfs/REVISE", - WNFSCapLevel::SoftDelete => "wnfs/SOFT_DELETE", - WNFSCapLevel::Overwrite => "wnfs/OVERWRITE", - WNFSCapLevel::SuperUser => "wnfs/SUPER_USER", + WNFSCapLevel::Create => "wnfs/create", + WNFSCapLevel::Revise => "wnfs/revise", + WNFSCapLevel::SoftDelete => "wnfs/soft_delete", + WNFSCapLevel::Overwrite => "wnfs/overwrite", + WNFSCapLevel::SuperUser => "wnfs/super_user", } .into() } diff --git a/ucan/src/tests/fixtures/crypto.rs b/ucan/src/tests/fixtures/crypto.rs index c4baa6d4..5161c8f3 100644 --- a/ucan/src/tests/fixtures/crypto.rs +++ b/ucan/src/tests/fixtures/crypto.rs @@ -1,11 +1,14 @@ -use crate::crypto::{did::KeyConstructorSlice, KeyMaterial}; +use crate::crypto::{ + did::{KeyConstructorSlice, ED25519_MAGIC_BYTES}, + KeyMaterial, +}; use anyhow::{anyhow, Result}; use async_trait::async_trait; use did_key::{CoreSign, Ed25519KeyPair, Fingerprint, KeyPair}; pub const SUPPORTED_KEYS: &KeyConstructorSlice = &[ // https://github.com/multiformats/multicodec/blob/e9ecf587558964715054a0afcc01f7ace220952c/table.csv#L94 - ([0xed, 0x01], bytes_to_ed25519_key), + (ED25519_MAGIC_BYTES, bytes_to_ed25519_key), ]; pub fn bytes_to_ed25519_key(bytes: Vec) -> Result> { @@ -14,8 +17,8 @@ pub fn bytes_to_ed25519_key(bytes: Vec) -> Result> { )))) } -#[cfg_attr(feature = "web", async_trait(?Send))] -#[cfg_attr(not(feature = "web"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl KeyMaterial for KeyPair { fn get_jwt_algorithm_name(&self) -> String { "EdDSA".into() diff --git a/ucan/src/tests/helpers.rs b/ucan/src/tests/helpers.rs new file mode 100644 index 00000000..7804dffb --- /dev/null +++ b/ucan/src/tests/helpers.rs @@ -0,0 +1,58 @@ +use anyhow::Result; +use did_key::KeyPair; +use serde::{de::DeserializeOwned, Serialize}; +use serde_ipld_dagcbor::{from_slice, to_vec}; + +use crate::{builder::UcanBuilder, capability::CapabilitySemantics}; + +use super::fixtures::{EmailSemantics, Identities}; + +pub fn dag_cbor_roundtrip(data: &T) -> Result +where + T: Serialize + DeserializeOwned, +{ + Ok(from_slice(&to_vec(data)?)?) +} + +pub async fn scaffold_ucan_builder(identities: &Identities) -> Result> { + let email_semantics = EmailSemantics {}; + let send_email_as_bob = email_semantics + .parse("mailto:bob@email.com".into(), "email/send".into()) + .unwrap(); + let send_email_as_alice = email_semantics + .parse("mailto:alice@email.com".into(), "email/send".into()) + .unwrap(); + + let leaf_ucan_alice = UcanBuilder::default() + .issued_by(&identities.alice_key) + .for_audience(identities.mallory_did.as_str()) + .with_expiration(1664232146010) + .claiming_capability(&send_email_as_alice) + .build() + .unwrap() + .sign() + .await + .unwrap(); + + let leaf_ucan_bob = UcanBuilder::default() + .issued_by(&identities.bob_key) + .for_audience(identities.mallory_did.as_str()) + .with_expiration(1664232146010) + .claiming_capability(&send_email_as_bob) + .build() + .unwrap() + .sign() + .await + .unwrap(); + + let builder = UcanBuilder::default() + .issued_by(&identities.mallory_key) + .for_audience(identities.alice_did.as_str()) + .with_expiration(1664232146010) + .witnessed_by(&leaf_ucan_alice) + .witnessed_by(&leaf_ucan_bob) + .claiming_capability(&send_email_as_alice) + .claiming_capability(&send_email_as_bob); + + Ok(builder) +} diff --git a/ucan/src/tests/mod.rs b/ucan/src/tests/mod.rs index 46f008d3..fb947692 100644 --- a/ucan/src/tests/mod.rs +++ b/ucan/src/tests/mod.rs @@ -2,5 +2,6 @@ mod attenuation; mod builder; mod chain; mod crypto; -mod fixtures; +pub mod fixtures; +pub mod helpers; mod ucan; diff --git a/ucan/src/tests/ucan.rs b/ucan/src/tests/ucan.rs index e6d33cbf..9e1f0e80 100644 --- a/ucan/src/tests/ucan.rs +++ b/ucan/src/tests/ucan.rs @@ -1,15 +1,20 @@ mod validate { - use did_key::KeyPair; - use crate::{ - builder::{Signable, UcanBuilder}, + builder::UcanBuilder, crypto::did::DidParser, tests::fixtures::{Identities, SUPPORTED_KEYS}, time::now, ucan::Ucan, }; - #[tokio::test] + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_round_trips_with_encode() { let identities = Identities::new().await; let mut did_parser = DidParser::new(SUPPORTED_KEYS); @@ -30,7 +35,8 @@ mod validate { decoded_ucan.validate(&mut did_parser).await.unwrap(); } - #[tokio::test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_identifies_a_ucan_that_is_not_active_yet() { let identities = Identities::new().await; @@ -48,7 +54,8 @@ mod validate { assert!(ucan.is_too_early()); } - #[tokio::test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_identifies_a_ucan_that_has_become_active() { let identities = Identities::new().await; let ucan = UcanBuilder::default() @@ -65,7 +72,8 @@ mod validate { assert!(!ucan.is_too_early()); } - #[tokio::test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] async fn it_can_be_serialized_as_json() { let identities = Identities::new().await; let ucan = UcanBuilder::default() @@ -87,7 +95,7 @@ mod validate { "header": { "alg": "EdDSA", "typ": "JWT", - "ucv": Signable::::UCAN_VERSION + "ucv": crate::ucan::UCAN_VERSION }, "payload": { "iss": ucan.issuer(), diff --git a/ucan/src/time.rs b/ucan/src/time.rs index 8089d4e8..19124e94 100644 --- a/ucan/src/time.rs +++ b/ucan/src/time.rs @@ -1,11 +1,3 @@ -#[cfg(target_arch = "wasm32")] -use instant::SystemTime; -#[cfg(not(target_arch = "wasm32"))] -use std::time::SystemTime; - pub fn now() -> u64 { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() + instant::now().floor() as u64 } diff --git a/ucan/src/ucan.rs b/ucan/src/ucan.rs index aaa1310e..1e885c77 100644 --- a/ucan/src/ucan.rs +++ b/ucan/src/ucan.rs @@ -1,10 +1,20 @@ +use std::convert::TryFrom; + use anyhow::{anyhow, Context, Result}; +use cid::multihash::{Code, MultihashDigest}; +use cid::Cid; +use libipld_core::codec::Codec; +use libipld_core::raw::RawCodec; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::capability::CapabilityIpld; use crate::crypto::did::DidParser; +use crate::serde::{Base64Encode, DagJson}; use crate::time::now; +pub const UCAN_VERSION: &'static str = "0.9.0-canary"; + #[derive(Clone, Serialize, Deserialize, Debug)] pub struct UcanHeader { pub alg: String, @@ -21,7 +31,7 @@ pub struct UcanPayload { pub nbf: Option, #[serde(skip_serializing_if = "Option::is_none")] pub nnc: Option, - pub att: Vec, + pub att: Vec, pub fct: Vec, pub prf: Vec, } @@ -64,7 +74,7 @@ impl Ucan { let header: UcanHeader = match parts.next() { Some(part) => match part { - Ok(decoded) => match serde_json::from_slice(decoded.as_slice()) { + Ok(decoded) => match UcanHeader::from_dag_json(&decoded) { Ok(header) => header, Err(error) => return Err(error).context("Could not parse UCAN header JSON"), }, @@ -75,7 +85,7 @@ impl Ucan { let payload: UcanPayload = match parts.next() { Some(part) => match part { - Ok(decoded) => match serde_json::from_slice(decoded.as_slice()) { + Ok(decoded) => match UcanPayload::from_dag_json(&decoded) { Ok(payload) => payload, Err(error) => return Err(error).context("Could not parse UCAN payload JSON"), }, @@ -122,17 +132,11 @@ impl Ucan { /// Produce a base64-encoded serialization of the UCAN suitable for /// transferring in a header field pub fn encode(&self) -> Result { - let header = base64::encode_config( - serde_json::to_string(&self.header)?.as_bytes(), - base64::URL_SAFE_NO_PAD, - ); - let payload = base64::encode_config( - serde_json::to_string(&self.payload)?.as_bytes(), - base64::URL_SAFE_NO_PAD, - ); + let header = self.header.jwt_base64_encode()?; + let payload = self.payload.jwt_base64_encode()?; let signature = base64::encode_config(self.signature.as_slice(), base64::URL_SAFE_NO_PAD); - Ok(format!("{}.{}.{}", header, payload, signature.as_str())) + Ok(format!("{}.{}.{}", header, payload, signature)) } /// Returns true if the UCAN has past its expiration date @@ -207,11 +211,35 @@ impl Ucan { &self.payload.nnc } - pub fn attenuation(&self) -> &Vec { + pub fn attenuation(&self) -> &Vec { &self.payload.att } pub fn facts(&self) -> &Vec { &self.payload.fct } + + pub fn version(&self) -> &str { + &self.header.ucv + } +} + +impl TryFrom<&Ucan> for Cid { + type Error = anyhow::Error; + + fn try_from(value: &Ucan) -> Result { + let codec = RawCodec::default(); + let token = value.encode()?; + let encoded = codec.encode(token.as_bytes())?; + + Ok(Cid::new_v1(codec.into(), Code::Sha2_256.digest(&encoded))) + } +} + +impl TryFrom for Cid { + type Error = anyhow::Error; + + fn try_from(value: Ucan) -> Result { + Cid::try_from(&value) + } }