diff --git a/.github/workflows/crypto.yml b/.github/workflows/crypto.yml new file mode 100644 index 00000000..41fc8a52 --- /dev/null +++ b/.github/workflows/crypto.yml @@ -0,0 +1,22 @@ +name: Crypto + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: | + cargo build -p atrium-crypto --verbose + - name: Run tests + run: | + cargo test -p atrium-crypto --lib diff --git a/.github/workflows/libs.yml b/.github/workflows/libs.yml new file mode 100644 index 00000000..c95e5f3d --- /dev/null +++ b/.github/workflows/libs.yml @@ -0,0 +1,27 @@ +name: Libs + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: | + cargo build -p atrium-libs --verbose + cargo build -p atrium-libs --verbose --features atproto-data + cargo build -p atrium-libs --verbose --features common-web + cargo build -p atrium-libs --verbose --features identity + cargo build -p atrium-libs --verbose --all-features + - name: Run tests + run: | + cargo test -p atrium-libs --lib + cargo test -p atrium-libs --lib --all-features diff --git a/Cargo.lock b/Cargo.lock index f69395b3..c2639c01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,9 +118,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.77" +version = "0.1.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", @@ -164,6 +164,38 @@ dependencies = [ "tokio", ] +[[package]] +name = "atrium-crypto" +version = "0.1.0" +dependencies = [ + "ecdsa", + "hex", + "k256", + "multibase", + "p256", + "rand", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "atrium-libs" +version = "0.1.0" +dependencies = [ + "async-trait", + "atrium-crypto", + "http 1.1.0", + "mockito", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "url", + "urlencoding", +] + [[package]] name = "atrium-xrpc" version = "0.10.5" @@ -221,12 +253,24 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -239,6 +283,15 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -383,6 +436,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -408,12 +467,43 @@ dependencies = [ "memchr", ] +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "curl" version = "0.4.46" @@ -471,6 +561,28 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + [[package]] name = "dirs" version = "5.0.1" @@ -492,6 +604,39 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.33" @@ -538,6 +683,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fnv" version = "1.0.7" @@ -644,6 +799,17 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + [[package]] name = "getrandom" version = "0.2.12" @@ -661,6 +827,17 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -698,6 +875,21 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "379dada1584ad501b383485dd706b8afb7a70fcbc7f4da7d780638a5a6124a60" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -983,6 +1175,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ff9b67e26e1a6a866cb758f12c6f8746208489e3e4a4b5580802f2f0a587b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "langtag" version = "0.3.4" @@ -1234,6 +1438,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.0" @@ -1301,6 +1517,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -1329,6 +1555,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.78" @@ -1470,6 +1705,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.8" @@ -1571,6 +1816,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -1596,9 +1855,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.197" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a" dependencies = [ "serde_derive", ] @@ -1614,9 +1873,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc" dependencies = [ "proc-macro2", "quote", @@ -1671,6 +1930,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1680,6 +1950,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "similar" version = "2.4.0" @@ -1728,6 +2008,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "strsim" version = "0.10.0" @@ -1956,6 +2246,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2006,6 +2302,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8parse" version = "0.2.1" @@ -2018,6 +2320,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "waker-fn" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 18b9c0d9..5ad538c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ members = [ "atrium-api", "atrium-cli", + "atrium-crypto", + "atrium-libs", "atrium-xrpc", "atrium-xrpc-client", ] @@ -22,12 +24,13 @@ keywords = ["atproto", "bluesky"] [workspace.dependencies] # Intra-workspace dependencies atrium-api = { version = "0.21.0", path = "atrium-api" } +atrium-crypto = { version = "0.1.0", path = "atrium-crypto" } atrium-xrpc = { version = "0.10.5", path = "atrium-xrpc" } atrium-xrpc-client = { version = "0.5.2", path = "atrium-xrpc-client" } # async in traits # Can be removed once MSRV is at least 1.75.0. -async-trait = "0.1.68" +async-trait = "0.1.80" # DAG-CBOR codec ipld-core = { version = "0.4.0", default-features = false, features = ["std"] } @@ -35,17 +38,27 @@ serde_ipld_dagcbor = { version = "0.6.0", default-features = false, features = [ # Parsing and validation chrono = "0.4" +hex = "0.4.3" langtag = "0.3" +multibase = "0.9.1" regex = "1" -serde = "1.0.160" +serde = "1.0.199" serde_bytes = "0.11.9" serde_json = "1.0.96" serde_html_form = "0.2.6" +urlencoding = "2.1.3" + +# Cryptography +ecdsa = "0.16.9" +k256 = { version = "0.13.3", default-features = false } +p256 = { version = "0.13.2", default-features = false } +rand = "0.8.5" # Networking futures = { version = "0.3.30", default-features = false, features = ["alloc"] } http = "1.1.0" tokio = { version = "1.36", default-features = false } +url = "2.5.0" # HTTP client integrations isahc = "1.7.2" diff --git a/atrium-crypto/Cargo.toml b/atrium-crypto/Cargo.toml new file mode 100644 index 00000000..99ac73ff --- /dev/null +++ b/atrium-crypto/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "atrium-crypto" +version = "0.1.0" +authors = ["sugyan "] +edition.workspace = true +rust-version.workspace = true +description = "Cryptographic library providing basic helpers for AT Protocol" +readme = "README.md" +repository.workspace = true +license.workspace = true +keywords.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +ecdsa = { workspace = true, features = ["std", "signing", "verifying"] } +k256 = { workspace = true, features = ["ecdsa"] } +p256 = { workspace = true, features = ["ecdsa"] } +multibase.workspace = true +thiserror.workspace = true + +[dev-dependencies] +hex.workspace = true +rand.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true diff --git a/atrium-crypto/README.md b/atrium-crypto/README.md new file mode 100644 index 00000000..37548d5c --- /dev/null +++ b/atrium-crypto/README.md @@ -0,0 +1,40 @@ +# ATrium Crypto + +Cryptographic library providing basic helpers for AT Protocol. + +This package implements the two currently supported cryptographic systems: + +- [`p256`](https://crates.io/crates/p256) elliptic curve: aka "NIST P-256", aka `secp256r1` (note the `r`), aka `prime256v1` +- [`k256`](https://crates.io/crates/k256) elliptic curve: aka "NIST K-256", aka `secp256k1` (note the `k`) + +The details of cryptography in atproto are described in [the specification](https://atproto.com/specs/cryptography). This includes string encodings, validity of "low-S" signatures, byte representation "compression", hashing, and more. + +## Usage + +```rust +use atrium_crypto::keypair::{Secp256k1Keypair, Did}; +use atrium_crypto::verify::verify_signature; +use rand::rngs::ThreadRng; + +fn main() -> Result<(), Box>{ + // generate a new random K-256 private key + let keypair = Secp256k1Keypair::create(&mut ThreadRng::default()); + + // sign binary data, resulting signature bytes. + // SHA-256 hash of data is what actually gets signed. + let msg = [1, 2, 3, 4, 5, 6, 7, 8]; + let signature = keypair.sign(&msg)?; + + // serialize the public key as a did:key string, which includes key type metadata + let pub_did_key = keypair.did(); + println!("{pub_did_key}"); + // output would look something like: 'did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38' + + // verify signature using public key + match verify_signature(&pub_did_key, &msg, &signature) { + Ok(()) => println!("Success"), + Err(_) => panic!("Uh oh, something is fishy"), + } + Ok(()) +} +``` diff --git a/atrium-crypto/src/algorithm.rs b/atrium-crypto/src/algorithm.rs new file mode 100644 index 00000000..d65f4898 --- /dev/null +++ b/atrium-crypto/src/algorithm.rs @@ -0,0 +1,35 @@ +use multibase::Base; + +/// Supported algorithms (elliptic curves) for atproto cryptography. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Algorithm { + /// [`p256`] elliptic curve: aka "NIST P-256", aka `secp256r1` (note the `r`), aka `prime256v1`. + P256, + /// [`k256`] elliptic curve: aka "NIST K-256", aka `secp256k1` (note the `k`). + Secp256k1, +} + +impl Algorithm { + const MULTICODE_PREFIX_P256: [u8; 2] = [0x80, 0x24]; + const MULTICODE_PREFIX_SECP256K1: [u8; 2] = [0xe7, 0x01]; + + pub(crate) fn prefix(&self) -> [u8; 2] { + match self { + Self::P256 => Self::MULTICODE_PREFIX_P256, + Self::Secp256k1 => Self::MULTICODE_PREFIX_SECP256K1, + } + } + pub(crate) fn from_prefix(prefix: [u8; 2]) -> Option { + match prefix { + Self::MULTICODE_PREFIX_P256 => Some(Self::P256), + Self::MULTICODE_PREFIX_SECP256K1 => Some(Self::Secp256k1), + _ => None, + } + } + pub(crate) fn format_mulikey_compressed(&self, key: &[u8]) -> String { + let mut v = Vec::with_capacity(2 + key.len()); + v.extend_from_slice(&self.prefix()); + v.extend_from_slice(key); + multibase::encode(Base::Base58Btc, v) + } +} diff --git a/atrium-crypto/src/did.rs b/atrium-crypto/src/did.rs new file mode 100644 index 00000000..071a8602 --- /dev/null +++ b/atrium-crypto/src/did.rs @@ -0,0 +1,188 @@ +//! Functions for parsing and formatting DID keys. +use crate::encoding::{compress_pubkey, decompress_pubkey}; +use crate::error::{Error, Result}; +use crate::{Algorithm, DID_KEY_PREFIX}; + +/// Format a public key as a DID key string. +/// +/// The public key will be compressed and encoded with multibase and multicode. +/// The resulting string will start with `did:key:`. +/// +/// Details: +/// [https://atproto.com/specs/cryptography#public-key-encoding](https://atproto.com/specs/cryptography#public-key-encoding) +/// +/// # Examples +/// +/// ``` +/// use atrium_crypto::Algorithm; +/// use atrium_crypto::did::format_did_key; +/// +/// # fn main() -> atrium_crypto::Result<()> { +/// let signing_key = ecdsa::SigningKey::::from_slice( +/// &hex::decode("9085d2bef69286a6cbb51623c8fa258629945cd55ca705cc4e66700396894e0c").unwrap() +/// )?; +/// let public_key = signing_key.verifying_key(); +/// let did_key = format_did_key(Algorithm::Secp256k1, &public_key.to_sec1_bytes())?; +/// assert_eq!(did_key, "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme"); +/// # Ok(()) +/// # } +/// ``` +pub fn format_did_key(alg: Algorithm, key: &[u8]) -> Result { + Ok(prefix_did_key( + &alg.format_mulikey_compressed(&compress_pubkey(alg, key)?), + )) +} + +/// Parse a DID key string. +/// +/// Input should be a string starting with `did:key:`. +/// The rest of the string is the multibase and multicode encoded public key, +/// which will be parsed with [`parse_multikey`]. +/// +/// Returns the parsed [`Algorithm`] and bytes of the public key. +/// +/// # Examples +/// +/// ``` +/// use atrium_crypto::Algorithm; +/// use atrium_crypto::did::parse_did_key; +/// +/// # fn main() -> atrium_crypto::Result<()> { +/// let (alg, key): (Algorithm, Vec) = parse_did_key("did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme")?; +/// assert_eq!(alg, Algorithm::Secp256k1); +/// assert_eq!(key.len(), 65); +/// # Ok(()) +/// # } +/// ``` +pub fn parse_did_key(did: &str) -> Result<(Algorithm, Vec)> { + if let Some(multikey) = did.strip_prefix(DID_KEY_PREFIX) { + parse_multikey(multikey) + } else { + Err(Error::IncorrectDIDKeyPrefix(did.to_string())) + } +} + +/// Parse a multibase and multicode encoded public key string. +/// +/// Details: +/// [https://atproto.com/specs/cryptography#public-key-encoding](https://atproto.com/specs/cryptography#public-key-encoding) +/// +/// Returns the parsed [`Algorithm`] and bytes of the public key. +/// +/// # Examples +/// +/// ``` +/// use atrium_crypto::Algorithm; +/// use atrium_crypto::did::parse_multikey; +/// +/// # fn main() -> atrium_crypto::Result<()> { +/// let (alg, key): (Algorithm, Vec) = parse_multikey("zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme")?; +/// assert_eq!(alg, Algorithm::Secp256k1); +/// assert_eq!(key.len(), 65); +/// # Ok(()) +/// # } +/// ``` +pub fn parse_multikey(multikey: &str) -> Result<(Algorithm, Vec)> { + let (_, decoded) = multibase::decode(multikey)?; + if let Ok(prefix) = decoded[..2].try_into() { + if let Some(alg) = Algorithm::from_prefix(prefix) { + return Ok((alg, decompress_pubkey(alg, &decoded[2..])?)); + } + } + Err(Error::UnsupportedMultikeyType) +} + +pub(crate) fn prefix_did_key(multikey: &str) -> String { + let mut ret = String::with_capacity(DID_KEY_PREFIX.len() + multikey.len()); + ret.push_str(DID_KEY_PREFIX); + ret.push_str(multikey); + ret +} + +#[cfg(test)] +mod tests { + use super::*; + use ecdsa::SigningKey; + use k256::Secp256k1; + use multibase::Base; + use p256::NistP256; + + // did:key secp256k1 test vectors from W3C + // https://github.com/w3c-ccg/did-method-key/blob/main/test-vectors/secp256k1.json + fn secp256k1_vectors() -> Vec<(&'static str, &'static str)> { + vec![ + ( + "9085d2bef69286a6cbb51623c8fa258629945cd55ca705cc4e66700396894e0c", + "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme", + ), + ( + "f0f4df55a2b3ff13051ea814a8f24ad00f2e469af73c363ac7e9fb999a9072ed", + "did:key:zQ3shtxV1FrJfhqE1dvxYRcCknWNjHc3c5X1y3ZSoPDi2aur2", + ), + ( + "6b0b91287ae3348f8c2f2552d766f30e3604867e34adc37ccbb74a8e6b893e02", + "did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N", + ), + ( + "c0a6a7c560d37d7ba81ecee9543721ff48fea3e0fb827d42c1868226540fac15", + "did:key:zQ3shadCps5JLAHcZiuX5YUtWHHL8ysBJqFLWvjZDKAWUBGzy", + ), + ( + "175a232d440be1e0788f25488a73d9416c04b6f924bea6354bf05dd2f1a75133", + "did:key:zQ3shptjE6JwdkeKN4fcpnYQY3m9Cet3NiHdAfpvSUZBFoKBj", + ), + ] + } + + // did:key p-256 test vectors from W3C + // https://github.com/w3c-ccg/did-method-key/blob/main/test-vectors/nist-curves.json + fn p256_vectors() -> Vec<(&'static str, &'static str)> { + vec![( + "9p4VRzdmhsnq869vQjVCTrRry7u4TtfRxhvBFJTGU2Cp", + "did:key:zDnaeTiq1PdzvZXUaMdezchcMJQpBdH2VN4pgrrEhMCCbmwSb", + )] + } + + #[test] + fn secp256k1() { + for (seed, id) in secp256k1_vectors() { + let bytes = hex::decode(seed).expect("hex decoding should succeed"); + let sig_key = SigningKey::::from_slice(&bytes) + .expect("initializing signing key should succeed"); + let did_key = format_did_key( + Algorithm::Secp256k1, + &sig_key.verifying_key().to_sec1_bytes(), + ) + .expect("formatting DID key should succeed"); + assert_eq!(did_key, id); + + let (alg, key) = parse_did_key(&did_key).expect("parsing DID key should succeed"); + assert_eq!(alg, Algorithm::Secp256k1); + assert_eq!( + &key, + sig_key.verifying_key().to_encoded_point(false).as_bytes() + ); + } + } + + #[test] + fn p256() { + for (private_key_base58, id) in p256_vectors() { + let bytes = Base::Base58Btc + .decode(private_key_base58) + .expect("multibase decoding should succeed"); + let sig_key = SigningKey::::from_slice(&bytes) + .expect("initializing signing key should succeed"); + let did_key = format_did_key(Algorithm::P256, &sig_key.verifying_key().to_sec1_bytes()) + .expect("formatting DID key should succeed"); + assert_eq!(did_key, id); + + let (alg, key) = parse_did_key(&did_key).expect("parsing DID key should succeed"); + assert_eq!(alg, Algorithm::P256); + assert_eq!( + &key, + sig_key.verifying_key().to_encoded_point(false).as_bytes() + ); + } + } +} diff --git a/atrium-crypto/src/encoding.rs b/atrium-crypto/src/encoding.rs new file mode 100644 index 00000000..50a43c96 --- /dev/null +++ b/atrium-crypto/src/encoding.rs @@ -0,0 +1,100 @@ +use crate::{error::Result, Algorithm}; +use ecdsa::VerifyingKey; +use k256::Secp256k1; +use p256::NistP256; + +pub(crate) fn compress_pubkey(alg: Algorithm, key: &[u8]) -> Result> { + pubkey_bytes(alg, key, true) +} + +pub(crate) fn decompress_pubkey(alg: Algorithm, key: &[u8]) -> Result> { + pubkey_bytes(alg, key, false) +} + +fn pubkey_bytes(alg: Algorithm, key: &[u8], compress: bool) -> Result> { + Ok(match alg { + Algorithm::P256 => VerifyingKey::::from_sec1_bytes(key)? + .to_encoded_point(compress) + .as_bytes() + .to_vec(), + Algorithm::Secp256k1 => VerifyingKey::::from_sec1_bytes(key)? + .to_encoded_point(compress) + .as_bytes() + .to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::{compress_pubkey, decompress_pubkey}; + use crate::did::parse_did_key; + use crate::keypair::{Did, P256Keypair, Secp256k1Keypair}; + use crate::Algorithm; + use rand::rngs::ThreadRng; + + #[test] + fn p256_compress_decompress() { + let did = P256Keypair::create(&mut ThreadRng::default()).did(); + let (alg, key) = parse_did_key(&did).expect("parsing did key should succeed"); + assert_eq!(alg, Algorithm::P256); + // compress a key to the correct length + let compressed = compress_pubkey(alg, &key).expect("compressing public key should succeed"); + assert_eq!(compressed.len(), 33); + // decompress a key to the original + let decompressed = + decompress_pubkey(alg, &compressed).expect("decompressing public key should succeed"); + assert_eq!(decompressed.len(), 65); + assert_eq!(key, decompressed); + + // works consitesntly + let keys = (0..100) + .map(|_| { + let did = P256Keypair::create(&mut ThreadRng::default()).did(); + let (_, key) = parse_did_key(&did).expect("parsing did key should succeed"); + key + }) + .collect::>(); + let compressed = keys + .iter() + .filter_map(|key| compress_pubkey(alg, &key).ok()) + .collect::>(); + let decompressed = compressed + .iter() + .filter_map(|key| decompress_pubkey(alg, &key).ok()) + .collect::>(); + assert_eq!(keys, decompressed); + } + + #[test] + fn secp256k1_compress_decompress() { + let did = Secp256k1Keypair::create(&mut ThreadRng::default()).did(); + let (alg, key) = parse_did_key(&did).expect("parsing did key should succeed"); + assert_eq!(alg, Algorithm::Secp256k1); + // compress a key to the correct length + let compressed = compress_pubkey(alg, &key).expect("compressing public key should succeed"); + assert_eq!(compressed.len(), 33); + // decompress a key to the original + let decompressed = + decompress_pubkey(alg, &compressed).expect("decompressing public key should succeed"); + assert_eq!(decompressed.len(), 65); + assert_eq!(key, decompressed); + + // works consitesntly + let keys = (0..100) + .map(|_| { + let did = Secp256k1Keypair::create(&mut ThreadRng::default()).did(); + let (_, key) = parse_did_key(&did).expect("parsing did key should succeed"); + key + }) + .collect::>(); + let compressed = keys + .iter() + .filter_map(|key| compress_pubkey(alg, key).ok()) + .collect::>(); + let decompressed = compressed + .iter() + .filter_map(|key| decompress_pubkey(alg, key).ok()) + .collect::>(); + assert_eq!(keys, decompressed); + } +} diff --git a/atrium-crypto/src/error.rs b/atrium-crypto/src/error.rs new file mode 100644 index 00000000..f2cb74c0 --- /dev/null +++ b/atrium-crypto/src/error.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +/// Error types. +#[derive(Error, Debug)] +pub enum Error { + /// Unsupported multikey type. + #[error("Unsupported key type")] + UnsupportedMultikeyType, + /// Incorrect prefix for DID key. + #[error("Incorrect prefix for did:key: {0}")] + IncorrectDIDKeyPrefix(String), + /// Low-S signature is not allowed. + #[error("Low-S signature is not allowed")] + LowSSignatureNotAllowed, + /// Signature is invalid. + #[error("Signature is invalid")] + InvalidSignature, + /// Error in [`multibase`] encoding or decoding. + #[error(transparent)] + Multibase(#[from] multibase::Error), + /// Error in [`ecdsa::signature`]. + #[error(transparent)] + Signature(#[from] ecdsa::signature::Error), +} + +/// Type alias to use this library's [`Error`](crate::Error) type in a [`Result`](core::result::Result). +pub type Result = std::result::Result; diff --git a/atrium-crypto/src/keypair.rs b/atrium-crypto/src/keypair.rs new file mode 100644 index 00000000..a3ce229a --- /dev/null +++ b/atrium-crypto/src/keypair.rs @@ -0,0 +1,299 @@ +//! Keypair structs for signing, and utility trait implementations. +use crate::did::prefix_did_key; +use crate::error::Result; +use crate::Algorithm; +use ecdsa::elliptic_curve::{ + generic_array::ArrayLength, + ops::Invert, + sec1::{FromEncodedPoint, ModulusSize, ToEncodedPoint}, + subtle::CtOption, + AffinePoint, CurveArithmetic, FieldBytesSize, PrimeCurve, Scalar, +}; +use ecdsa::hazmat::{DigestPrimitive, SignPrimitive}; +use ecdsa::signature::{rand_core::CryptoRngCore, Signer}; +use ecdsa::{Signature, SignatureSize, SigningKey}; +use k256::Secp256k1; +use p256::NistP256; + +/// A keypair for signing messages. +pub struct Keypair +where + C: PrimeCurve + CurveArithmetic, + Scalar: Invert>> + SignPrimitive, + SignatureSize: ArrayLength, +{ + signing_key: SigningKey, +} + +impl Keypair +where + C: PrimeCurve + CurveArithmetic, + Scalar: Invert>> + SignPrimitive, + SignatureSize: ArrayLength, +{ + /// Generate a cryptographically random [`SigningKey`]. + /// + /// ``` + /// use atrium_crypto::keypair::Keypair; + /// + /// let keypair = Keypair::::create(&mut rand::thread_rng()); + /// ``` + pub fn create(rng: &mut impl CryptoRngCore) -> Self { + Self { + signing_key: SigningKey::::random(rng), + } + } + /// Initialize signing key from a raw scalar serialized as a byte slice. + pub fn import(bytes: &[u8]) -> Result { + Ok(Self { + signing_key: SigningKey::from_slice(bytes)?, + }) + } +} + +impl Keypair +where + C: PrimeCurve + CurveArithmetic, + Scalar: Invert>> + SignPrimitive, + SignatureSize: ArrayLength, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + fn compressed_public_key(&self) -> Box<[u8]> { + self.signing_key + .verifying_key() + .to_encoded_point(true) + .to_bytes() + } +} + +impl Keypair +where + C: PrimeCurve + CurveArithmetic + DigestPrimitive, + Scalar: Invert>> + SignPrimitive, + SignatureSize: ArrayLength, +{ + /// Sign a message with the keypair. + /// + /// Returns the signature as a byte vector of the "low-S" form. + /// + /// Details: + /// [https://atproto.com/specs/cryptography#ecdsa-signature-malleability](https://atproto.com/specs/cryptography#ecdsa-signature-malleability) + pub fn sign(&self, msg: &[u8]) -> Result> { + let signature: Signature<_> = self.signing_key.try_sign(msg)?; + Ok(signature + .normalize_s() + .unwrap_or(signature) + .to_bytes() + .to_vec()) + } +} + +/// Generate a DID key string from a keypair. +pub trait Did { + fn did(&self) -> String; +} + +/// Export a keypair as a byte vector. +pub trait Export { + fn export(&self) -> Vec; +} + +impl Export for Keypair +where + C: PrimeCurve + CurveArithmetic, + Scalar: Invert>> + SignPrimitive, + SignatureSize: ArrayLength, +{ + fn export(&self) -> Vec { + self.signing_key.to_bytes().to_vec() + } +} + +/// Type alias for a P-256 keypair. +pub type P256Keypair = Keypair; + +impl Did for P256Keypair { + fn did(&self) -> String { + prefix_did_key(&Algorithm::P256.format_mulikey_compressed(&self.compressed_public_key())) + } +} + +/// Type alias for a secp256k1 keypair. +pub type Secp256k1Keypair = Keypair; + +impl Did for Secp256k1Keypair { + fn did(&self) -> String { + prefix_did_key( + &Algorithm::Secp256k1.format_mulikey_compressed(&self.compressed_public_key()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{P256Keypair, Secp256k1Keypair}; + use crate::did::{format_did_key, parse_did_key}; + use crate::verify::Verifier; + use crate::Algorithm; + use rand::rngs::ThreadRng; + + #[test] + fn p256_did() { + let keypair = P256Keypair::create(&mut ThreadRng::default()); + let did = { + use super::Did; + keypair.did() + }; + let formatted = format_did_key( + Algorithm::P256, + &keypair.signing_key.verifying_key().to_sec1_bytes(), + ) + .expect("formatting to did key should succeed"); + assert_eq!(did, formatted); + + let (alg, public_key) = parse_did_key(&did).expect("parsing did key should succeed"); + assert_eq!(alg, Algorithm::P256); + assert_eq!( + public_key, + keypair + .signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + ); + } + + #[test] + fn secp256k1_did() { + let keypair = Secp256k1Keypair::create(&mut ThreadRng::default()); + let did = { + use super::Did; + keypair.did() + }; + let formatted = format_did_key( + Algorithm::Secp256k1, + &keypair.signing_key.verifying_key().to_sec1_bytes(), + ) + .expect("formatting to did key should succeed"); + assert_eq!(did, formatted); + + let (alg, public_key) = parse_did_key(&did).expect("parsing did key should succeed"); + assert_eq!(alg, Algorithm::Secp256k1); + assert_eq!( + public_key, + keypair + .signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + ); + } + + #[test] + fn p256_export() { + let keypair = P256Keypair::create(&mut ThreadRng::default()); + let exported = { + use super::Export; + keypair.export() + }; + let imported = P256Keypair::import(&exported).expect("importing keypair should succeed"); + { + use super::Did; + assert_eq!(keypair.did(), imported.did()); + } + } + + #[test] + fn secp256k1_export() { + let keypair = Secp256k1Keypair::create(&mut ThreadRng::default()); + let exported = { + use super::Export; + keypair.export() + }; + let imported = + Secp256k1Keypair::import(&exported).expect("importing keypair should succeed"); + { + use super::Did; + assert_eq!(keypair.did(), imported.did()); + } + } + + #[test] + fn p256_verify() { + let keypair = P256Keypair::create(&mut ThreadRng::default()); + let did = { + use super::Did; + keypair.did() + }; + let (alg, public_key) = parse_did_key(&did).expect("parsing did key should succeed"); + assert_eq!(alg, Algorithm::P256); + + let verifier = Verifier::default(); + let msg = [1, 2, 3, 4, 5, 6, 7, 8]; + let signature = keypair.sign(&msg).expect("signing should succeed"); + let mut corrupted_signature = signature.clone(); + corrupted_signature[0] = corrupted_signature[0].wrapping_add(1); + assert!( + verifier.verify(alg, &public_key, &msg, &signature).is_ok(), + "verifying signature should succeed" + ); + assert!( + verifier + .verify(alg, &public_key, &msg[..7], &signature) + .is_err(), + "verifying signature should fail with incorrect message" + ); + assert!( + verifier + .verify(alg, &public_key, &msg, &corrupted_signature) + .is_err(), + "verifying signature should fail with incorrect signature" + ); + assert!( + verifier + .verify(Algorithm::Secp256k1, &public_key, &msg, &signature) + .is_err(), + "verifying signature should fail with incorrect algorithm" + ); + } + + #[test] + fn secp256k1_verify() { + let keypair = Secp256k1Keypair::create(&mut ThreadRng::default()); + let did = { + use super::Did; + keypair.did() + }; + let (alg, public_key) = parse_did_key(&did).expect("parsing did key should succeed"); + assert_eq!(alg, Algorithm::Secp256k1); + + let verifier = Verifier::default(); + let msg = [1, 2, 3, 4, 5, 6, 7, 8]; + let signature = keypair.sign(&msg).expect("signing should succeed"); + let mut corrupted_signature = signature.clone(); + corrupted_signature[0] = corrupted_signature[0].wrapping_add(1); + assert!( + verifier.verify(alg, &public_key, &msg, &signature).is_ok(), + "verifying signature should succeed" + ); + assert!( + verifier + .verify(alg, &public_key, &msg[..7], &signature) + .is_err(), + "verifying signature should fail with incorrect message" + ); + assert!( + verifier + .verify(alg, &public_key, &msg, &corrupted_signature) + .is_err(), + "verifying signature should fail with incorrect signature" + ); + assert!( + verifier + .verify(Algorithm::P256, &public_key, &msg, &signature) + .is_err(), + "verifying signature should fail with incorrect algorithm" + ); + } +} diff --git a/atrium-crypto/src/lib.rs b/atrium-crypto/src/lib.rs new file mode 100644 index 00000000..dd1bcfa5 --- /dev/null +++ b/atrium-crypto/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!("../README.md")] +mod algorithm; +pub mod did; +mod encoding; +mod error; +pub mod keypair; +pub mod verify; + +pub use crate::algorithm::Algorithm; +pub use crate::error::{Error, Result}; +pub use multibase; + +const DID_KEY_PREFIX: &str = "did:key:"; diff --git a/atrium-crypto/src/verify.rs b/atrium-crypto/src/verify.rs new file mode 100644 index 00000000..34dcf318 --- /dev/null +++ b/atrium-crypto/src/verify.rs @@ -0,0 +1,258 @@ +//! Verifies a signature for a message using a public key. +use crate::did::parse_did_key; +use crate::error::{Error, Result}; +use crate::Algorithm; +use ecdsa::der::{MaxOverhead, MaxSize}; +use ecdsa::elliptic_curve::{ + generic_array::ArrayLength, + sec1::{FromEncodedPoint, ModulusSize, ToEncodedPoint}, + AffinePoint, CurveArithmetic, FieldBytesSize, PrimeCurve, +}; +use ecdsa::hazmat::{DigestPrimitive, VerifyPrimitive}; +use ecdsa::{SignatureSize, VerifyingKey}; +use k256::Secp256k1; +use p256::NistP256; +use std::ops::Add; + +/// Verify a signature for a message using the given DID key formatted public key. +/// +/// This function verifies a signature using [`Verifier::default()`]. +/// +/// # Examples +/// +/// ``` +/// use atrium_crypto::verify::verify_signature; +/// +/// # fn main() -> atrium_crypto::Result<()> { +/// let did_key = "did:key:zQ3shtNTBUUCARYFEkRPZQ9NCaM5i5hVHPeEsEKXpmVkR2Upq"; +/// let signature = hex::decode( +/// "fdaa28ab03d6767c11d71fa39627c770ff62f91ca9661401ca0e2c475ae96a8c27064fbde3c355fa8121d2e8bbcf87a2de308e1d72b9bf4270f1e7cd8a1575ab" +/// ).unwrap(); +/// assert!(verify_signature(did_key, b"Hello, world!", &signature).is_ok()); +/// assert!(verify_signature(did_key, b"Hello, world?", &signature).is_err()); +/// # Ok(()) +/// # } +/// ``` +pub fn verify_signature(did_key: &str, msg: &[u8], signature: &[u8]) -> Result<()> { + let (alg, public_key) = parse_did_key(did_key)?; + Verifier::default().verify(alg, &public_key, msg, signature) +} + +/// Verifier for verifying signatures for a message using a public key. +/// +/// This verifier can be configured to `allow_malleable` mode, which allows +/// verifying signatures with "high-S" or DER-encoded ones. +/// By default, this verifier allows only "low-S" signatures. +/// +/// See also: [https://github.com/bluesky-social/atproto/pull/1839](https://github.com/bluesky-social/atproto/pull/1839) +#[derive(Debug, Default)] +pub struct Verifier { + allow_malleable: bool, +} + +impl Verifier { + /// Create a new verifier with the given malleable mode. + pub fn new(allow_malleable: bool) -> Self { + Self { allow_malleable } + } + /// Verify a signature for a message using the given public key. + /// The `algorithm` is used to determine the curve for the public key. + pub fn verify( + &self, + algorithm: Algorithm, + public_key: &[u8], + msg: &[u8], + signature: &[u8], + ) -> Result<()> { + match algorithm { + Algorithm::P256 => self.verify_inner::(public_key, msg, signature), + Algorithm::Secp256k1 => self.verify_inner::(public_key, msg, signature), + } + } + /// Verify a signature for a message using the given public key. + /// Any elliptic curve of the generics implementation of [`ECDSA`](ecdsa) can be used for parameter `C`. + pub fn verify_inner(&self, public_key: &[u8], msg: &[u8], bytes: &[u8]) -> Result<()> + where + C: PrimeCurve + CurveArithmetic + DigestPrimitive, + AffinePoint: VerifyPrimitive + FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, + SignatureSize: ArrayLength, + MaxSize: ArrayLength, + as Add>::Output: Add + ArrayLength, + { + let verifying_key = VerifyingKey::::from_sec1_bytes(public_key)?; + if let Ok(mut signature) = ecdsa::Signature::from_slice(bytes) { + if let Some(normalized) = signature.normalize_s() { + if !self.allow_malleable { + return Err(Error::LowSSignatureNotAllowed); + } + signature = normalized + } + Ok(ecdsa::signature::Verifier::verify( + &verifying_key, + msg, + &signature, + )?) + } else if self.allow_malleable { + let signature = ecdsa::der::Signature::from_bytes(bytes)?; + Ok(ecdsa::signature::Verifier::verify( + &verifying_key, + msg, + &signature, + )?) + } else { + Err(Error::InvalidSignature) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use multibase::Base; + use serde::{Deserialize, Serialize}; + use std::{fs::File, path::PathBuf}; + + #[derive(Debug, Serialize, Deserialize)] + enum Algorithm { + ES256, + ES256K, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct TestVector { + comment: String, + message_base64: String, + algorithm: Algorithm, + public_key_multibase: String, + public_key_did: String, + signature_base64: String, + valid_signature: bool, + tags: Vec, + } + + fn test_vectors(cond: Option<&str>) -> Vec { + let data_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data/signature-fixtures.json"); + let file = File::open(data_path).expect("opening test data should succeed"); + let v = serde_json::from_reader::<_, Vec>(file) + .expect("parsing test data should succeed"); + v.into_iter() + .filter(|v| { + if let Some(s) = cond { + v.tags.contains(&s.to_string()) + } else { + true + } + }) + .collect() + } + + #[test] + fn verify() { + let vectors = test_vectors(None); + assert!(!vectors.is_empty()); + let verifier = Verifier::default(); + for vector in vectors { + let message = Base::Base64 + .decode(vector.message_base64) + .expect("decoding message should succeed"); + let signature = Base::Base64 + .decode(vector.signature_base64) + .expect("decoding signature should succeed"); + + let (base, decoded_key) = multibase::decode(vector.public_key_multibase) + .expect("decoding multibase public key should succeed"); + assert_eq!(base, Base::Base58Btc); + let (alg, parsed_key) = + parse_did_key(&vector.public_key_did).expect("parsing DID key should succeed"); + + // assert_eq!(decoded_key, parsed_key); + match vector.algorithm { + Algorithm::ES256 => assert_eq!(alg, crate::Algorithm::P256), + Algorithm::ES256K => assert_eq!(alg, crate::Algorithm::Secp256k1), + } + assert_eq!( + verifier + .verify(alg, &decoded_key, &message, &signature) + .is_ok(), + vector.valid_signature + ); + assert_eq!( + verifier + .verify(alg, &parsed_key, &message, &signature) + .is_ok(), + vector.valid_signature + ); + } + } + + #[test] + fn verify_high_s() { + let vectors = test_vectors(Some("high-s")); + assert!(vectors.len() >= 2); + let verifier = Verifier::new(true); + for vector in vectors { + let message = Base::Base64 + .decode(vector.message_base64) + .expect("decoding message should succeed"); + let signature = Base::Base64 + .decode(vector.signature_base64) + .expect("decoding signature should succeed"); + + let (base, decoded_key) = multibase::decode(vector.public_key_multibase) + .expect("decoding multibase public key should succeed"); + assert_eq!(base, Base::Base58Btc); + let (alg, parsed_key) = + parse_did_key(&vector.public_key_did).expect("parsing DID key should succeed"); + + // assert_eq!(decoded_key, parsed_key); + match vector.algorithm { + Algorithm::ES256 => assert_eq!(alg, crate::Algorithm::P256), + Algorithm::ES256K => assert_eq!(alg, crate::Algorithm::Secp256k1), + } + assert!(!vector.valid_signature); + assert!(verifier + .verify(alg, &decoded_key, &message, &signature) + .is_ok()); + assert!(verifier + .verify(alg, &parsed_key, &message, &signature) + .is_ok()); + } + } + + #[test] + fn verify_der_encoded() { + let vectors = test_vectors(Some("der-encoded")); + assert!(vectors.len() >= 2); + let verifier = Verifier::new(true); + for vector in vectors { + let message = Base::Base64 + .decode(vector.message_base64) + .expect("decoding message should succeed"); + let signature = Base::Base64 + .decode(vector.signature_base64) + .expect("decoding signature should succeed"); + + let (base, decoded_key) = multibase::decode(vector.public_key_multibase) + .expect("decoding multibase public key should succeed"); + assert_eq!(base, Base::Base58Btc); + let (alg, parsed_key) = + parse_did_key(&vector.public_key_did).expect("parsing DID key should succeed"); + + // assert_eq!(decoded_key, parsed_key); + match vector.algorithm { + Algorithm::ES256 => assert_eq!(alg, crate::Algorithm::P256), + Algorithm::ES256K => assert_eq!(alg, crate::Algorithm::Secp256k1), + } + assert!(!vector.valid_signature); + assert!(verifier + .verify(alg, &decoded_key, &message, &signature) + .is_ok()); + assert!(verifier + .verify(alg, &parsed_key, &message, &signature) + .is_ok()); + } + } +} diff --git a/atrium-crypto/tests/data/signature-fixtures.json b/atrium-crypto/tests/data/signature-fixtures.json new file mode 100644 index 00000000..2e41be58 --- /dev/null +++ b/atrium-crypto/tests/data/signature-fixtures.json @@ -0,0 +1,68 @@ +[ + { + "comment": "valid P-256 key and signature, with low-S signature", + "messageBase64": "oWVoZWxsb2V3b3JsZA", + "algorithm": "ES256", + "didDocSuite": "EcdsaSecp256r1VerificationKey2019", + "publicKeyDid": "did:key:zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo", + "publicKeyMultibase": "zxdM8dSstjrpZaRUwBmDvjGXweKuEMVN95A9oJBFjkWMh", + "signatureBase64": "2vZNsG3UKvvO/CDlrdvyZRISOFylinBh0Jupc6KcWoJWExHptCfduPleDbG3rko3YZnn9Lw0IjpixVmexJDegg", + "validSignature": true, + "tags": [] + }, + { + "comment": "valid K-256 key and signature, with low-S signature", + "messageBase64": "oWVoZWxsb2V3b3JsZA", + "algorithm": "ES256K", + "didDocSuite": "EcdsaSecp256k1VerificationKey2019", + "publicKeyDid": "did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc", + "publicKeyMultibase": "z25z9DTpsiYYJKGsWmSPJK2NFN8PcJtZig12K59UgW7q5t", + "signatureBase64": "5WpdIuEUUfVUYaozsi8G0B3cWO09cgZbIIwg1t2YKdUn/FEznOndsz/qgiYb89zwxYCbB71f7yQK5Lr7NasfoA", + "validSignature": true, + "tags": [] + }, + { + "comment": "P-256 key and signature, with non-low-S signature which is invalid in atproto", + "messageBase64": "oWVoZWxsb2V3b3JsZA", + "algorithm": "ES256", + "didDocSuite": "EcdsaSecp256r1VerificationKey2019", + "publicKeyDid": "did:key:zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo", + "publicKeyMultibase": "zxdM8dSstjrpZaRUwBmDvjGXweKuEMVN95A9oJBFjkWMh", + "signatureBase64": "2vZNsG3UKvvO/CDlrdvyZRISOFylinBh0Jupc6KcWoKp7O4VS9giSAah8k5IUbXIW00SuOrjfEqQ9HEkN9JGzw", + "validSignature": false, + "tags": ["high-s"] + }, + { + "comment": "K-256 key and signature, with non-low-S signature which is invalid in atproto", + "messageBase64": "oWVoZWxsb2V3b3JsZA", + "algorithm": "ES256K", + "didDocSuite": "EcdsaSecp256k1VerificationKey2019", + "publicKeyDid": "did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc", + "publicKeyMultibase": "z25z9DTpsiYYJKGsWmSPJK2NFN8PcJtZig12K59UgW7q5t", + "signatureBase64": "5WpdIuEUUfVUYaozsi8G0B3cWO09cgZbIIwg1t2YKdXYA67MYxYiTMAVfdnkDCMN9S5B3vHosRe07aORmoshoQ", + "validSignature": false, + "tags": ["high-s"] + }, + { + "comment": "P-256 key and signature, with DER-encoded signature which is invalid in atproto", + "messageBase64": "oWVoZWxsb2V3b3JsZA", + "algorithm": "ES256", + "didDocSuite": "EcdsaSecp256r1VerificationKey2019", + "publicKeyDid": "did:key:zDnaeT6hL2RnTdUhAPLij1QBkhYZnmuKyM7puQLW1tkF4Zkt8", + "publicKeyMultibase": "ze8N2PPxnu19hmBQ58t5P3E9Yj6CqakJmTVCaKvf9Byq2", + "signatureBase64": "MEQCIFxYelWJ9lNcAVt+jK0y/T+DC/X4ohFZ+m8f9SEItkY1AiACX7eXz5sgtaRrz/SdPR8kprnbHMQVde0T2R8yOTBweA", + "validSignature": false, + "tags": ["der-encoded"] + }, + { + "comment": "K-256 key and signature, with DER-encoded signature which is invalid in atproto", + "messageBase64": "oWVoZWxsb2V3b3JsZA", + "algorithm": "ES256K", + "didDocSuite": "EcdsaSecp256k1VerificationKey2019", + "publicKeyDid": "did:key:zQ3shnriYMXc8wvkbJqfNWh5GXn2bVAeqTC92YuNbek4npqGF", + "publicKeyMultibase": "z22uZXWP8fdHXi4jyx8cCDiBf9qQTsAe6VcycoMQPfcMQX", + "signatureBase64": "MEUCIQCWumUqJqOCqInXF7AzhIRg2MhwRz2rWZcOEsOjPmNItgIgXJH7RnqfYY6M0eg33wU0sFYDlprwdOcpRn78Sz5ePgk", + "validSignature": false, + "tags": ["der-encoded"] + } +] diff --git a/atrium-libs/Cargo.toml b/atrium-libs/Cargo.toml new file mode 100644 index 00000000..43bffc78 --- /dev/null +++ b/atrium-libs/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "atrium-libs" +version = "0.1.0" +authors = ["sugyan "] +edition.workspace = true +rust-version.workspace = true +description = "A collection of libraries for AT Protocol" +readme = "README.md" +repository.workspace = true +license.workspace = true +keywords.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +atrium-crypto = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } +http = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +url = { workspace = true, optional = true } +urlencoding = { workspace = true, optional = true } + +[dev-dependencies] +mockito = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros"] } + +[features] +default = ["common-web", "identity"] +atproto-data = ["atrium-crypto"] +common-web = ["http", "serde/derive"] +identity = ["common-web", "async-trait", "serde_json", "thiserror", "url", "urlencoding"] diff --git a/atrium-libs/src/common_web.rs b/atrium-libs/src/common_web.rs new file mode 100644 index 00000000..5c8b5586 --- /dev/null +++ b/atrium-libs/src/common_web.rs @@ -0,0 +1 @@ +pub mod did_doc; diff --git a/atrium-libs/src/common_web/did_doc.rs b/atrium-libs/src/common_web/did_doc.rs new file mode 100644 index 00000000..50560700 --- /dev/null +++ b/atrium-libs/src/common_web/did_doc.rs @@ -0,0 +1,153 @@ +//! Definitions for DID document types. +//! https://atproto.com/specs/did#did-documents + +/// A DID document, containing information associated with the DID +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DidDocument { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "@context")] + pub context: Option>, + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub also_known_as: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_method: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option>, +} + +/// The public signing key for the account +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VerificationMethod { + pub id: String, + pub r#type: String, + pub controller: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key_multibase: Option, +} + +/// The PDS service network location for the account +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Service { + pub id: String, + pub r#type: String, + pub service_endpoint: String, +} + +impl TryFrom<&str> for DidDocument { + type Error = serde_json::Error; + + fn try_from(value: &str) -> Result { + serde_json::from_str(value) + } +} + +impl DidDocument { + pub fn get_did(&self) -> String { + self.id.clone() + } + pub fn get_handle(&self) -> Option { + if let Some(aka) = &self.also_known_as { + aka.iter() + .find_map(|name| name.strip_prefix("at://")) + .map(String::from) + } else { + None + } + } + pub fn get_signing_key(&self) -> Option<(String, String)> { + self.get_verification_material("#atproto") + } + pub fn get_pds_endpoint(&self) -> Option { + self.get_service_endpoint("#atproto_pds", "AtprotoPersonalDataServer") + } + fn get_verification_material(&self, id: &str) -> Option<(String, String)> { + let did = self.get_did(); + if let Some(keys) = &self.verification_method { + keys.iter().find_map(|key| { + if key.id == id || key.id == format!("{did}{id}") { + key.public_key_multibase + .as_ref() + .map(|multibase| (key.r#type.clone(), multibase.clone())) + } else { + None + } + }) + } else { + None + } + } + fn get_service_endpoint(&self, id: &str, r#type: &str) -> Option { + let did = self.get_did(); + if let Some(services) = &self.service { + services + .iter() + .find(|service| { + (service.id == id || service.id == format!("{did}{id}")) + && service.r#type == r#type + }) + .and_then(|service| Self::validate_url(&service.service_endpoint)) + } else { + None + } + } + fn validate_url(s: &str) -> Option { + s.parse::() + .ok() + .and_then(|uri| match (uri.scheme(), uri.host()) { + (Some(scheme), Some(_)) if (scheme == "https" || scheme == "http") => { + Some(s.to_string()) + } + _ => None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DID_DOC_JSON: &str = r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:4ee6oesrsbtmuln4gqsqf6fp","alsoKnownAs":["at://sugyan.com"],"verificationMethod":[{"id":"did:plc:4ee6oesrsbtmuln4gqsqf6fp#atproto","type":"Multikey","controller":"did:plc:4ee6oesrsbtmuln4gqsqf6fp","publicKeyMultibase":"zQ3shnw8ChQwGUE6gMghuvn5g7Q9YVej1MUJENqMsLmxZwRSz"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://puffball.us-east.host.bsky.network"}]}"##; + + fn did_doc_example() -> DidDocument { + DidDocument { + context: Some(vec![ + String::from("https://www.w3.org/ns/did/v1"), + String::from("https://w3id.org/security/multikey/v1"), + String::from("https://w3id.org/security/suites/secp256k1-2019/v1"), + ]), + id: String::from("did:plc:4ee6oesrsbtmuln4gqsqf6fp"), + also_known_as: Some(vec![String::from("at://sugyan.com")]), + verification_method: Some(vec![VerificationMethod { + id: String::from("did:plc:4ee6oesrsbtmuln4gqsqf6fp#atproto"), + r#type: String::from("Multikey"), + controller: String::from("did:plc:4ee6oesrsbtmuln4gqsqf6fp"), + public_key_multibase: Some(String::from( + "zQ3shnw8ChQwGUE6gMghuvn5g7Q9YVej1MUJENqMsLmxZwRSz", + )), + }]), + service: Some(vec![Service { + id: String::from("#atproto_pds"), + r#type: String::from("AtprotoPersonalDataServer"), + service_endpoint: String::from("https://puffball.us-east.host.bsky.network"), + }]), + } + } + + #[test] + fn serialize_did_doc() { + let result = + serde_json::to_string(&did_doc_example()).expect("serialization should succeed"); + assert_eq!(result, DID_DOC_JSON); + } + + #[test] + fn deserialize_did_doc() { + let result = serde_json::from_str::(DID_DOC_JSON) + .expect("deserialization should succeed"); + assert_eq!(result, did_doc_example()); + } +} diff --git a/atrium-libs/src/identity.rs b/atrium-libs/src/identity.rs new file mode 100644 index 00000000..4f82e58a --- /dev/null +++ b/atrium-libs/src/identity.rs @@ -0,0 +1,2 @@ +//! A library for decentralized identities in [atproto](https://atproto.com) using DIDs and handles +pub mod did; diff --git a/atrium-libs/src/identity/did.rs b/atrium-libs/src/identity/did.rs new file mode 100644 index 00000000..ebee1413 --- /dev/null +++ b/atrium-libs/src/identity/did.rs @@ -0,0 +1,156 @@ +use crate::common_web::did_doc::DidDocument; +#[cfg(feature = "atproto-data")] +pub mod atproto_data; +pub mod did_resolver; +mod error; +mod plc_resolver; +mod web_resolver; + +use self::error::{Error, Result}; +use async_trait::async_trait; + +#[async_trait] +pub trait Fetch { + async fn fetch( + url: &str, + timeout: Option, + ) -> std::result::Result>, Box>; +} + +#[async_trait] +pub trait Resolve { + async fn resolve_no_check(&self, did: &str) -> Result>>; + + async fn resolve_no_cache(&self, did: &str) -> Result> { + if let Some(got) = self.resolve_no_check(did).await? { + Ok(serde_json::from_slice(&got)?) + } else { + Ok(None) + } + } + async fn resolve(&self, did: &str, force_refresh: bool) -> Result> { + // TODO: from cache + if let Some(got) = self.resolve_no_cache(did).await? { + // TODO: store in cache + Ok(Some(got)) + } else { + // TODO: clear cache + Ok(None) + } + } + async fn ensure_resolve(&self, did: &str, force_refresh: bool) -> Result { + self.resolve(did, force_refresh) + .await? + .ok_or_else(|| Error::DidNotFound(did.to_string())) + } +} + +pub fn validate_did_doc(did: &str, value: impl TryInto) -> Result { + if let Ok(did_doc) = value.try_into() { + if did_doc.get_did() == did { + return Ok(did_doc); + } + } + Err(Error::PoorlyFormattedDidDocument) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_bad_did_doc() { + let err = validate_did_doc("did:plc:yk4dd2qkboz2yv6tpubpc6co", r##" + { + "ideep": "did:plc:yk4dd2qkboz2yv6tpubpc6co", + "blah": [ + "https://dholms.xyz" + ], + "zoot": [ + { + "id": "#elsewhere", + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": "did:plc:yk4dd2qkboz2yv6tpubpc6co", + "publicKeyMultibase": "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR" + } + ], + "yarg": [ ] + } + "##, + ).expect_err("validation should fail with bad DID document"); + assert!(matches!(err, Error::PoorlyFormattedDidDocument)); + } + + #[test] + fn validate_legacy_format() { + let did_doc = validate_did_doc( + "did:plc:yk4dd2qkboz2yv6tpubpc6co", + r##" + { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1" + ], + "id": "did:plc:yk4dd2qkboz2yv6tpubpc6co", + "alsoKnownAs": [ + "at://dholms.xyz" + ], + "verificationMethod": [ + { + "id": "#atproto", + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": "did:plc:yk4dd2qkboz2yv6tpubpc6co", + "publicKeyMultibase": "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR" + } + ], + "service": [ + { + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://bsky.social" + } + ] + } + "##, + ) + .expect("validation should succeed with legacy DID format"); + assert_eq!(did_doc.get_did(), "did:plc:yk4dd2qkboz2yv6tpubpc6co"); + } + + #[test] + fn validate_newer_format() { + let did_doc = validate_did_doc( + "did:plc:yk4dd2qkboz2yv6tpubpc6co", + r##" + { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1" + ], + "id": "did:plc:yk4dd2qkboz2yv6tpubpc6co", + "alsoKnownAs": [ + "at://dholms.xyz" + ], + "verificationMethod": [ + { + "id": "did:plc:yk4dd2qkboz2yv6tpubpc6co#atproto", + "type": "Multikey", + "controller": "did:plc:yk4dd2qkboz2yv6tpubpc6co", + "publicKeyMultibase": "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" + } + ], + "service": [ + { + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://bsky.social" + } + ] + } + "##, + ) + .expect("validation should succeed with newer Multikey DID format"); + assert_eq!(did_doc.get_did(), "did:plc:yk4dd2qkboz2yv6tpubpc6co"); + } +} diff --git a/atrium-libs/src/identity/did/atproto_data.rs b/atrium-libs/src/identity/did/atproto_data.rs new file mode 100644 index 00000000..ad95e2cd --- /dev/null +++ b/atrium-libs/src/identity/did/atproto_data.rs @@ -0,0 +1,152 @@ +use crate::common_web::did_doc::DidDocument; +use atrium_crypto::did::{format_did_key, parse_multikey}; +use atrium_crypto::multibase; +use atrium_crypto::Algorithm; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Could not parse signing key from doc: {0:?}")] + SigningKey(DidDocument), + #[error("Could not parse handle from doc: {0:?}")] + Handle(DidDocument), + #[error("Could not parse pds from doc: {0:?}")] + Pds(DidDocument), + #[error(transparent)] + Crypto(#[from] atrium_crypto::Error), + #[error(transparent)] + Multibase(#[from] multibase::Error), +} + +pub type Result = std::result::Result; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct AtprotoData { + pub did: String, + pub signing_key: String, + pub handle: String, + pub pds: String, +} + +pub fn ensure_atproto_data(did_doc: &DidDocument) -> Result { + Ok(AtprotoData { + did: did_doc.get_did(), + signing_key: get_key(did_doc)?.ok_or(Error::SigningKey(did_doc.clone()))?, + handle: did_doc.get_handle().ok_or(Error::Handle(did_doc.clone()))?, + pds: did_doc + .get_pds_endpoint() + .ok_or(Error::Pds(did_doc.clone()))?, + }) +} + +fn get_key(did_doc: &DidDocument) -> Result> { + if let Some((r#type, public_key_multibase)) = did_doc.get_signing_key() { + get_did_key_from_multibase(r#type, public_key_multibase) + } else { + Ok(None) + } +} + +fn get_did_key_from_multibase( + r#type: String, + public_key_multibase: String, +) -> Result> { + Ok(match r#type.as_str() { + "EcdsaSecp256r1VerificationKey2019" => { + let (_, key) = multibase::decode(public_key_multibase)?; + Some(format_did_key(Algorithm::P256, &key)?) + } + "EcdsaSecp256k1VerificationKey2019" => { + let (_, key) = multibase::decode(public_key_multibase)?; + Some(format_did_key(Algorithm::Secp256k1, &key)?) + } + "Multikey" => { + let (alg, key) = parse_multikey(&public_key_multibase)?; + Some(format_did_key(alg, &key)?) + } + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common_web::did_doc::{Service, VerificationMethod}; + + #[test] + fn extract_from_legacy_format() { + let did_doc = DidDocument { + context: Some(vec![ + String::from("https://www.w3.org/ns/did/v1"), + String::from("https://w3id.org/security/suites/secp256k1-2019/v1"), + ]), + id: String::from("did:plc:yk4dd2qkboz2yv6tpubpc6co"), + also_known_as: Some(vec![String::from("at://dholms.xyz")]), + verification_method: Some(vec![VerificationMethod { + id: String::from("#atproto"), + r#type: String::from("EcdsaSecp256k1VerificationKey2019"), + controller: String::from("did:plc:yk4dd2qkboz2yv6tpubpc6co"), + public_key_multibase: Some(String::from( + "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR", + )), + }]), + service: Some(vec![Service { + id: String::from("#atproto_pds"), + r#type: String::from("AtprotoPersonalDataServer"), + service_endpoint: String::from("https://bsky.social"), + }]), + }; + let atp_data = ensure_atproto_data(&did_doc) + .expect("ensure_atproto_data should succeed with legacy DID format"); + assert_eq!( + atp_data, + AtprotoData { + did: String::from("did:plc:yk4dd2qkboz2yv6tpubpc6co"), + signing_key: String::from( + "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" + ), + handle: String::from("dholms.xyz"), + pds: String::from("https://bsky.social"), + } + ); + } + + #[test] + fn extract_from_newer_format() { + let did_doc = DidDocument { + context: Some(vec![ + String::from("https://www.w3.org/ns/did/v1"), + String::from("https://w3id.org/security/multikey/v1"), + String::from("https://w3id.org/security/suites/secp256k1-2019/v1"), + ]), + id: String::from("did:plc:yk4dd2qkboz2yv6tpubpc6co"), + also_known_as: Some(vec![String::from("at://dholms.xyz")]), + verification_method: Some(vec![VerificationMethod { + id: String::from("did:plc:yk4dd2qkboz2yv6tpubpc6co#atproto"), + r#type: String::from("Multikey"), + controller: String::from("did:plc:yk4dd2qkboz2yv6tpubpc6co"), + public_key_multibase: Some(String::from( + "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF", + )), + }]), + service: Some(vec![Service { + id: String::from("#atproto_pds"), + r#type: String::from("AtprotoPersonalDataServer"), + service_endpoint: String::from("https://bsky.social"), + }]), + }; + let atp_data = ensure_atproto_data(&did_doc) + .expect("ensure_atproto_data should succeed with legacy DID format"); + assert_eq!( + atp_data, + AtprotoData { + did: String::from("did:plc:yk4dd2qkboz2yv6tpubpc6co"), + signing_key: String::from( + "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" + ), + handle: String::from("dholms.xyz"), + pds: String::from("https://bsky.social"), + } + ); + } +} diff --git a/atrium-libs/src/identity/did/did_resolver.rs b/atrium-libs/src/identity/did/did_resolver.rs new file mode 100644 index 00000000..35364dad --- /dev/null +++ b/atrium-libs/src/identity/did/did_resolver.rs @@ -0,0 +1,228 @@ +use super::error::{Error, Result}; +use super::{plc_resolver::DidPlcResolver, web_resolver::DidWebResolver}; +use super::{Fetch, Resolve}; +use async_trait::async_trait; + +#[derive(Debug)] +pub struct DidResolver { + pub plc: DidPlcResolver, + pub web: DidWebResolver, +} + +#[async_trait] +impl Resolve for DidResolver +where + T: Fetch + Send + Sync, +{ + async fn resolve_no_check(&self, did: &str) -> Result>> { + let parts = did.split(':').collect::>(); + if parts.len() < 3 || parts[0] != "did" { + return Err(Error::PoorlyFormattedDid(did.to_string())); + } + match parts[1] { + "web" => self.web.resolve_no_check(did).await, + "plc" => self.plc.resolve_no_check(did).await, + _ => Err(Error::UnsupportedDidMethod(did.to_string())), + } + } +} + +impl Default for DidResolver { + fn default() -> Self { + let timeout = Some(3000); + let plc_url = String::from("https://plc.directory"); + Self { + plc: DidPlcResolver::new(plc_url, timeout), + web: DidWebResolver::new(timeout), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common_web::did_doc::{DidDocument, Service}; + use mockito::{Matcher, Server, ServerGuard}; + use reqwest::{header::CONTENT_TYPE, Client}; + use std::time::Duration; + + struct ReqwestFetcher; + + #[async_trait] + impl Fetch for ReqwestFetcher { + async fn fetch( + url: &str, + timeout: Option, + ) -> std::result::Result>, Box> + { + let mut builder = Client::builder(); + if let Some(timeout) = timeout { + builder = builder.timeout(Duration::from_millis(timeout)); + } + match builder.build()?.get(url).send().await?.error_for_status() { + Ok(response) => Ok(Some(response.bytes().await?.to_vec())), + Err(err) => { + if err + .status() + .map_or(false, |status| status.is_client_error()) + { + Ok(None) + } else { + Err(Box::new(err)) + } + } + } + } + } + + fn did_doc_example() -> DidDocument { + DidDocument { + context: None, + id: String::from("did:plc:234567abcdefghijklmnopqr"), + also_known_as: Some(vec![String::from("at://alice.test")]), + verification_method: None, + service: Some(vec![Service { + id: String::from("#atproto_pds"), + r#type: String::from("AtprotoPersonalDataServer"), + service_endpoint: String::from("https://service.test"), + }]), + } + } + + async fn web_server() -> (ServerGuard, DidDocument) { + let mut did_doc = did_doc_example(); + let mut server = Server::new_async().await; + did_doc.id = format!( + "did:web:{}", + urlencoding::encode(&server.host_with_port()).into_owned() + ); + server + .mock("GET", "/.well-known/did.json") + .with_status(200) + .with_header(CONTENT_TYPE.as_str(), "application/did+ld+json") + .with_body(serde_json::to_vec(&did_doc).expect("failed to serialize did_doc")) + .create(); + (server, did_doc) + } + + async fn plc_server() -> (ServerGuard, DidDocument) { + let did_doc = did_doc_example(); + let mut server = Server::new_async().await; + server + .mock( + "GET", + format!("/{}", urlencoding::encode(&did_doc.id)).as_str(), + ) + .with_status(200) + .with_header(CONTENT_TYPE.as_str(), "application/did+ld+json") + .with_body(serde_json::to_vec(&did_doc_example()).expect("failed to serialize did_doc")) + .create(); + server + .mock("GET", Matcher::Regex(String::from(r"^/[^/]+$"))) + .with_status(404) + .create(); + (server, did_doc) + } + + fn resolver(plc_url: Option) -> DidResolver { + let timeout = Some(3000); + DidResolver { + plc: DidPlcResolver::new( + plc_url.unwrap_or(String::from("https://plc.directory")), + timeout, + ), + web: DidWebResolver::new(timeout), + } + } + + #[tokio::test] + async fn resolve_did_web_valid() { + let (_server, did_doc) = web_server().await; + let resolver = resolver(None); + let result = resolver + .ensure_resolve(&did_doc.id, false) + .await + .expect("ensure_resolve shoud succeed with a valid did:web"); + assert_eq!(result, did_doc); + } + + #[tokio::test] + async fn resolve_did_web_malformed() { + let resolver = resolver(None); + + let err = resolver + .ensure_resolve("did:web:asdf", false) + .await + .expect_err("ensure_resolve should fail with a malformed did:web"); + assert!( + matches!(err, Error::Fetch(_)), + "error should be Fetch: {err:?}" + ); + + let err = resolver + .ensure_resolve("did:web:", false) + .await + .expect_err("ensure_resolve should fail with a malformed did:web"); + assert!( + matches!(err, Error::PoorlyFormattedDid(_)), + "error should be PoorlyFormattedDid: {err:?}" + ); + + let err = resolver + .ensure_resolve("", false) + .await + .expect_err("ensure_resolve should fail with a malformed did:web"); + assert!( + matches!(err, Error::PoorlyFormattedDid(_)), + "error should be PoorlyFormattedDid: {err:?}" + ); + } + + #[tokio::test] + async fn resolve_did_web_with_path_components() { + let resolver = resolver(None); + let err = resolver + .ensure_resolve("did:web:example.com:u:bob", false) + .await + .expect_err("ensure_resolve should fail with did:web with path components"); + assert!( + matches!(err, Error::UnsupportedDidWebPath(_)), + "error should be UnsupportedDidWebPath: {err:?}" + ); + } + + #[tokio::test] + async fn resolve_did_plc_valid() { + let (server, did_doc) = plc_server().await; + let resolver = resolver(Some(server.url())); + let result = resolver + .ensure_resolve(&did_doc.id, false) + .await + .expect("ensure_resolve shoud succeed with a valid did:plc"); + assert_eq!(result, did_doc); + } + + #[tokio::test] + async fn resolve_did_plc_malformed() { + let (server, _) = plc_server().await; + let resolver = resolver(Some(server.url())); + + let err = resolver + .ensure_resolve("did:plc:asdf", false) + .await + .expect_err("ensure_resolve should fail with a malformed did:plc"); + assert!( + matches!(err, Error::DidNotFound(_)), + "error should be DidNotFound: {err:?}" + ); + + let err = resolver + .ensure_resolve("did:plc", false) + .await + .expect_err("ensure_resolve should fail with a malformed did:plc"); + assert!( + matches!(err, Error::PoorlyFormattedDid(_)), + "error should be PoorlyFormattedDid: {err:?}" + ); + } +} diff --git a/atrium-libs/src/identity/did/error.rs b/atrium-libs/src/identity/did/error.rs new file mode 100644 index 00000000..9351b3ea --- /dev/null +++ b/atrium-libs/src/identity/did/error.rs @@ -0,0 +1,26 @@ +use std::string::FromUtf8Error; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + FromUtf8(#[from] FromUtf8Error), + #[error(transparent)] + UrlParse(#[from] url::ParseError), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + #[error("fetch error: {0}")] + Fetch(Box), + #[error("Could not resolve DID: {0}")] + DidNotFound(String), + #[error("Poorly formatted DID: {0}")] + PoorlyFormattedDid(String), + #[error("Poorly formatted DID Document")] + PoorlyFormattedDidDocument, + #[error("Unsupported DID method: {0}")] + UnsupportedDidMethod(String), + #[error("Unsupported did:web paths: {0}")] + UnsupportedDidWebPath(String), +} + +pub type Result = std::result::Result; diff --git a/atrium-libs/src/identity/did/plc_resolver.rs b/atrium-libs/src/identity/did/plc_resolver.rs new file mode 100644 index 00000000..7e9ddff4 --- /dev/null +++ b/atrium-libs/src/identity/did/plc_resolver.rs @@ -0,0 +1,34 @@ +use super::error::{Error, Result}; +use super::{Fetch, Resolve}; +use async_trait::async_trait; +use url::Url; + +#[derive(Debug, Default)] +pub struct DidPlcResolver { + plc_url: String, + timeout: Option, + _fetcher: std::marker::PhantomData, +} + +impl DidPlcResolver { + pub fn new(plc_url: String, timeout: Option) -> Self { + Self { + plc_url, + timeout, + _fetcher: std::marker::PhantomData, + } + } +} + +#[async_trait] +impl Resolve for DidPlcResolver +where + T: Fetch + Send + Sync, +{ + async fn resolve_no_check(&self, did: &str) -> Result>> { + let url = Url::parse(&format!("{}/{}", self.plc_url, urlencoding::encode(did)))?; + T::fetch(url.as_ref(), self.timeout) + .await + .map_err(Error::Fetch) + } +} diff --git a/atrium-libs/src/identity/did/web_resolver.rs b/atrium-libs/src/identity/did/web_resolver.rs new file mode 100644 index 00000000..06d15723 --- /dev/null +++ b/atrium-libs/src/identity/did/web_resolver.rs @@ -0,0 +1,52 @@ +use super::error::{Error, Result}; +use super::{Fetch, Resolve}; +use async_trait::async_trait; +use std::marker::PhantomData; +use url::{Host, Url}; + +#[derive(Debug, Default)] +pub struct DidWebResolver { + timeout: Option, + _fetcher: PhantomData, +} + +impl DidWebResolver { + pub fn new(timeout: Option) -> Self { + Self { + timeout, + _fetcher: PhantomData, + } + } +} + +#[async_trait] +impl Resolve for DidWebResolver +where + T: Fetch + Send + Sync, +{ + async fn resolve_no_check(&self, did: &str) -> Result>> { + let parts = did.splitn(3, ':').collect::>(); + if parts[2].is_empty() { + return Err(Error::PoorlyFormattedDid(did.to_string())); + } + if parts[2].contains(':') { + return Err(Error::UnsupportedDidWebPath(did.to_string())); + } + let mut url = Url::parse(&format!( + "https://{}/.well-known/did.json", + urlencoding::decode(parts[2])? + ))?; + if match url.host() { + Some(Host::Domain(domain)) if domain == "localhost" => true, + Some(Host::Ipv4(addr)) => addr.is_loopback(), + Some(Host::Ipv6(addr)) => addr.is_loopback(), + _ => false, + } { + url.set_scheme("http") + .expect("failed to set scheme to http"); + } + T::fetch(url.as_ref(), self.timeout) + .await + .map_err(Error::Fetch) + } +} diff --git a/atrium-libs/src/lib.rs b/atrium-libs/src/lib.rs new file mode 100644 index 00000000..8e7dfbf2 --- /dev/null +++ b/atrium-libs/src/lib.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "common-web")] +mod common_web; +#[cfg(feature = "identity")] +mod identity;