diff --git a/Cargo.lock b/Cargo.lock index bdfeeb04..1918518d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -558,6 +558,18 @@ dependencies = [ "byte-tools", ] +[[package]] +name = "blst" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94087b935a822949d3291a9989ad2b2051ea141eda0fd4e478a75f6aa3e604b" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + [[package]] name = "bstr" version = "1.9.1" @@ -1213,6 +1225,37 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_builder" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.53", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +dependencies = [ + "derive_builder_core", + "syn 2.0.53", +] + [[package]] name = "deterministic-bloom" version = "0.1.0" @@ -1228,6 +1271,15 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "did_url" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d5f6334e473e3bb5650ab4ef3e4c910296b76968e62758e7c66157ff767c05" +dependencies = [ + "form_urlencoded", +] + [[package]] name = "diesel" version = "2.1.5" @@ -1402,6 +1454,7 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature", + "spki", ] [[package]] @@ -1447,6 +1500,7 @@ dependencies = [ "ff", "generic-array 0.14.7", "group", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -1577,6 +1631,7 @@ dependencies = [ "chrono", "clap", "config", + "data-encoding", "directories", "ed25519", "ed25519-dalek", @@ -1600,11 +1655,12 @@ dependencies = [ "serde_json", "task-local-extensions", "test-log", - "testresult", + "testresult 0.4.0", "tokio", "tokio-util", "tracing", "tracing-subscriber", + "ucan", "url 2.5.0", "walkdir", "websocket", @@ -1632,10 +1688,11 @@ dependencies = [ "serde_json", "signature", "test-log", - "testresult", + "testresult 0.4.0", "toml 0.8.12", "tracing", "tracing-subscriber", + "ucan", "url 2.5.0", "utoipa", "validator", @@ -1667,6 +1724,7 @@ dependencies = [ "console-subscriber", "const_format", "dashmap", + "data-encoding", "diesel", "diesel-async", "diesel_migrations", @@ -1713,7 +1771,7 @@ dependencies = [ "sysinfo", "task-local-extensions", "test-log", - "testresult", + "testresult 0.4.0", "thiserror", "time 0.3.34", "tokio", @@ -1725,6 +1783,7 @@ dependencies = [ "tracing-appender", "tracing-opentelemetry", "tracing-subscriber", + "ucan", "ulid", "url 2.5.0", "utoipa", @@ -1923,6 +1982,12 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "group" version = "0.13.0" @@ -3096,6 +3161,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom-unicode" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c03a4d71ab9740c752c165329e9df13a808093b146dfbef6170ac260771ffe" +dependencies = [ + "nom", +] + +[[package]] +name = "nonempty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "995defdca0a589acfdd1bd2e8e3b896b4d4f7675a31fd14c32611440c7f608e6" + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -3443,6 +3523,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "parking_lot" version = "0.11.2" @@ -4623,6 +4717,7 @@ dependencies = [ "base16ct", "der", "generic-array 0.14.7", + "pkcs8", "subtle", "zeroize", ] @@ -5174,6 +5269,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52e045f5cf9ad69772c1c9652f5567a75df88bbb5a1310a64e53cab140c5c459" +[[package]] +name = "testresult" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d72e255c0541f86589b0287139b70bd941a197ea4cea8fd8f87afe9c965a99e4" + [[package]] name = "thiserror" version = "1.0.58" @@ -5204,6 +5305,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" version = "0.1.45" @@ -5765,6 +5875,46 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucan" +version = "0.2.0" +source = "git+https://github.com/fission-codes/rs-ucan/?branch=v1.0-rc.1#a8d1b1d5b8931e4049d84fbf5e49c03a3f54894f" +dependencies = [ + "base64 0.21.7", + "blst", + "console_error_panic_hook", + "derive_builder", + "did_url", + "ecdsa", + "ed25519-dalek", + "enum-as-inner", + "getrandom", + "js-sys", + "k256", + "libipld-cbor", + "libipld-core", + "multibase", + "multihash 0.18.1", + "nom", + "nom-unicode", + "nonempty", + "p256", + "p384", + "p521", + "rsa", + "serde", + "serde-wasm-bindgen", + "serde_derive", + "signature", + "thiserror", + "unsigned-varint", + "url 2.5.0", + "wasm-bindgen", + "wasm-bindgen-derive", + "web-sys", + "web-time 0.2.4", +] + [[package]] name = "ucd-trie" version = "0.1.6" @@ -6098,6 +6248,28 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-derive" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab6c8bffb3f89584781211283fb57337d6902faab6eaee38f336977bdf177d" +dependencies = [ + "js-sys", + "wasm-bindgen", + "wasm-bindgen-derive-macro", +] + +[[package]] +name = "wasm-bindgen-derive-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87c28b31d27616bc69a891700ef0445d2cbaa0340fdacb145145256d5f5fcbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "wasm-bindgen-futures" version = "0.4.42" @@ -6570,7 +6742,7 @@ dependencies = [ "serde-byte-array", "serde_bytes", "serde_ipld_dagcbor", - "testresult", + "testresult 0.3.0", "thiserror", "wnfs-common", ] @@ -6610,7 +6782,7 @@ dependencies = [ "num_enum", "prost 0.12.3", "rand_core 0.6.4", - "testresult", + "testresult 0.3.0", "tokio", "wnfs-common", ] diff --git a/Cargo.toml b/Cargo.toml index ed4a1067..4909914f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,14 +21,17 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_ipld_dagcbor = "0.4" test-log = { version = "0.2", default-features = false, features = ["trace"] } -testresult = "0.3" +testresult = "0.4" tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "parking_lot", "registry"] } rs-ucan = { git = "https://github.com/fission-codes/rs-ucan/", branch = "matheus23/skip-rsa-len-check" } +# ucan = { path = "../rs-ucan" } +ucan = { git = "https://github.com/fission-codes/rs-ucan/", branch = "v1.0-rc.1" } url = { version = "2.3", features = ["serde"] } utoipa = { version = "3.1", features = ["uuid", "axum_extras"] } validator = { version = "0.16.0", features = ["derive"] } tokio = { version = "1.26", features = ["full", "parking_lot"] } tokio-util = "0.7.8" +data-encoding = "2.5.0" # Config for 'cargo dist' [workspace.metadata.dist] diff --git a/fission-core/Cargo.toml b/fission-core/Cargo.toml index dbb5dac0..0f248551 100644 --- a/fission-core/Cargo.toml +++ b/fission-core/Cargo.toml @@ -26,6 +26,7 @@ rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } rs-ucan = { workspace = true } +ucan = { workspace = true } url = { workspace = true } utoipa = { workspace = true } validator = { workspace = true } @@ -47,3 +48,7 @@ idna = "0.5.0" [dev-dependencies] test-log = { workspace = true } testresult = { workspace = true } + +[features] +default = [] +test_utils = [] diff --git a/fission-core/src/caps.rs b/fission-core/src/caps.rs new file mode 100644 index 00000000..d6089c88 --- /dev/null +++ b/fission-core/src/caps.rs @@ -0,0 +1,371 @@ +//! UCAN 1.0 capabilities used in the fission server (TODO: adjust docs once the upgrade is complete) + +use libipld::Ipld; +use serde::Serialize; +use ucan::ability::{ + arguments::Named, + command::{Command, ToCommand}, + parse::{ParseAbility, ParseAbilityError}, +}; + +/// All account-related abilities +#[derive(Debug, Clone, PartialEq)] +pub enum FissionAbility { + /// Account creation + AccountCreate(CmdAccountCreate), + /// Anything noncritical + AccountNoncritical(AccountNoncritical), + /// General account management + AccountManage(CmdAccountManage), + /// Account deletion + AccountDelete(CmdAccountDelete), + /// Account linking + AccountLink(CmdAccountLink), + /// Capability fetching + CapabilityFetch(CmdCapabilityFetch), +} + +/// All non-critical account abilities +#[derive(Debug, Clone, PartialEq)] +pub enum AccountNoncritical { + /// Account information + Info(CmdAccountInfo), +} + +/// The `/account/create` command +#[derive(Debug, Clone, PartialEq)] +pub struct CmdAccountCreate; + +impl Command for CmdAccountCreate { + const COMMAND: &'static str = "/account/create"; +} + +impl From for FissionAbility { + fn from(cmd: CmdAccountCreate) -> Self { + FissionAbility::AccountCreate(cmd) + } +} + +/// The `/account/noncritical/info` command +#[derive(Debug, Clone, PartialEq)] +pub struct CmdAccountInfo; + +impl Command for CmdAccountInfo { + const COMMAND: &'static str = "/account/noncritical/info"; +} + +impl From for FissionAbility { + fn from(cmd: CmdAccountInfo) -> Self { + FissionAbility::AccountNoncritical(AccountNoncritical::Info(cmd)) + } +} + +/// The `/account/manage` command +#[derive(Debug, Clone, PartialEq)] +pub struct CmdAccountManage; + +impl Command for CmdAccountManage { + const COMMAND: &'static str = "/account/manage"; +} + +impl From for FissionAbility { + fn from(cmd: CmdAccountManage) -> Self { + FissionAbility::AccountManage(cmd) + } +} + +/// The `/account/delete` command +#[derive(Debug, Clone, PartialEq)] +pub struct CmdAccountDelete; + +impl Command for CmdAccountDelete { + const COMMAND: &'static str = "/account/delete"; +} + +impl From for FissionAbility { + fn from(cmd: CmdAccountDelete) -> Self { + FissionAbility::AccountDelete(cmd) + } +} + +/// The `/account/link` command +#[derive(Debug, Clone, PartialEq)] +pub struct CmdAccountLink; + +impl Command for CmdAccountLink { + const COMMAND: &'static str = "/account/link"; +} + +impl From for FissionAbility { + fn from(cmd: CmdAccountLink) -> Self { + FissionAbility::AccountLink(cmd) + } +} + +/// The `/capability/fetch` command +#[derive(Debug, Clone, PartialEq)] +pub struct CmdCapabilityFetch; + +impl Command for CmdCapabilityFetch { + const COMMAND: &'static str = "/capability/fetch"; +} + +impl From for FissionAbility { + fn from(cmd: CmdCapabilityFetch) -> Self { + FissionAbility::CapabilityFetch(cmd) + } +} + +// +// Ability implementations +// + +impl ToCommand for AccountNoncritical { + fn to_command(&self) -> String { + match self { + Self::Info(info) => info.to_command(), + } + } +} + +impl ToCommand for FissionAbility { + fn to_command(&self) -> String { + match self { + Self::AccountCreate(create) => create.to_command(), + Self::AccountNoncritical(noncritical) => noncritical.to_command(), + Self::AccountManage(manage) => manage.to_command(), + Self::AccountDelete(delete) => delete.to_command(), + Self::AccountLink(link) => link.to_command(), + Self::CapabilityFetch(fetch) => fetch.to_command(), + } + } +} + +impl ParseAbility for FissionAbility { + type ArgsErr = (); + + fn try_parse(cmd: &str, _: Named) -> Result> { + match cmd { + CmdAccountCreate::COMMAND => Ok(FissionAbility::AccountCreate(CmdAccountCreate)), + CmdAccountInfo::COMMAND => Ok(FissionAbility::AccountNoncritical( + AccountNoncritical::Info(CmdAccountInfo), + )), + CmdAccountLink::COMMAND => Ok(FissionAbility::AccountLink(CmdAccountLink)), + CmdAccountDelete::COMMAND => Ok(FissionAbility::AccountDelete(CmdAccountDelete)), + CmdAccountManage::COMMAND => Ok(FissionAbility::AccountManage(CmdAccountManage)), + CmdCapabilityFetch::COMMAND => Ok(FissionAbility::CapabilityFetch(CmdCapabilityFetch)), + _ => Err(ParseAbilityError::UnknownCommand(cmd.to_string())), + } + } +} + +impl From for Named { + fn from(_: FissionAbility) -> Self { + // No fields yet + Named::new() + } +} + +impl Serialize for FissionAbility { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let ipld: Named = self.clone().into(); + ipld.serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use super::{CmdAccountCreate, CmdAccountManage}; + use crate::{ + caps::{CmdAccountInfo, FissionAbility}, + test_utils::{delegate, setup_agents, varsig_header}, + }; + use assert_matches::assert_matches; + use std::{collections::BTreeMap, convert::Infallible, time::SystemTime}; + use testresult::TestResult; + use ucan::{ + delegation::{self}, + invocation::{ + self, + agent::{InvokeError, Recipient}, + Agent, + }, + Invocation, + }; + + fn simulate_create_account<'a>( + delegations: &'a delegation::store::MemoryStore, + client: &Agent< + invocation::store::MemoryStore, + &delegation::store::MemoryStore, + FissionAbility, + >, + server: &Agent< + invocation::store::MemoryStore, + &delegation::store::MemoryStore, + FissionAbility, + >, + server_del: &delegation::Agent<&'_ delegation::store::MemoryStore>, + subject: &ucan::did::preset::Verifier, + ) -> TestResult<( + Agent< + invocation::store::MemoryStore, + &'a delegation::store::MemoryStore, + FissionAbility, + >, + delegation::Agent<&'a delegation::store::MemoryStore>, + )> { + let create_invocation = client.invoke( + Some(server.did.clone()), + subject.clone(), + FissionAbility::AccountCreate(CmdAccountCreate), + BTreeMap::new(), + None, + None, + None, + SystemTime::now(), + varsig_header(), + )?; + + // The sever receives the invocation and validates it: + assert_matches!(server.receive(create_invocation)?, Recipient::You(_)); + + // Then it creates a new account DID & delegates it to itself & further delegates to the client. + let (account, account_del) = setup_agents::(delegations); + + // Create a delegation from account -> server + // TODO: Should we make this a powerline eventually? + delegate( + delegations, + &account_del, + &server.did, + Some(&account.did), + "/", + )?; + + // Delegate from server to the subject that wanted to create the account: + // TODO: also powerline? + delegate(delegations, &server_del, subject, Some(&account.did), "/")?; + + Ok((account, account_del)) + } + + fn simulate_account_invocation( + account: &Agent< + invocation::store::MemoryStore, + &delegation::store::MemoryStore, + FissionAbility, + >, + client: &Agent< + invocation::store::MemoryStore, + &delegation::store::MemoryStore, + FissionAbility, + >, + server: &Agent< + invocation::store::MemoryStore, + &delegation::store::MemoryStore, + FissionAbility, + >, + cmd: FissionAbility, + ) -> Result, InvokeError> { + client.invoke( + Some(server.did.clone()), + account.did.clone(), + cmd, + BTreeMap::new(), + None, + None, + None, + SystemTime::now(), + varsig_header(), + ) + } + + #[test_log::test] + fn simulate_account_create_flow() -> TestResult { + // delegations are simulated to be "public". + // Any stored delegations are readable by everyone. + let delegations = &delegation::store::MemoryStore::default(); + + let (server, server_del) = setup_agents::(delegations); + let (device, _) = setup_agents::(delegations); + + let (account, _) = + simulate_create_account(delegations, &device, &server, &server_del, &device.did)?; + + // Now, the device should be able to do stuff! + + let info_invocation = + simulate_account_invocation(&account, &device, &server, CmdAccountInfo.into())?; + assert_matches!(server.receive(info_invocation), Ok(Recipient::You(_))); + + Ok(()) + } + + #[test_log::test] + fn simulate_account_delegation_with_restrictions() -> TestResult { + let delegations = &delegation::store::MemoryStore::default(); + + let (server, server_del) = setup_agents::(delegations); + let (device, device_del) = setup_agents::(delegations); + + let (account, _) = + simulate_create_account(delegations, &device, &server, &server_del, &device.did)?; + + // The main device now delegates to a second device which should + // only have noncritical access + let (second_device, _) = setup_agents::(delegations); + delegate( + delegations, + &device_del, // from + &second_device.did, // to + Some(&account.did), // subject + "/account/noncritical", // command + )?; + + let second_info_invocation = + simulate_account_invocation(&account, &second_device, &server, CmdAccountInfo.into())?; + + // info should be a noncritical delegation and thus work + assert_matches!( + server.receive(second_info_invocation), + Ok(Recipient::You(_)) + ); + + // Can't delegate /account/manage with only /account/noncritical ability + assert_matches!( + simulate_account_invocation(&account, &second_device, &server, CmdAccountManage.into()), + Err(_) + ); + + Ok(()) + } + + #[test_log::test] + fn simulate_account_create_with_passkey() -> TestResult { + let delegations = &delegation::store::MemoryStore::default(); + + let (server, server_del) = setup_agents::(delegations); + let (passkey, passkey_del) = setup_agents::(delegations); + let (device, _) = setup_agents::(delegations); + + // We create a powerline from passkey to the device - it's allowed to act on its behalf: + delegate(delegations, &passkey_del, &device.did, None, "/")?; + + // We create an account on behalf of the passkey! + let (account, _) = + simulate_create_account(delegations, &device, &server, &server_del, &passkey.did)?; + + // We can now still act as the passkey, even though the subject changed from passkey.did to account.did: + let info_invocation = + simulate_account_invocation(&account, &device, &server, CmdAccountInfo.into())?; + + // info should be a noncritical delegation and thus work + assert_matches!(server.receive(info_invocation), Ok(Recipient::You(_))); + + Ok(()) + } +} diff --git a/fission-core/src/common.rs b/fission-core/src/common.rs index a91878a4..ca9fdf77 100644 --- a/fission-core/src/common.rs +++ b/fission-core/src/common.rs @@ -1,7 +1,6 @@ //! Request and response data types that are common and useful between clients of and the fission server use crate::username::{Handle, Username}; -use rs_ucan::ucan::Ucan; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use utoipa::ToSchema; @@ -59,7 +58,7 @@ pub struct MemberNumberResponse { pub struct UcansResponse { /// Ucans indexed by their canonical CID (base32, sha-256 and raw codec) #[schema(value_type = HashMap)] - pub ucans: BTreeMap, + pub ucans: BTreeMap, /// The subset of canonical CIDs of UCANs that are revoked #[schema(value_type = Vec)] pub revoked: BTreeSet, @@ -67,7 +66,7 @@ pub struct UcansResponse { impl UcansResponse { /// List unrevoked ucans - pub fn into_unrevoked(self) -> impl Iterator { + pub fn into_unrevoked(self) -> impl Iterator { let Self { ucans, revoked } = self; ucans.into_iter().filter_map(move |(canonical_cid, ucan)| { if revoked.contains(&canonical_cid) { @@ -87,7 +86,7 @@ pub struct Account { pub did: String, /// Username associated with the account - #[schema(value_type = Option, example = "boris.e9c7.name")] + #[schema(value_type = Option, example = "boris.computer.name")] pub username: Option, /// Email address associated with the account diff --git a/fission-core/src/ed_did_key.rs b/fission-core/src/ed_did_key.rs index 28337413..f9167ee1 100644 --- a/fission-core/src/ed_did_key.rs +++ b/fission-core/src/ed_did_key.rs @@ -5,11 +5,12 @@ use ed25519::{ pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey}, Signature, }; -use ed25519_dalek::SigningKey; +use ed25519_dalek::{SigningKey, VerifyingKey}; use rand::thread_rng; use rs_ucan::crypto::SignerDid; use signature::Signer; use std::fmt::Display; +use ucan::did::preset::{self, Verifier}; use zeroize::ZeroizeOnDrop; /// An Ed25519 EdDSA `did:key:zM...` with the signing key stored in-memory and zeroized on drop @@ -58,6 +59,19 @@ impl EdDidKey { pub fn did_as_str(&self) -> &str { &self.did_key_string } + + /// Returns this DID's public key + pub fn verifying_key(&self) -> VerifyingKey { + self.signing_key.verifying_key() + } + + /// TODO remove once we've moved fully to rs-ucan 1.0 + pub fn to_ucan_interop(&self) -> (preset::Signer, Verifier) { + ( + preset::Signer::Key(ucan::did::key::Signer::EdDsa(self.signing_key.clone())), + preset::Verifier::Key(ucan::did::key::Verifier::EdDsa(self.verifying_key())), + ) + } } impl Signer for EdDidKey { diff --git a/fission-core/src/lib.rs b/fission-core/src/lib.rs index 7e8ca4b5..1d6530d3 100644 --- a/fission-core/src/lib.rs +++ b/fission-core/src/lib.rs @@ -2,13 +2,16 @@ #![warn(missing_debug_implementations, missing_docs, rust_2018_idioms)] #![deny(unreachable_pub)] -//! fission-core +//! fission-core, shared code between the server & clients pub mod authority; pub mod capabilities; +pub mod caps; pub mod common; pub mod dns; pub mod ed_did_key; pub mod revocation; pub mod serde_value_source; +#[cfg(any(test, feature = "test_utils"))] +pub mod test_utils; pub mod username; diff --git a/fission-core/src/test_utils.rs b/fission-core/src/test_utils.rs new file mode 100644 index 00000000..dfaa544a --- /dev/null +++ b/fission-core/src/test_utils.rs @@ -0,0 +1,94 @@ +//! Utilities for testing (TODO this should be a temporary module. Let's figure out simpler APIs, perhaps builders, etc. for rs-ucan) +use anyhow::Result; +use libipld::Ipld; +use rand::rngs::OsRng; +use std::{collections::BTreeMap, time::SystemTime}; +use ucan::{ + ability::{arguments::Named, command::ToCommand, parse::ParseAbility}, + crypto::varsig::{self, header::EdDsaHeader}, + delegation::{self, store::Store}, + did::preset::{Signer, Verifier}, + invocation::{self, Agent}, + time::Timestamp, + Delegation, +}; + +/// The dag-cbor varsig header +pub fn varsig_header() -> varsig::header::Preset { + varsig::header::Preset::EdDsa(EdDsaHeader { + codec: varsig::encoding::Preset::DagCbor, + }) +} + +/// Setup a simple agent that can delegate and invoke +pub fn setup_agents( + delegation_store: &delegation::store::MemoryStore, +) -> ( + Agent, &'_ delegation::store::MemoryStore, T>, + delegation::Agent<&'_ delegation::store::MemoryStore>, +) +where + Named: From, +{ + let sk = ed25519_dalek::SigningKey::generate(&mut OsRng); + let did = Verifier::Key(ucan::did::key::Verifier::EdDsa(sk.verifying_key())); + let signer = Signer::Key(ucan::did::key::Signer::EdDsa(sk)); + let inv_store = invocation::store::MemoryStore::::default(); + let invocation_agent = Agent::new(did.clone(), signer.clone(), inv_store, delegation_store); + let delegation_agent = delegation::Agent::new(did, signer, delegation_store); + (invocation_agent, delegation_agent) +} + +/// Setup a simple agent that can only invoke +pub fn setup_invocation_agent( +) -> Agent, delegation::store::MemoryStore, T> +where + Named: From, +{ + let sk = ed25519_dalek::SigningKey::generate(&mut OsRng); + let did = Verifier::Key(ucan::did::key::Verifier::EdDsa(sk.verifying_key())); + let signer = Signer::Key(ucan::did::key::Signer::EdDsa(sk)); + let inv_store = invocation::store::MemoryStore::::default(); + Agent::new( + did.clone(), + signer.clone(), + inv_store, + delegation::store::MemoryStore::default(), + ) +} + +/// Create a delegation from a delegation agent +pub fn create_delegation( + from: &delegation::Agent<&delegation::store::MemoryStore>, + to: &ucan::did::preset::Verifier, + subject: Option<&ucan::did::preset::Verifier>, + cmd: &str, +) -> Result { + let ucan = from.delegate( + to.clone(), + subject, + None, + cmd.to_string(), + Vec::new(), + BTreeMap::new(), + None, + None, + SystemTime::now(), + varsig_header(), + )?; + + Ok(ucan) +} + +/// Create and store a delegation from given agent in given delegation store +pub fn delegate( + delegations: &delegation::store::MemoryStore, + from: &delegation::Agent<&delegation::store::MemoryStore>, + to: &ucan::did::preset::Verifier, + subject: Option<&ucan::did::preset::Verifier>, + cmd: &str, +) -> Result<()> { + let ucan = create_delegation(from, to, subject, cmd)?; + delegations.insert(ucan)?; + Ok(()) +} diff --git a/fission-server/Cargo.toml b/fission-server/Cargo.toml index 037f2400..21a517cf 100644 --- a/fission-server/Cargo.toml +++ b/fission-server/Cargo.toml @@ -106,6 +106,7 @@ tracing-opentelemetry = "0.20" tracing-subscriber = { workspace = true } hickory-server = { version = "0.24", features = ["dns-over-rustls"] } rs-ucan = { workspace = true } +ucan = { workspace = true } ulid = { version = "1.0", features = ["serde"] } url = { workspace = true } utoipa = { workspace = true } @@ -118,11 +119,13 @@ wnfs = { workspace = true } blake3 = "1.4.1" uuid = "1.4.1" erased-serde = "0.3.31" +data-encoding = { workspace = true } [dev-dependencies] assert-json-diff = "2.0" assert_matches = "1.5.0" blake3 = "1.4.1" +fission-core = { path = "../fission-core", version = "0.1", features = ["test_utils"] } test-log = { workspace = true } testresult = { workspace = true } diff --git a/fission-server/migrations/2023-10-23-163251_ucans_indexer/down.sql b/fission-server/migrations/2023-10-23-163251_ucans_indexer/down.sql index a62e7be2..ccf41111 100644 --- a/fission-server/migrations/2023-10-23-163251_ucans_indexer/down.sql +++ b/fission-server/migrations/2023-10-23-163251_ucans_indexer/down.sql @@ -1,8 +1,5 @@ -DROP INDEX idx_capabilities_resource; -DROP INDEX idx_capabilities_ability; -DROP TABLE capabilities; - DROP INDEX idx_ucans_cid; +DROP INDEX idx_ucans_subject; DROP INDEX idx_ucans_issuer; DROP INDEX idx_ucans_audience; DROP TABLE ucans; diff --git a/fission-server/migrations/2023-10-23-163251_ucans_indexer/up.sql b/fission-server/migrations/2023-10-23-163251_ucans_indexer/up.sql index 0a4c6ed6..ecf68fae 100644 --- a/fission-server/migrations/2023-10-23-163251_ucans_indexer/up.sql +++ b/fission-server/migrations/2023-10-23-163251_ucans_indexer/up.sql @@ -1,10 +1,17 @@ CREATE TABLE ucans ( id SERIAL PRIMARY KEY, cid TEXT NOT NULL UNIQUE, - encoded TEXT NOT NULL, + encoded BYTEA NOT NULL, + subject TEXT, issuer TEXT NOT NULL, audience TEXT NOT NULL, + via TEXT, + + command TEXT NOT NULL, + policy JSONB NOT NULL, + metadata JSONB NOT NULL, + nonce BYTEA NOT NULL, not_before TIMESTAMP, expires_at TIMESTAMP @@ -12,24 +19,8 @@ CREATE TABLE ucans ( CREATE UNIQUE INDEX idx_ucans_cid ON ucans (cid); +CREATE INDEX idx_ucans_subject ON ucans (subject); + CREATE INDEX idx_ucans_issuer ON ucans (issuer); CREATE INDEX idx_ucans_audience ON ucans (audience); - - -CREATE TABLE capabilities ( - id SERIAL PRIMARY KEY, - - resource TEXT NOT NULL, - ability TEXT NOT NULL, - - caveats JSONB NOT NULL, - - ucan_id INTEGER NOT NULL - REFERENCES ucans(id) - ON DELETE CASCADE -); - -CREATE INDEX idx_capabilities_resource ON capabilities (resource); - -CREATE INDEX idx_capabilities_ability ON capabilities (ability); diff --git a/fission-server/src/authority.rs b/fission-server/src/authority.rs index 4266b69f..db35ce4c 100644 --- a/fission-server/src/authority.rs +++ b/fission-server/src/authority.rs @@ -2,28 +2,17 @@ use crate::{ app_state::AppState, - db, - db::Conn, error::{AppError, AppResult}, - models::revocation::find_revoked_subset, setups::ServerSetup, }; -use anyhow::{bail, Result}; -use fission_core::{ - capabilities::did::Did, - revocation::{canonical_cid, Revocation}, -}; +use fission_core::{capabilities::did::Did, caps::FissionAbility, revocation::Revocation}; use http::StatusCode; -use libipld::{raw::RawCodec, Ipld}; -use rs_ucan::{ - did_verifier::DidVerifierMap, - semantics::ability::Ability, - store::{InMemoryStore, Store}, - ucan::Ucan, - DefaultFact, +use libipld::Ipld; +use ucan::{ + ability::{arguments::Named, command::ToCommand, parse::ParseAbility}, + delegation::{self, store::Store}, + invocation, Delegation, Invocation, }; -use serde::de::DeserializeOwned; -use std::collections::BTreeSet; //-------// // TYPES // @@ -31,34 +20,21 @@ use std::collections::BTreeSet; #[derive(Debug, Clone)] /// Represents the authority of an incoming request -pub struct Authority { +pub struct Authority { /// https://github.com/ucan-wg/ucan-as-bearer-token#21-entry-point - pub ucan: Ucan, - /// proofs from `ucan` header - pub proofs: Vec, + pub invocation: Invocation, + /// proofs from `ucans` header + pub delegations: Vec, } //-----------------// // IMPLEMENTATIONS // //-----------------// -impl Authority { - /// Validate the authority audience - pub fn validate_audience(&self, intended_audience: &str) -> Result<()> { - let audience = self.ucan.audience(); - if audience != intended_audience { - tracing::error!( - audience = %audience, - expected = %intended_audience, - token = ?self.ucan.encode(), - "Auth token audience doesn't match server DID" - ); - bail!("Auth token audience doesn't match server DID. Expected {intended_audience}, but got {audience}.") - } - - Ok(()) - } - +impl Authority +where + Named: From, +{ /// Validate an attempt to create a revocation. /// /// The revoked UCAN needs to be specified using the main `authorization` header @@ -68,125 +44,52 @@ impl Authority { /// /// The UCAN from the `authorization`'s canonical CID needs to match the revocation's /// CID. - pub fn validate_revocation(&self, revocation: &Revocation) -> AppResult<()> { - let mut store = InMemoryStore::::default(); - - for proof in &self.proofs { - store.write(Ipld::Bytes(proof.encode()?.as_bytes().to_vec()), None)?; - } - - revocation - .verify_valid(&self.ucan, &DidVerifierMap::default(), &store) - .map_err(|e| AppError::new(StatusCode::FORBIDDEN, Some(e))) - } - - /// find the set of UCAN canonical CIDs that are revoked and relevant to this request - pub async fn get_relevant_revocations(&self, conn: &mut Conn<'_>) -> Result> { - let mut canonical_cids = BTreeSet::from([canonical_cid(&self.ucan)?]); - - for proof in &self.proofs { - // This is duplicating work in the usual case, but also it's not *too bad*. - canonical_cids.insert(canonical_cid(proof)?); - } - - find_revoked_subset(canonical_cids, conn).await + pub fn validate_revocation(&self, _revocation: &Revocation) -> AppResult<()> { + // TODO + Ok(()) } /// Validates whether or not the UCAN and proofs have the capability to /// perform the given action, with the given issuer as the root of that /// authority. pub async fn get_capability( - &self, + self, app_state: &AppState, - ability: impl Ability, + ability: A, ) -> AppResult { - self.validate_audience(app_state.server_keypair.did_as_str())?; + let Self { + invocation, + delegations, + } = self; - let revocations = self - .get_relevant_revocations(&mut db::connect(&app_state.db_pool).await?) - .await?; - - if revocations.contains(&canonical_cid(&self.ucan)?) { + let subject = invocation.subject().to_string(); + if invocation.ability() != &ability { return Err(AppError::new( StatusCode::FORBIDDEN, - Some("Invocation UCAN was revoked"), + Some("Incorrect ability in invocation"), )); } - let current_time = rs_ucan::time::now(); - - let mut store = InMemoryStore::::default(); - - for proof in &self.proofs { - // TODO(matheus23): rs-ucan should probably have support for revoked CIDs - if revocations.contains(&canonical_cid(proof)?) { - continue; // This CID was revoked. - } - // TODO(matheus23): we assume SHA2-256 atm. The spec says to hash with all CID formats used in proofs >.< - store.write(Ipld::Bytes(proof.encode()?.as_bytes().to_vec()), None)?; + let delegation_store = delegation::store::MemoryStore::new(); + for delegation in delegations { + delegation_store.insert(delegation)?; } - - let caps = self.ucan.capabilities().collect::>(); - let [cap] = caps[..] else { - if caps.is_empty() { - tracing::error!("No capabilities provided."); - return Err(AppError::new( - StatusCode::BAD_REQUEST, - Some("Invocation UCAN without capabilities provided."), - )); - } - tracing::error!(caps = ?caps, "Invocation UCAN with multiple capabilities is ambiguous."); - return Err(AppError::new( - StatusCode::BAD_REQUEST, - Some("Invocation UCAN with multiple capabilities is ambiguous."), - )); - }; - - if !cap.ability().is_valid_attenuation(&ability) { - return Err(AppError::new( + let (signer, did) = app_state.server_keypair.to_ucan_interop(); + let agent = invocation::Agent::<_, _, A>::new( + did.clone(), + signer, + invocation::store::MemoryStore::default(), + delegation_store, + ); + + let recipient = agent.receive(invocation)?; + match recipient { + invocation::agent::Recipient::You(_) => Ok(Did(subject)), + _ => Err(AppError::new( StatusCode::FORBIDDEN, - Some(format!( - "Invalid authorization. Expected ability {ability}, but got {}", - cap.ability() - )), - )); + Some("Authorization UCAN has incorrect audience."), + )), } - - let Some(Did(did)) = cap.resource().downcast_ref() else { - return Err(AppError::new( - StatusCode::BAD_REQUEST, - Some(format!( - "Invalid authorization. Expected resource to be DID, but got {}", - cap.resource() - )), - )); - }; - - let ability_str = ability.to_string(); - - let caps = self - .ucan - .capabilities_for( - did, - Did(did.clone()), - ability, - current_time, - &DidVerifierMap::default(), - &store, - ) - .map_err(|e| AppError::new(StatusCode::FORBIDDEN, Some(e)))?; - - // TODO(matheus23): Not yet handling caveats. - caps.first() - .ok_or_else(|| { - AppError::new(StatusCode::FORBIDDEN, Some(format!( - "Invalid authorization. Couldn't find proof for {ability_str} as issued from {did}" - ))) - })? - .resource() - .downcast_ref() - .cloned() - .ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, Some("Invalid authorization. Something went wrong. Capability resource is not a DID."))) } } @@ -196,47 +99,87 @@ impl Authority { #[cfg(test)] mod tests { - use super::*; - - use fission_core::ed_did_key::EdDidKey; - use rs_ucan::builder::UcanBuilder; + use super::Authority; + use crate::test_utils::test_context::TestContext; + use assert_matches::assert_matches; + use fission_core::{ + caps::{CmdAccountCreate, CmdCapabilityFetch, FissionAbility}, + test_utils::{setup_invocation_agent, varsig_header}, + }; + use std::{collections::BTreeMap, time::SystemTime}; use testresult::TestResult; #[test_log::test(tokio::test)] - async fn validation_test() -> TestResult { - let issuer = &EdDidKey::generate(); - let ucan: Ucan = UcanBuilder::default() - .for_audience("did:web:runfission.com") - .with_lifetime(100) - .sign(issuer)?; - + async fn smoke_test() -> TestResult { + let ctx = &TestContext::new().await?; + let (_, server_did) = ctx.server_did().to_ucan_interop(); + // create an invocation agent + let agent = setup_invocation_agent::(); + + // create an invocation + let invocation = agent.invoke( + Some(server_did), + agent.did.clone(), + FissionAbility::CapabilityFetch(CmdCapabilityFetch), + BTreeMap::new(), + None, + None, + None, + SystemTime::now(), + varsig_header(), + )?; + + // create an Authority with only that invocation let authority = Authority { - ucan, - proofs: vec![], + invocation, + delegations: Vec::new(), }; - assert!(authority - .validate_audience("did:web:runfission.com") - .is_ok()); + // verify that get_capability works + let result = authority + .get_capability( + ctx.app_state(), + FissionAbility::CapabilityFetch(CmdCapabilityFetch), + ) + .await; + + assert_matches!(result, Ok(_)); Ok(()) } #[test_log::test(tokio::test)] - #[ignore] - async fn invalid_ucan_test() { - panic!("pending") - } + async fn test_get_mismatching_capability_fails() -> TestResult { + let ctx = &TestContext::new().await?; + let (_, server_did) = ctx.server_did().to_ucan_interop(); + let agent = setup_invocation_agent::(); + let invocation = agent.invoke( + Some(server_did), + agent.did.clone(), + FissionAbility::CapabilityFetch(CmdCapabilityFetch), + BTreeMap::new(), + None, + None, + None, + SystemTime::now(), + varsig_header(), + )?; - #[test_log::test(tokio::test)] - #[ignore] - async fn incomplete_proofs_test() { - panic!("pending") - } + let authority = Authority { + invocation, + delegations: Vec::new(), + }; - #[test_log::test(tokio::test)] - #[ignore] - async fn invalid_delegation_test() { - panic!("pending") + // we're using a different capability here + let result = authority + .get_capability( + ctx.app_state(), + FissionAbility::AccountCreate(CmdAccountCreate), + ) + .await; + + assert_matches!(result, Err(_)); + + Ok(()) } } diff --git a/fission-server/src/db/schema.rs b/fission-server/src/db/schema.rs index 810b048e..b03f46c5 100644 --- a/fission-server/src/db/schema.rs +++ b/fission-server/src/db/schema.rs @@ -24,16 +24,6 @@ diesel::table! { } } -diesel::table! { - capabilities (id) { - id -> Int4, - resource -> Text, - ability -> Text, - caveats -> Jsonb, - ucan_id -> Int4, - } -} - diesel::table! { email_verifications (id) { id -> Int4, @@ -57,9 +47,15 @@ diesel::table! { ucans (id) { id -> Int4, cid -> Text, - encoded -> Text, + encoded -> Bytea, + subject -> Nullable, issuer -> Text, audience -> Text, + via -> Nullable, + command -> Text, + policy -> Jsonb, + metadata -> Jsonb, + nonce -> Bytea, not_before -> Nullable, expires_at -> Nullable, } @@ -77,12 +73,10 @@ diesel::table! { diesel::joinable!(accounts -> volumes (volume_id)); diesel::joinable!(apps -> accounts (owner_id)); diesel::joinable!(apps -> volumes (volume_id)); -diesel::joinable!(capabilities -> ucans (ucan_id)); diesel::allow_tables_to_appear_in_same_query!( accounts, apps, - capabilities, email_verifications, revocations, ucans, diff --git a/fission-server/src/error.rs b/fission-server/src/error.rs index b7bdc04f..e01c114a 100644 --- a/fission-server/src/error.rs +++ b/fission-server/src/error.rs @@ -1,5 +1,7 @@ //! Generic result/error resprentation(s). +use std::{convert::Infallible, fmt::Debug}; + use axum::{ extract::rejection::{ExtensionRejection, QueryRejection}, http::StatusCode, @@ -8,7 +10,13 @@ use axum::{ }; use http::header::ToStrError; +use libipld::codec::Codec; use serde::{Deserialize, Serialize}; +use ucan::{ + crypto::varsig, + did::Did, + invocation::{agent::ReceiveError, store::Store}, +}; use ulid::Ulid; use utoipa::ToSchema; use validator::ValidationErrors; @@ -191,6 +199,51 @@ impl From for AppError { } } +impl From for AppError { + fn from(the_impossible: Infallible) -> Self { + match the_impossible {} + } +} + +impl> From> for AppError { + fn from(err: ucan::delegation::store::DelegationInsertError) -> Self { + match err { + ucan::delegation::store::DelegationInsertError::CannotMakeCid(e) => { + AppError::new(StatusCode::INTERNAL_SERVER_ERROR, Some(e)) + } + ucan::delegation::store::DelegationInsertError::StoreError(e) => e.into(), + } + } +} + +impl, V: varsig::Header, C: Codec> + From> for AppError +{ + fn from(err: ReceiveError) -> Self { + match err { + ReceiveError::DelegationNotFound(_) => { + AppError::new(StatusCode::FORBIDDEN, Some("Delegation not found")) + } + ReceiveError::EncodingError(_) => { + AppError::new(StatusCode::BAD_REQUEST, Some("UCANs invalidly encoded")) + } + ReceiveError::SigVerifyError(_) => AppError::new( + StatusCode::BAD_REQUEST, + Some("UCAN signature couldn't be verified"), + ), + ReceiveError::InvocationStoreError(_) => AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + Some("Something went wrong in the invocation store"), + ), + ReceiveError::DelegationStoreError(_) => AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + Some("Something went wrong in the delegation store"), + ), + ReceiveError::ValidationError(e) => AppError::new(StatusCode::FORBIDDEN, Some(e)), + } + } +} + impl From for AppError { fn from(err: car_mirror::Error) -> Self { match err { diff --git a/fission-server/src/extract/authority.rs b/fission-server/src/extract/authority.rs index c04041b3..24122462 100644 --- a/fission-server/src/extract/authority.rs +++ b/fission-server/src/extract/authority.rs @@ -2,8 +2,7 @@ //! //! Todo: this should be extracted to a separate crate and made available as a generic Axum UCAN extractor. -use std::str::FromStr; - +use crate::{authority::Authority, error::AppError}; use anyhow::anyhow; use axum::{ async_trait, @@ -12,18 +11,16 @@ use axum::{ http::request::Parts, RequestPartsExt, }; - +use fission_core::authority::{ + self, + Error::{InvalidUcan, MissingCredentials}, +}; use http::{HeaderValue, StatusCode}; -use rs_ucan::ucan::Ucan; -use serde::de::DeserializeOwned; +use libipld::Ipld; use serde_json::json; - -// 🧬 - -use crate::{authority::Authority, error::AppError}; -use fission_core::{ - authority, - authority::Error::{InvalidUcan, MissingCredentials}, +use ucan::{ + ability::{arguments::Named, command::ToCommand, parse::ParseAbility}, + Delegation, }; ///////////////// @@ -32,7 +29,7 @@ use fission_core::{ /// The `ucans` header #[derive(Debug)] -pub struct UcansHeader(Vec); +pub struct UcansHeader(Vec); impl Header for UcansHeader { fn name() -> &'static HeaderName { @@ -45,11 +42,11 @@ impl Header for UcansHeader { where I: Iterator, { - let mut ucans = Vec::new(); + let mut delegations = Vec::new(); for header_value in header_values { let header_str = header_value.to_str().map_err(|_| { - tracing::warn!("Got non-string ucan request header: {:?}", header_value); + tracing::error!("Got non-string ucan request header: {:?}", header_value); headers::Error::invalid() })?; @@ -61,15 +58,31 @@ impl Header for UcansHeader { // Per Postel's principle we're lenient in what we accept. continue; } - let ucan = Ucan::from_str(ucan_str).map_err(|e| { - tracing::warn!(?ucan_str, "Got invalid ucan in ucan request header: {e}"); - headers::Error::invalid() - })?; - ucans.push(ucan); + + let bytes = data_encoding::BASE64URL_NOPAD + .decode(ucan_str.as_bytes()) + .map_err(|e| { + tracing::error!( + ?ucan_str, + "Got invalid ucan in ucan request header: {e:#?}" + ); + headers::Error::invalid() + })?; + + let delegation: Delegation = + serde_ipld_dagcbor::from_slice(&bytes).map_err(|e| { + tracing::error!( + ?ucan_str, + "Got invalid ucan in ucan request header, couldn't deserialize: {e:#?}" + ); + headers::Error::invalid() + })?; + + delegations.push(delegation); } } - Ok(UcansHeader(ucans)) + Ok(UcansHeader(delegations)) } fn encode(&self, values: &mut E) @@ -79,14 +92,29 @@ impl Header for UcansHeader { let header_str = self .0 .iter() - .map(|ucan| ucan.encode().expect("Failed to encode UCAN")) + .filter_map(|delegation| { + let result = (|| { + let bytes = serde_ipld_dagcbor::to_vec(&delegation)?; + let string = data_encoding::BASE64URL_NOPAD.encode(&bytes); + Ok::<_, anyhow::Error>(string) + })(); + if let Err(e) = &result { + tracing::error!("Couldn't encode delegation in HTTP header: {e:#?}"); + } + // This isn't ideal, but avoids panicking. + result.ok() + }) .collect::>() .join(" "); - let header_value = HeaderValue::from_str(&header_str) - .expect("Encoded UCAN into invalid HTTP header characters"); - - values.extend([header_value]); + match HeaderValue::from_str(&header_str) { + Err(e) => { + tracing::error!("Couldn't encode 'ucans' header as string: {e:#?}"); + } + Ok(header_value) => { + values.extend([header_value]); + } + } } } @@ -95,36 +123,43 @@ impl Header for UcansHeader { //////////// #[async_trait] -impl FromRequestParts for Authority +impl FromRequestParts for Authority where S: Send + Sync, - F: Clone + DeserializeOwned, + A: Clone + ToCommand + ParseAbility, + Named: From, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - do_extract_authority(parts).await.map_err(|err| match err { - authority::Error::InsufficientCapabilityScope { .. } => { - AppError::new(StatusCode::FORBIDDEN, Some("Insufficient capability scope")) - } - authority::Error::InvalidUcan { reason } => AppError::new( - StatusCode::UNAUTHORIZED, - Some(format!("Invalid UCAN: {reason}")), - ), - authority::Error::MissingCredentials => { - AppError::new(StatusCode::UNAUTHORIZED, Some("Missing credentials")) + do_extract_authority(parts).await.map_err(|err| { + tracing::error!(?err, "Couldn't extract UCANs from request"); + match err { + authority::Error::InsufficientCapabilityScope { .. } => { + AppError::new(StatusCode::FORBIDDEN, Some("Insufficient capability scope")) + } + authority::Error::InvalidUcan { reason } => AppError::new( + StatusCode::UNAUTHORIZED, + Some(format!("Invalid UCAN: {reason}")), + ), + authority::Error::MissingCredentials => { + AppError::new(StatusCode::UNAUTHORIZED, Some("Missing credentials")) + } + authority::Error::MissingProofs { proofs_needed } => AppError::new( + StatusCode::NOT_EXTENDED, + Some(json!({ "prf": proofs_needed })), + ), } - authority::Error::MissingProofs { proofs_needed } => AppError::new( - StatusCode::NOT_EXTENDED, - Some(json!({ "prf": proofs_needed })), - ), }) } } -async fn do_extract_authority( +async fn do_extract_authority( parts: &mut Parts, -) -> Result, authority::Error> { +) -> Result, authority::Error> +where + Named: From, +{ // Extract the token from the authorization header let TypedHeader(Authorization(bearer)) = parts .extract::>>() @@ -134,22 +169,31 @@ async fn do_extract_authority( MissingCredentials })?; - let TypedHeader(UcansHeader(proofs)) = parts + let TypedHeader(UcansHeader(delegations)) = parts .extract::>() .await .map_err(|e| { - tracing::error!(?e, "Error while looking up ucans header value"); - MissingCredentials - })?; + tracing::error!(?e, "Error while looking up ucans header value"); + MissingCredentials + })?; // Decode the UCAN let token = bearer.token(); - let ucan = Ucan::try_from(token).map_err(|reason| InvalidUcan { - reason: anyhow!(reason), + let bytes = data_encoding::BASE64URL_NOPAD + .decode(token.as_bytes()) + .map_err(|e| InvalidUcan { + reason: anyhow!("Couldn't parse base64 {token:?}: {e:#?}"), + })?; + + let invocation = serde_ipld_dagcbor::from_slice(&bytes).map_err(|e| InvalidUcan { + reason: anyhow!("Couldn't decode invocation: {e:#?}"), })?; // Construct authority - Ok(Authority { ucan, proofs }) + Ok(Authority { + invocation, + delegations, + }) } /////////// @@ -167,18 +211,36 @@ mod tests { extract::State, routing::{get, Router}, }; - use fission_core::ed_did_key::EdDidKey; + use fission_core::caps::{CmdCapabilityFetch, FissionAbility}; use http::{Request, Response}; - use rs_ucan::builder::UcanBuilder; + use rand::rngs::OsRng; + use std::collections::BTreeMap; use testresult::TestResult; use tower::ServiceExt; + use ucan::{ + crypto::{ + signature::Envelope, + varsig::{self, header::EdDsaHeader}, + Nonce, + }, + did::preset::{Signer, Verifier}, + invocation::Payload, + Invocation, + }; #[test_log::test(tokio::test)] async fn extract_authority() -> TestResult { let ctx = &TestContext::new().await?; - let issuer = &EdDidKey::generate(); + let sk = ed25519_dalek::SigningKey::generate(&mut OsRng); + let did = Verifier::Key(ucan::did::key::Verifier::EdDsa(sk.verifying_key())); + let signer = Signer::Key(ucan::did::key::Signer::EdDsa(sk)); + + let server_did = Verifier::Key(ucan::did::key::Verifier::EdDsa( + ctx.server_did().verifying_key(), + )); // Test if request requires a valid UCAN + #[axum_macros::debug_handler] async fn authorized_get( _state: State>, _authority: Authority, @@ -191,12 +253,27 @@ mod tests { .with_state(ctx.app_state().clone()); // If a valid UCAN is given - let ucan: Ucan = UcanBuilder::default() - .for_audience(ctx.server_did()) - .with_lifetime(100) - .sign(issuer)?; - - let ucan_string: String = ucan.encode()?; + let ucan = Invocation::try_sign( + &signer, + varsig::header::Preset::EdDsa(EdDsaHeader { + codec: varsig::encoding::Preset::DagCbor, + }), + Payload { + subject: did.clone(), + issuer: did, + audience: Some(server_did), + ability: FissionAbility::CapabilityFetch(CmdCapabilityFetch), + proofs: Vec::new(), + cause: None, + metadata: BTreeMap::new(), + nonce: Nonce::generate_16(), + issued_at: None, + expiration: None, + }, + )?; + + let ucan_string: String = + data_encoding::BASE64URL_NOPAD.encode(&serde_ipld_dagcbor::to_vec(&ucan)?); let authed = app .clone() .oneshot( diff --git a/fission-server/src/models/account.rs b/fission-server/src/models/account.rs index f66e746a..cb981b8d 100644 --- a/fission-server/src/models/account.rs +++ b/fission-server/src/models/account.rs @@ -1,6 +1,6 @@ //! Fission Account Model -use super::capability_indexing::index_ucan; +use super::capability_indexing::{index_ucan, ucan_base64}; use crate::{ db::{ schema::{accounts, ucans}, @@ -15,19 +15,14 @@ use chrono::NaiveDateTime; use diesel::prelude::*; use diesel_async::RunQueryDsl; use fission_core::{ - capabilities::did::Did, common::Account, ed_did_key::EdDidKey, + test_utils::varsig_header, username::{Handle, Username}, }; -use rs_ucan::{ - builder::UcanBuilder, - capability::Capability, - semantics::{ability::TopAbility, caveat::EmptyCaveat}, - ucan::Ucan, -}; use serde::{Deserialize, Serialize}; -use std::str::FromStr; +use std::{str::FromStr, time::SystemTime}; +use ucan::{crypto::signature::Envelope, delegation, did::preset::Verifier, Delegation}; use utoipa::ToSchema; /// New Account Struct (for creating new accounts) @@ -211,8 +206,7 @@ pub struct AccountAndAuth { /// The Associated Account pub account: Account, /// UCANs that give root access - #[schema(value_type = Vec)] - pub ucans: Vec, + pub ucans: Vec, } impl AccountAndAuth { @@ -233,7 +227,10 @@ impl AccountAndAuth { dns_settings: &settings::Dns, conn: &mut Conn<'_>, ) -> Result { - let (ucans, account_did) = Self::issue_root_ucans(server, agent_did, conn).await?; + let (signer, did) = server.to_ucan_interop(); + let server = delegation::Agent::new(did, signer, delegation::store::MemoryStore::new()); + let (ucans, account_did) = Self::issue_root_ucans(&server, agent_did, conn).await?; + let ucans = ucans.iter().map(ucan_base64).collect::>()?; let record = AccountRecord::new(conn, username, email, account_did).await?; Ok(Self { @@ -250,68 +247,79 @@ impl AccountAndAuth { pub async fn link_agent( account: AccountRecord, agent_did: &str, - server: &EdDidKey, + server: &delegation::Agent, dns_settings: &settings::Dns, conn: &mut Conn<'_>, ) -> Result { - let server_ucan: String = ucans::table + let server_ucan_encoded: Vec = ucans::table .filter(ucans::issuer.eq(&account.did)) - .filter(ucans::audience.eq(server.did_as_str())) + .filter(ucans::audience.eq(&server.did.to_string())) .select(ucans::encoded) .get_result(conn) .await?; - let server_ucan: Ucan = Ucan::from_str(&server_ucan)?; + let account_did = Verifier::from_str(&account.did)?; + let agent_did = Verifier::from_str(agent_did)?; - let account_did = account.did.clone(); + let agent_ucan: Delegation = + Self::issue_agent_ucan(server, &account_did, &agent_did, conn).await?; - let agent_ucan = - Self::issue_agent_ucan(server, account_did, agent_did, &server_ucan, conn).await?; + let server_ucan_base64 = data_encoding::BASE64URL_NOPAD.encode(&server_ucan_encoded); + let agent_ucan_base64 = ucan_base64(&agent_ucan)?; Ok(Self { - ucans: vec![server_ucan, agent_ucan], + ucans: vec![server_ucan_base64, agent_ucan_base64], account: account.to_account(dns_settings)?, }) } async fn issue_root_ucans( - server: &EdDidKey, + server: &delegation::Agent, agent_did: &str, conn: &mut Conn<'_>, - ) -> Result<(Vec, String)> { - let account = EdDidKey::generate(); // Zeroized on drop + ) -> Result<(Vec, String)> { + let (account, account_did) = EdDidKey::generate().to_ucan_interop(); // Gets dropped at the end + let agent_did = Verifier::from_str(agent_did)?; // Delegate all access to the fission server - let capability = Capability::new(Did(account.did()), TopAbility, EmptyCaveat); - let server_ucan: Ucan = UcanBuilder::default() - .for_audience(server) - .claiming_capability(capability) - .sign(&account)?; + let server_ucan = Delegation::try_sign( + &account, + varsig_header(), + delegation::PayloadBuilder::default() + .issuer(account_did.clone()) + .audience(server.did.clone()) + .subject(None) + .command("/".to_string()) + .build()?, + )?; // Persist UCAN in the DB index_ucan(&server_ucan, conn).await?; // Delegate the account to the agent - let agent_ucan = - Self::issue_agent_ucan(server, account.did(), agent_did, &server_ucan, conn).await?; + let agent_ucan = Self::issue_agent_ucan(server, &account_did, &agent_did, conn).await?; - Ok((vec![server_ucan, agent_ucan], account.did())) + Ok((vec![server_ucan, agent_ucan], account_did.to_string())) } async fn issue_agent_ucan( - server: &EdDidKey, - account_did: String, - agent_did: &str, - server_ucan: &Ucan, + server: &delegation::Agent, + account_did: &Verifier, + agent_did: &Verifier, conn: &mut Conn<'_>, - ) -> Result { - // Delegate the account to the agent - let capability = Capability::new(Did(account_did), TopAbility, EmptyCaveat); - let agent_ucan: Ucan = UcanBuilder::default() - .for_audience(agent_did) - .claiming_capability(capability) - .witnessed_by(server_ucan, None) - .sign(server)?; + ) -> Result { + let agent_ucan = server.delegate( + agent_did.clone(), + Some(account_did), + None, + "/".to_string(), + Default::default(), + Default::default(), + Default::default(), + Default::default(), + SystemTime::now(), + varsig_header(), + )?; index_ucan(&agent_ucan, conn).await?; diff --git a/fission-server/src/models/capability_indexing.rs b/fission-server/src/models/capability_indexing.rs index 98f62649..7ca9723a 100644 --- a/fission-server/src/models/capability_indexing.rs +++ b/fission-server/src/models/capability_indexing.rs @@ -1,27 +1,21 @@ //! Models related to capability indexing, specifically the `ucans` and `capabilities` table. use crate::{ - db::{ - schema::{capabilities, ucans}, - Conn, - }, + db::{schema::ucans, Conn}, models::revocation::find_revoked_subset, }; -use anyhow::{anyhow, Result}; +use anyhow::{bail, Result}; use chrono::{DateTime, NaiveDateTime}; use diesel::{ - pg::Pg, Associations, ExpressionMethods, Identifiable, Insertable, OptionalExtension, QueryDsl, - Queryable, Selectable, SelectableHelper, + pg::Pg, ExpressionMethods, Identifiable, Insertable, OptionalExtension, QueryDsl, Queryable, + Selectable, SelectableHelper, }; use diesel_async::RunQueryDsl; -use fission_core::{common::UcansResponse, revocation::canonical_cid}; -use rs_ucan::{capability::Capability, ucan::Ucan}; +use fission_core::common::UcansResponse; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{ - collections::{BTreeMap, BTreeSet}, - str::FromStr, -}; +use std::collections::{BTreeMap, BTreeSet}; +use ucan::{crypto::signature::Envelope, time::Timestamp, Delegation}; use utoipa::ToSchema; /// Represents an indexed UCAN in the database @@ -34,15 +28,28 @@ pub struct IndexedUcan { /// Internal DB id pub id: i32, - /// SHA2-256 raw CID of the encoded token + /// CID of the encoded token, represented as string pub cid: String, /// Token in encoded format - pub encoded: String, + pub encoded: Vec, + /// UCAN `sub` field + pub subject: Option, /// UCAN `iss` field pub issuer: String, /// UCAN `aud` field pub audience: String, + /// UCAN `via` field + pub via: Option, + + /// UCAN `cmd` field: Minimum command prefix for allowed delegation + pub command: String, + /// UCAN `pol` field + pub policy: Value, + /// UCAN `meta` field + pub metadata: Value, + /// UCAN `nonce` field + pub nonce: Vec, /// UCAN `nbf` field #[schema(value_type = Option)] @@ -57,15 +64,28 @@ pub struct IndexedUcan { #[diesel(table_name = ucans)] #[diesel(check_for_backend(Pg))] pub struct NewIndexedUcan { - /// SHA2-256 raw CID of the encoded token + /// CID of the encoded token, represented as string pub cid: String, /// Token in encoded format - pub encoded: String, + pub encoded: Vec, + /// UCAN `sub` field + pub subject: Option, /// UCAN `iss` field pub issuer: String, /// UCAN `aud` field pub audience: String, + /// UCAN `via` field + pub via: Option, + + /// UCAN `cmd` field: Minimum command prefix for allowed delegation + pub command: String, + /// UCAN `pol` field + pub policy: Value, + /// UCAN `meta` field + pub metadata: Value, + /// UCAN `nonce` field + pub nonce: Vec, /// UCAN `nbf` field #[schema(value_type = Option)] @@ -75,64 +95,22 @@ pub struct NewIndexedUcan { pub expires_at: Option, } -/// Represents a database row of an indexed capability -#[derive( - Debug, - Clone, - Queryable, - Selectable, - Insertable, - Identifiable, - Associations, - Serialize, - Deserialize, - ToSchema, -)] -#[diesel(belongs_to(IndexedUcan, foreign_key = ucan_id))] -#[diesel(table_name = capabilities)] -#[diesel(check_for_backend(Pg))] -pub struct IndexedCapability { - /// Internal DB id - pub id: i32, - - /// Capability resource. For our purposes this will always be a DID. - pub resource: String, - /// Capability's ability - pub ability: String, - - /// Any caveats. 'No caveats' would be `[{}]` - pub caveats: Value, - - /// DB id of the UCAN that this capability is contained in - pub ucan_id: i32, -} - -/// Represents an indexed capability that hasn't been added to the database yet -#[derive( - Debug, Clone, Queryable, Selectable, Insertable, Associations, Serialize, Deserialize, ToSchema, -)] -#[diesel(belongs_to(IndexedUcan, foreign_key = ucan_id))] -#[diesel(table_name = capabilities)] -#[diesel(check_for_backend(Pg))] -pub struct NewIndexedCapability { - /// Capability resource. For our purposes this will always be a DID. - pub resource: String, - /// Capability's ability - pub ability: String, - - /// Any caveats. 'No caveats' would be `[{}]` - pub caveats: Value, - - /// DB id of the UCAN that this capability is contained in - pub ucan_id: i32, +pub fn ucan_base64(ucan: &Delegation) -> Result { + Ok(data_encoding::BASE64URL_NOPAD.encode(&ucan.varsig_encode(Vec::new())?)) } /// Index a UCAN in the database. /// Should be idempotent. -pub async fn index_ucan(ucan: &Ucan, conn: &mut Conn<'_>) -> Result { +pub async fn index_ucan(ucan: &Delegation, conn: &mut Conn<'_>) -> Result { use crate::db::schema::*; - // TODO only index UCANs & their capabilities, if they've been proven! + if let Some(exp) = ucan.expiration() { + if *exp < Timestamp::now() { + bail!("Not indexing UCAN that already expired (expiration: {exp:?})"); + } + } + + ucan.validate_signature()?; let new_indexed_ucan = NewIndexedUcan::new(ucan)?; @@ -143,8 +121,7 @@ pub async fn index_ucan(ucan: &Ucan, conn: &mut Conn<'_>) -> Result .await .optional()?; - // We short-circuit if we've stored this before, since then we'd have - // stored the capabilities as well. + // We short-circuit if we've stored this before if let Some(ucan_id) = existing_ucan_id { return Ok(IndexedUcan::new(new_indexed_ucan, ucan_id)); } @@ -157,82 +134,83 @@ pub async fn index_ucan(ucan: &Ucan, conn: &mut Conn<'_>) -> Result let indexed_ucan = IndexedUcan::new(new_indexed_ucan, ucan_id); - let capabilities = ucan - .capabilities() - .map(|cap| NewIndexedCapability::new(cap, ucan_id)) - .collect::>>()?; - - diesel::insert_into(capabilities::table) - .values(&capabilities) - .execute(conn) - .await?; - Ok(indexed_ucan) } /// Fetch all indexed UCANs that end in a specific audience +#[tracing::instrument(skip(conn), ret, err)] pub async fn find_ucans_for_audience( audience: String, conn: &mut Conn<'_>, ) -> Result { + // TODO also filter by command? tracing::debug!(audience, "Doing initial lookup of UCANs matching audience"); - let ids_issuers_resources: Vec<(i32, String, String)> = ucans::table - .inner_join(capabilities::table) - .filter(ucans::audience.eq(&audience)) - .select((ucans::id, ucans::issuer, capabilities::resource)) - .get_results(conn) - .await?; - - let ids = ids_issuers_resources.iter().map(|(id, _, _)| id).cloned(); - let issuers = ids_issuers_resources.iter().map(|(_, iss, _)| iss).cloned(); - - let mut visited_ids_set = BTreeSet::::from_iter(ids); - let mut audience_dids_frontier = BTreeSet::from_iter(issuers); - - let resources = ids_issuers_resources - .into_iter() - .map(|(_, _, res)| res) - .collect::>(); - - tracing::debug!( - ?resources, - "Looking for resources (not yet looking for subsumtions)" - ); - - loop { - tracing::debug!( - visited_ids_set = ?visited_ids_set, - audience_dids_frontier = ?audience_dids_frontier, - "UCAN graph search iteration" - ); - - let ids_and_issuers: Vec<(i32, String)> = ucans::table - .inner_join(capabilities::table) - .filter(ucans::audience.eq_any(&audience_dids_frontier)) - .filter(ucans::id.ne_all(&visited_ids_set)) - // TODO: Support subsumtion of resources/capabilities - .filter(capabilities::resource.eq_any(&resources)) - .select((ucans::id, ucans::issuer)) + let mut needles: BTreeMap> = + BTreeMap::from([(audience, BTreeSet::new())]); + let mut visited_ids = BTreeSet::new(); + let mut good_ucan_ids = BTreeSet::new(); + while !needles.is_empty() { + tracing::debug!(?needles, "Looking for candidates"); + + let candidates: Vec<(i32, String, String, Option)> = ucans::table + .filter(ucans::audience.eq_any(needles.iter().map(|(aud, _)| aud))) + .filter(ucans::id.ne_all(&visited_ids)) + .select((ucans::id, ucans::issuer, ucans::audience, ucans::subject)) .get_results(conn) .await?; - if ids_and_issuers.is_empty() { - break; + tracing::debug!(?candidates, "Found candidates"); + + let mut next_needles: BTreeMap> = BTreeMap::new(); + for (id, issuer, audience, subject) in candidates { + tracing::trace!(id, issuer, audience, subject, "Examining candidate"); + + if !visited_ids.insert(id) { + tracing::trace!("Already examined. Trying next."); + continue; // We've visited this before + } + + let Some(required_subjects) = needles.get(&audience) else { + tracing::warn!("Impossible case reached"); + continue; // Got a ucan... we didn't ask for? (Should never happen due to the ucans::audience.eq_any above) + }; + + // We may have restrictions on which subjects we can search for. + if !required_subjects.is_empty() { + tracing::trace!( + ?required_subjects, + "Matching subject against required subjects" + ); + + match subject { + None => { + // This is a powerline. We continue looking for UCANs, but without restrictions. + next_needles.insert(issuer, BTreeSet::new()); // If we were looking for UCANs with subject restrictions, by overwriting we've now lifted them. + } + Some(subject) => { + if !required_subjects.contains(&subject) { + // This UCAN doesn't help us out - it's delegating a different subject + continue; + } + + // Add the required subject as one of the possible subjects to look for + next_needles.entry(issuer).or_default().insert(subject); + } + } + } + + tracing::debug!("Noting candidate as good."); + good_ucan_ids.insert(id); } - audience_dids_frontier.clear(); - - for (id, issuer) in ids_and_issuers { - visited_ids_set.insert(id); - audience_dids_frontier.insert(issuer); - } + needles = next_needles; } - tracing::debug!(visited_ids_set = ?visited_ids_set, "Finished UCAN graph search"); + tracing::debug!(?good_ucan_ids, "Finished UCAN graph search"); let indexed_ucans = ucans::table - .filter(ucans::id.eq_any(&visited_ids_set)) + .filter(ucans::id.eq_any(&good_ucan_ids)) .select(IndexedUcan::as_select()) .get_results(conn) .await?; @@ -240,10 +218,12 @@ pub async fn find_ucans_for_audience( let ucans = indexed_ucans .into_iter() .map(|ucan| { - let decoded = Ucan::from_str(&ucan.encoded).map_err(|e| anyhow!(e))?; - Ok((ucan.cid, decoded)) + ( + ucan.cid, + data_encoding::BASE64URL_NOPAD.encode(&ucan.encoded), + ) }) - .collect::>>()?; + .collect::>(); let canonical_cids = ucans.keys().cloned().collect(); @@ -253,56 +233,62 @@ pub async fn find_ucans_for_audience( } impl NewIndexedUcan { - fn new(ucan: &Ucan) -> Result { - let encoded = ucan.encode()?; + fn new(ucan: &Delegation) -> Result { + let encoded = ucan.varsig_encode(Vec::new())?; + let cid = ucan.cid()?.to_string(); // TODO: duplicate work + + let subject = ucan.subject().map(|did| did.to_string()); let issuer = ucan.issuer().to_string(); let audience = ucan.audience().to_string(); + let via = ucan.via().map(|did| did.to_string()); + + let command = ucan.command().clone(); + let policy = Value::Array(Vec::new()); // TODO: Actually convert policy -> serde_json::Value + let metadata = Value::Object(serde_json::Map::new()); // TODO: Actually convert metadata -> serde_json::Value + let nonce = ucan.nonce().clone().into(); let not_before = ucan .not_before() + .map(Timestamp::to_unix) .and_then(|seconds| DateTime::from_timestamp_millis((seconds * 1000) as i64)) .map(|dt| dt.naive_utc()); let expires_at = ucan - .expires_at() + .expiration() + .map(Timestamp::to_unix) .and_then(|seconds| DateTime::from_timestamp_millis((seconds * 1000) as i64)) .map(|dt| dt.naive_utc()); - let cid = canonical_cid(ucan)?; - Ok(Self { cid, encoded, + subject, issuer, audience, + via, + command, + policy, + metadata, + nonce, not_before, expires_at, }) } } -impl NewIndexedCapability { - fn new(cap: &Capability, ucan_id: i32) -> Result { - let resource = cap.resource().to_string(); - let ability = cap.ability().to_string(); - let caveats = cap.caveat().serialize(serde_json::value::Serializer)?; - - Ok(Self { - resource, - ability, - caveats, - ucan_id, - }) - } -} - impl IndexedUcan { fn new(new_ucan: NewIndexedUcan, id: i32) -> Self { let NewIndexedUcan { cid, encoded, + subject, issuer, audience, + via, + command, + policy, + metadata, + nonce, not_before, expires_at, } = new_ucan; @@ -310,70 +296,56 @@ impl IndexedUcan { id, cid, encoded, + subject, issuer, audience, + via, + command, + policy, + metadata, + nonce, not_before, expires_at, } } } -impl IndexedCapability { - #[allow(unused)] - fn new(new_cap: NewIndexedCapability, id: i32) -> Self { - let NewIndexedCapability { - resource, - ability, - caveats, - ucan_id, - } = new_cap; - Self { - id, - resource, - ability, - caveats, - ucan_id, - } - } -} - #[cfg(test)] mod tests { use super::*; use crate::test_utils::test_context::TestContext; - use fission_core::{ - capabilities::{did::Did, fission::FissionAbility}, - ed_did_key::EdDidKey, - }; - use rs_ucan::{builder::UcanBuilder, semantics::caveat::EmptyCaveat}; + use fission_core::{ed_did_key::EdDidKey, test_utils::varsig_header}; use testresult::TestResult; + use ucan::delegation; #[test_log::test(tokio::test)] async fn test_find_ucan_by_audience_single() -> TestResult { let ctx = &TestContext::new().await?; let conn = &mut ctx.get_db_conn().await?; - let issuer = EdDidKey::generate(); - let audience = EdDidKey::generate(); + let (issuer, issuer_did) = EdDidKey::generate().to_ucan_interop(); + let (_, audience_did) = EdDidKey::generate().to_ucan_interop(); - let ucan: Ucan = UcanBuilder::default() - .for_audience(&audience) - .claiming_capability(Capability::new( - Did(issuer.did()), - FissionAbility::AccountManage, - EmptyCaveat, - )) - .sign(&issuer)?; + let ucan = Delegation::try_sign( + &issuer, + varsig_header(), + delegation::PayloadBuilder::default() + .issuer(issuer_did.clone()) + .audience(audience_did.clone()) + .subject(Some(issuer_did.clone())) + .command("/".to_string()) + .build()?, + )?; index_ucan(&ucan, conn).await?; - let response = find_ucans_for_audience(audience.did(), conn).await?; + let response = find_ucans_for_audience(audience_did.to_string(), conn).await?; assert_eq!(response.ucans.len(), 1); let response_ucan = response.ucans.first_key_value().unwrap().1; - assert_eq!(response_ucan.to_cid(None)?, ucan.to_cid(None)?); + assert_eq!(response_ucan, &ucan_base64(&ucan)?); Ok(()) } @@ -383,27 +355,37 @@ mod tests { let ctx = &TestContext::new().await?; let conn = &mut ctx.get_db_conn().await?; - let alice = EdDidKey::generate(); - let bob = EdDidKey::generate(); - let carol = EdDidKey::generate(); - - let cap = Capability::new(Did(alice.did()), FissionAbility::AccountManage, EmptyCaveat); - - let root_ucan: Ucan = UcanBuilder::default() - .for_audience(&bob) - .claiming_capability(cap.clone()) - .sign(&alice)?; - - let ucan: Ucan = UcanBuilder::default() - .for_audience(&carol) - .claiming_capability(cap) - .sign(&bob)?; + let (alice, alice_did) = EdDidKey::generate().to_ucan_interop(); + let (bob, bob_did) = EdDidKey::generate().to_ucan_interop(); + let (_, carol_did) = EdDidKey::generate().to_ucan_interop(); + + let root_ucan = Delegation::try_sign( + &alice, + varsig_header(), + delegation::PayloadBuilder::default() + .issuer(alice_did.clone()) + .audience(bob_did.clone()) + .subject(Some(alice_did.clone())) + .command("/".to_string()) + .build()?, + )?; + + let ucan = Delegation::try_sign( + &bob, + varsig_header(), + delegation::PayloadBuilder::default() + .issuer(bob_did.clone()) + .audience(carol_did.clone()) + .subject(Some(alice_did.clone())) + .command("/".to_string()) + .build()?, + )?; index_ucan(&root_ucan, conn).await?; index_ucan(&ucan, conn).await?; - let response_bob = find_ucans_for_audience(bob.did(), conn).await?; - let response_carol = find_ucans_for_audience(carol.did(), conn).await?; + let response_bob = find_ucans_for_audience(bob_did.to_string(), conn).await?; + let response_carol = find_ucans_for_audience(carol_did.to_string(), conn).await?; // Bob still only gets one UCAN assert_eq!(response_bob.ucans.len(), 1); @@ -411,10 +393,7 @@ mod tests { assert_eq!(response_carol.ucans.len(), 2); let ucan_cids = response_carol.ucans.into_keys().collect::>(); - let expected_cids = BTreeSet::from([ - root_ucan.to_cid(None)?.to_string(), - ucan.to_cid(None)?.to_string(), - ]); + let expected_cids = BTreeSet::from([root_ucan.cid()?.to_string(), ucan.cid()?.to_string()]); assert_eq!(ucan_cids, expected_cids); @@ -422,32 +401,41 @@ mod tests { } #[test_log::test(tokio::test)] - async fn test_find_ucan_by_audience_only_matching_resource() -> TestResult { + async fn test_find_ucan_by_audience_only_matching_subjects() -> TestResult { let ctx = &TestContext::new().await?; let conn = &mut ctx.get_db_conn().await?; - let alice = EdDidKey::generate(); - let bob = EdDidKey::generate(); - let carol = EdDidKey::generate(); - - let cap_a = Capability::new(Did(alice.did()), FissionAbility::AccountManage, EmptyCaveat); - let cap_b = Capability::new(Did(bob.did()), FissionAbility::AccountManage, EmptyCaveat); - - let ucan_a: Ucan = UcanBuilder::default() - .for_audience(&bob) - .claiming_capability(cap_a) - .sign(&alice)?; - - let ucan_b: Ucan = UcanBuilder::default() - .for_audience(&carol) - .claiming_capability(cap_b) // Different capability! - .sign(&bob)?; + let (alice, alice_did) = EdDidKey::generate().to_ucan_interop(); + let (bob, bob_did) = EdDidKey::generate().to_ucan_interop(); + let (carol, carol_did) = EdDidKey::generate().to_ucan_interop(); + + let ucan_a = Delegation::try_sign( + &alice, + varsig_header(), + delegation::PayloadBuilder::default() + .issuer(alice_did.clone()) + .audience(bob_did.clone()) + .subject(Some(alice_did.clone())) + .command("/".to_string()) + .build()?, + )?; + + let ucan_b = Delegation::try_sign( + &bob, + varsig_header(), + delegation::PayloadBuilder::default() + .issuer(bob_did.clone()) + .audience(carol_did.clone()) + .subject(Some(bob_did.clone())) // Different subject! + .command("/".to_string()) + .build()?, + )?; index_ucan(&ucan_a, conn).await?; index_ucan(&ucan_b, conn).await?; - let response_bob = find_ucans_for_audience(bob.did(), conn).await?; - let response_carol = find_ucans_for_audience(carol.did(), conn).await?; + let response_bob = find_ucans_for_audience(bob_did.to_string(), conn).await?; + let response_carol = find_ucans_for_audience(carol_did.to_string(), conn).await?; // Bob still only gets one UCAN assert_eq!(response_bob.ucans.len(), 1); @@ -455,7 +443,7 @@ mod tests { assert_eq!(response_carol.ucans.len(), 1); let ucan_cids = response_carol.ucans.into_keys().collect::>(); - let expected_cids = BTreeSet::from([ucan_b.to_cid(None)?.to_string()]); + let expected_cids = BTreeSet::from([ucan_b.cid()?.to_string()]); // Carol doesn't get the UCAN that's not matching the same resource assert_eq!(ucan_cids, expected_cids); diff --git a/fission-server/src/routes/account.rs b/fission-server/src/routes/account.rs index 87c0b13e..550ccf9d 100644 --- a/fission-server/src/routes/account.rs +++ b/fission-server/src/routes/account.rs @@ -24,21 +24,24 @@ use axum::{ use diesel::{ExpressionMethods, OptionalExtension, QueryDsl}; use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection, RunQueryDsl}; use fission_core::{ - capabilities::{did::Did, fission::FissionAbility}, + capabilities::did::Did, + caps::{ + AccountNoncritical, CmdAccountCreate, CmdAccountDelete, CmdAccountInfo, CmdAccountLink, + CmdAccountManage, FissionAbility, + }, common::{ Account, AccountCreationRequest, AccountLinkRequest, MemberNumberResponse, SuccessResponse, }, ed_did_key::EdDidKey, - revocation::Revocation, username::{Handle, Username}, }; use hickory_server::proto::{rr::RecordType, serialize::binary::BinDecodable}; -use rs_ucan::ucan::Ucan; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, str::FromStr, }; use tracing::debug; +use ucan::delegation; use validator::Validate; /// POST handler for creating a new account @@ -65,7 +68,7 @@ pub async fn create_account( .map_err(|e| AppError::new(StatusCode::BAD_REQUEST, Some(e)))?; let Did(did) = authority - .get_capability(&state, FissionAbility::AccountCreate) + .get_capability(&state, FissionAbility::AccountCreate(CmdAccountCreate)) .await?; let conn = &mut db::connect(&state.db_pool).await?; @@ -119,7 +122,7 @@ pub async fn link_account( Json(request): Json, ) -> AppResult<(StatusCode, Json)> { let Did(agent_did) = authority - .get_capability(&state, FissionAbility::AccountLink) + .get_capability(&state, FissionAbility::AccountLink(CmdAccountLink)) .await?; let conn = &mut db::connect(&state.db_pool).await?; @@ -138,10 +141,14 @@ pub async fn link_account( debug!("Found EmailVerification {verification:?}"); + let (server, server_did) = state.server_keypair.to_ucan_interop(); + let server_agent = + delegation::Agent::new(server_did, server, delegation::store::MemoryStore::new()); + let account = AccountAndAuth::link_agent( account, &agent_did, - &state.server_keypair, + &server_agent, &state.dns_settings, conn, ) @@ -176,7 +183,10 @@ pub async fn get_account( authority: Authority, ) -> AppResult<(StatusCode, Json)> { let Did(did) = authority - .get_capability(&state, FissionAbility::AccountInfo) + .get_capability( + &state, + FissionAbility::AccountNoncritical(AccountNoncritical::Info(CmdAccountInfo)), + ) .await?; let conn = &mut db::connect(&state.db_pool).await?; @@ -211,7 +221,10 @@ pub async fn get_member_number( authority: Authority, ) -> AppResult<(StatusCode, Json)> { let Did(did) = authority - .get_capability(&state, FissionAbility::AccountInfo) + .get_capability( + &state, + FissionAbility::AccountNoncritical(AccountNoncritical::Info(CmdAccountInfo)), + ) .await?; let conn = &mut db::connect(&state.db_pool).await?; @@ -254,7 +267,7 @@ pub async fn patch_username( Username::from_str(&username)?; let Did(did) = authority - .get_capability(&state, FissionAbility::AccountManage) + .get_capability(&state, FissionAbility::AccountManage(CmdAccountManage)) .await?; let conn = &mut db::connect(&state.db_pool).await?; @@ -295,7 +308,7 @@ pub async fn patch_handle( handle.validate()?; let Did(did) = authority - .get_capability(&state, FissionAbility::AccountManage) + .get_capability(&state, FissionAbility::AccountManage(CmdAccountManage)) .await?; // TODO Better APIs. It should be easier to ask our own DNS server some Qs @@ -360,7 +373,7 @@ pub async fn delete_handle( authority: Authority, ) -> AppResult<(StatusCode, Json)> { let Did(did) = authority - .get_capability(&state, FissionAbility::AccountManage) + .get_capability(&state, FissionAbility::AccountManage(CmdAccountManage)) .await?; let conn = &mut db::connect(&state.db_pool).await?; @@ -394,14 +407,14 @@ pub async fn delete_account( authority: Authority, ) -> AppResult<(StatusCode, Json)> { let Did(did) = authority - .get_capability(&state, FissionAbility::AccountDelete) + .get_capability(&state, FissionAbility::AccountDelete(CmdAccountDelete)) .await?; let conn = &mut db::connect(&state.db_pool).await?; let server_keypair = state.server_keypair; conn.transaction(|conn| { async move { - use crate::db::schema::{accounts, capabilities, ucans, revocations}; + use crate::db::schema::{accounts, ucans, revocations}; let account = diesel::delete(accounts::table) .filter(accounts::did.eq(&did)) .get_result::(conn) @@ -414,9 +427,8 @@ pub async fn delete_account( ) })?; - let indexed_ucans: Vec<(String, String, i32)> = capabilities::table - .inner_join(ucans::table) - .filter(capabilities::resource.eq(&did)) + let indexed_ucans: Vec<(String, Vec, i32)> = ucans::table + .filter(ucans::subject.eq(&did)) .select((ucans::issuer, ucans::encoded, ucans::id)) .get_results(conn) .await?; @@ -425,10 +437,8 @@ pub async fn delete_account( // Revoke the server to user UCANs - fn ucan_revocation(issuer: &EdDidKey, encoded_ucan: &str) -> Result { - let ucan: Ucan = Ucan::from_str(encoded_ucan)?; - let revocation = Revocation::new(issuer, &ucan)?; - Ok(NewRevocationRecord::new(revocation)) + fn ucan_revocation(_issuer: &EdDidKey, _encoded_ucan: &[u8]) -> Result { + todo!() } let revocation_records = indexed_ucans @@ -495,13 +505,16 @@ mod tests { let (status, auth) = create_account::(username, email, issuer, ctx).await?; + let ucans = auth + .ucans + .iter() + .map(|s| Ucan::from_str(&s)) + .collect::, _>>()?; + assert_eq!(status, StatusCode::CREATED); assert_eq!(auth.account.username, Some(ctx.user_handle(username)?)); assert_eq!(auth.account.email, Some(email.to_string())); - assert!(auth - .ucans - .iter() - .any(|ucan| ucan.audience() == issuer.as_ref())); + assert!(ucans.iter().any(|ucan| ucan.audience() == issuer.as_ref())); Ok(()) } @@ -1002,8 +1015,13 @@ mod tests { issuer: &EdDidKey, ctx: &TestContext, ) -> Result { - let account_ucan = account + let ucans = account .ucans + .iter() + .map(|ucan| Ucan::from_str(&ucan)) + .collect::, _>>()?; + + let account_ucan = ucans .iter() .find(|ucan| ucan.audience() == issuer.did_as_str()); @@ -1037,12 +1055,18 @@ mod tests { let invocation = build_acc_invocation(FissionAbility::AccountManage, &account, issuer, ctx)?; + let ucans = account + .ucans + .iter() + .map(|ucan| Ucan::from_str(&ucan)) + .collect::, _>>()?; + ctx.request( Method::PATCH, format!("/api/v0/account/username/{new_username}"), ) .with_ucan(invocation) - .with_ucan_proofs(account.ucans.clone()) + .with_ucan_proofs(ucans) .into_json_response() .await } @@ -1056,12 +1080,18 @@ mod tests { let invocation = build_acc_invocation(FissionAbility::AccountManage, &account, issuer, ctx)?; + let ucans = account + .ucans + .iter() + .map(|ucan| Ucan::from_str(&ucan)) + .collect::, _>>()?; + ctx.request( Method::PATCH, format!("/api/v0/account/handle/{new_handle}"), ) .with_ucan(invocation) - .with_ucan_proofs(account.ucans.clone()) + .with_ucan_proofs(ucans) .into_json_response() .await } @@ -1094,9 +1124,15 @@ mod tests { let invocation = build_acc_invocation(FissionAbility::AccountManage, &account, issuer, ctx)?; + let ucans = account + .ucans + .iter() + .map(|ucan| Ucan::from_str(&ucan)) + .collect::, _>>()?; + ctx.request(Method::DELETE, format!("/api/v0/account/handle")) .with_ucan(invocation) - .with_ucan_proofs(account.ucans.clone()) + .with_ucan_proofs(ucans) .into_json_response() .await } @@ -1108,9 +1144,16 @@ mod tests { ) -> Result<(StatusCode, T)> { let invocation = build_acc_invocation(FissionAbility::AccountDelete, account, issuer, ctx)?; + + let ucans = account + .ucans + .iter() + .map(|ucan| Ucan::from_str(&ucan)) + .collect::, _>>()?; + ctx.request(Method::DELETE, format!("/api/v0/account")) .with_ucan(invocation) - .with_ucan_proofs(account.ucans.clone()) + .with_ucan_proofs(ucans) .into_json_response() .await } @@ -1122,9 +1165,15 @@ mod tests { ) -> Result<(StatusCode, T)> { let invocation = build_acc_invocation(FissionAbility::AccountInfo, auth, issuer, ctx)?; + let ucans = auth + .ucans + .iter() + .map(|ucan| Ucan::from_str(&ucan)) + .collect::, _>>()?; + ctx.request(Method::GET, "/api/v0/account") .with_ucan(invocation) - .with_ucan_proofs(auth.ucans.clone()) + .with_ucan_proofs(ucans) .into_json_response() .await } @@ -1136,9 +1185,15 @@ mod tests { ) -> Result<(StatusCode, T)> { let invocation = build_acc_invocation(FissionAbility::AccountInfo, auth, issuer, ctx)?; + let ucans = auth + .ucans + .iter() + .map(|ucan| Ucan::from_str(&ucan)) + .collect::, _>>()?; + ctx.request(Method::GET, "/api/v0/account/member-number") .with_ucan(invocation) - .with_ucan_proofs(auth.ucans.clone()) + .with_ucan_proofs(ucans) .into_json_response() .await } diff --git a/fission-server/src/routes/capability_indexing.rs b/fission-server/src/routes/capability_indexing.rs index a0f6608e..5bf67b9f 100644 --- a/fission-server/src/routes/capability_indexing.rs +++ b/fission-server/src/routes/capability_indexing.rs @@ -6,10 +6,7 @@ use crate::{ }; use axum::extract::State; use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection}; -use fission_core::{ - capabilities::{did::Did, indexing::IndexingAbility}, - common::UcansResponse, -}; +use fission_core::{capabilities::did::Did, caps::CmdCapabilityFetch, common::UcansResponse}; use http::StatusCode; /// Return capabilities for a given DID @@ -31,7 +28,10 @@ pub async fn get_capabilities( authority: Authority, ) -> AppResult<(StatusCode, Json)> { let Did(audience_needle) = authority - .get_capability(&state, IndexingAbility::Fetch) + .get_capability( + &state, + fission_core::caps::FissionAbility::CapabilityFetch(CmdCapabilityFetch), + ) .await?; let conn = &mut db::connect(&state.db_pool).await?; @@ -48,33 +48,47 @@ pub async fn get_capabilities( #[cfg(test)] mod tests { + use std::str::FromStr; + use super::*; use crate::{ - db::Conn, error::ErrorResponse, models::capability_indexing::index_ucan, + db::Conn, + error::ErrorResponse, + models::capability_indexing::{index_ucan, ucan_base64}, test_utils::test_context::TestContext, }; use anyhow::Result; use assert_matches::assert_matches; - use fission_core::ed_did_key::EdDidKey; + use fission_core::{ + capabilities::indexing::IndexingAbility, ed_did_key::EdDidKey, test_utils::varsig_header, + }; use http::Method; use rs_ucan::{ - builder::UcanBuilder, - capability::Capability, - semantics::{ability::TopAbility, caveat::EmptyCaveat}, - ucan::Ucan, + builder::UcanBuilder, capability::Capability, semantics::caveat::EmptyCaveat, ucan::Ucan, }; use testresult::TestResult; + use ucan::{crypto::signature::Envelope, delegation, did::preset::Verifier, Delegation}; async fn index_test_ucan( issuer: &EdDidKey, audience: &EdDidKey, resource_did: String, conn: &mut Conn<'_>, - ) -> Result { - let ucan: Ucan = UcanBuilder::default() - .for_audience(audience) - .claiming_capability(Capability::new(Did(resource_did), TopAbility, EmptyCaveat)) - .sign(issuer)?; + ) -> Result { + let (issuer, issuer_did) = issuer.to_ucan_interop(); + let (_audience, audience_did) = audience.to_ucan_interop(); + let resource_did = Verifier::from_str(&resource_did)?; + + let ucan = Delegation::try_sign( + &issuer, + varsig_header(), + delegation::PayloadBuilder::default() + .issuer(issuer_did) + .audience(audience_did) + .subject(Some(resource_did)) + .command("/".to_string()) + .build()?, + )?; index_ucan(&ucan, conn).await?; @@ -117,7 +131,7 @@ mod tests { assert_eq!(status, StatusCode::OK); let ucans = response.ucans.values().into_iter().collect::>(); - assert_matches!(&ucans[..], [one_ucan] if one_ucan.encode().unwrap() == ucan.encode().unwrap()); + assert_matches!(&ucans[..], [one_ucan] if *one_ucan == &ucan_base64(&ucan).unwrap()); Ok(()) } @@ -130,12 +144,7 @@ mod tests { let device = &EdDidKey::generate(); let server = ctx.server_did(); - let ucan: Ucan = UcanBuilder::default() - .for_audience(device) - .claiming_capability(Capability::new(Did(server.did()), TopAbility, EmptyCaveat)) - .sign(server)?; - - index_ucan(&ucan, conn).await?; + index_test_ucan(server, device, server.did(), conn).await?; let (status, _) = ctx .request(Method::GET, "/api/v0/capabilities") @@ -177,8 +186,8 @@ mod tests { .into_values() .into_iter() .collect::>(); - assert_matches!(&ucans[..], [u] if u.encode().unwrap() == ucan.encode().unwrap()); - assert_matches!(&ucans_other[..], [u] if u.encode().unwrap() == ucan_other.encode().unwrap()); + assert_matches!(&ucans[..], [u] if u == &ucan_base64(&ucan)?); + assert_matches!(&ucans_other[..], [u] if u == &ucan_base64(&ucan_other)?); Ok(()) } @@ -204,16 +213,11 @@ mod tests { // In the future, when the `prf` field is removed, this will make // a lot more sense. - let ucans = response - .ucans - .into_values() - .into_iter() - .map(|ucan| ucan.encode()) - .collect::, _>>()?; + let ucans = response.ucans.into_values().collect::>(); assert_eq!(ucans.len(), 2); - assert!(ucans.contains(&ucan_one.encode()?)); - assert!(ucans.contains(&ucan_two.encode()?)); + assert!(ucans.contains(&ucan_base64(&ucan_one)?)); + assert!(ucans.contains(&ucan_base64(&ucan_two)?)); Ok(()) } diff --git a/fission-server/src/routes/volume.rs b/fission-server/src/routes/volume.rs index 32d24fd8..41ecfd57 100644 --- a/fission-server/src/routes/volume.rs +++ b/fission-server/src/routes/volume.rs @@ -18,7 +18,10 @@ use bytes::Bytes; use car_mirror::messages::{PullRequest, PushResponse}; use cid::Cid; use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection}; -use fission_core::capabilities::{did::Did, fission::FissionAbility}; +use fission_core::{ + capabilities::did::Did, + caps::{CmdAccountManage, FissionAbility}, +}; use futures_util::{Stream, TryStreamExt}; use headers::ContentLength; use http::StatusCode; @@ -53,7 +56,7 @@ pub async fn push_volume_cid( tracing::info!(content_length, "Parsed content length hint"); let Did(did) = authority - .get_capability(&state, FissionAbility::AccountManage) + .get_capability(&state, FissionAbility::AccountManage(CmdAccountManage)) .await?; let conn = &mut db::connect(&state.db_pool).await?;