mirror of
https://github.com/aljazceru/pubky-core.git
synced 2026-01-30 11:24:31 +01:00
295
Cargo.lock
generated
295
Cargo.lock
generated
@@ -135,6 +135,20 @@ dependencies = [
|
||||
"critical-section",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authenticator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"clap",
|
||||
"pubky",
|
||||
"pubky-common",
|
||||
"rpassword",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.3.0"
|
||||
@@ -336,9 +350,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.6.1"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952"
|
||||
checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
@@ -365,9 +379,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.11"
|
||||
version = "4.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35723e6a11662c2afb578bcf0b88bf6ea8e21282a953428f240574fcc3a2b5b3"
|
||||
checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -375,9 +389,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.11"
|
||||
version = "4.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49eb96cbfa7cfa35017b7cd548c75b14c3118c98b423041d70562665e07fb0fa"
|
||||
checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -387,9 +401,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.11"
|
||||
version = "4.5.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d029b67f89d30bbb547c89fd5161293c0aec155fc691d7924b64550662db93e"
|
||||
checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -635,6 +649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"serde",
|
||||
"signature",
|
||||
]
|
||||
|
||||
@@ -998,6 +1013,24 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.6"
|
||||
@@ -1077,9 +1110,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.69"
|
||||
version = "0.3.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
|
||||
checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -1203,13 +1236,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1237,16 +1271,6 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.1"
|
||||
@@ -1400,10 +1424,10 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkarr"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4548c673cbf8c91b69f7a17d3a042710aa73cffe5e82351db5378f26c3be64d8"
|
||||
version = "2.2.0"
|
||||
source = "git+https://github.com/Pubky/pkarr?branch=v3#17975121c809d97dcad907fbb2ffc782e994d270"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"bytes",
|
||||
"document-features",
|
||||
"dyn-clone",
|
||||
@@ -1415,13 +1439,13 @@ dependencies = [
|
||||
"mainline",
|
||||
"rand",
|
||||
"self_cell",
|
||||
"serde",
|
||||
"simple-dns",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"z32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1488,7 +1512,7 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
name = "pubky"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"js-sys",
|
||||
"pkarr",
|
||||
@@ -1506,6 +1530,7 @@ dependencies = [
|
||||
name = "pubky-common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base32",
|
||||
"blake3",
|
||||
"crypto_secretbox",
|
||||
@@ -1556,6 +1581,54 @@ dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b22d8e7369034b9a7132bc2008cac12f2013c8132b45e0554e6e20e2617f2156"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba92fb39ec7ad06ca2582c0ca834dfeadcaf06ddfc8e635c80aa7e1c05315fdd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.36"
|
||||
@@ -1675,6 +1748,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
@@ -1683,25 +1757,73 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 1.0.1",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"spin",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.0"
|
||||
@@ -1711,6 +1833,47 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.17"
|
||||
@@ -1752,9 +1915,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.204"
|
||||
version = "1.0.209"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
|
||||
checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@@ -1780,9 +1943,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.204"
|
||||
version = "1.0.209"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
|
||||
checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2073,34 +2236,44 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.38.0"
|
||||
version = "1.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
|
||||
checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"num_cpus",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
|
||||
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.11"
|
||||
@@ -2315,6 +2488,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.2"
|
||||
@@ -2361,19 +2540,20 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
|
||||
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
|
||||
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
@@ -2398,9 +2578,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
|
||||
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -2408,9 +2588,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
|
||||
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2421,20 +2601,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
|
||||
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.69"
|
||||
version = "0.3.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
|
||||
checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -2615,12 +2804,6 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "z32"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edb37266251c28b03d08162174a91c3a092e3bd4f476f8205ee1c507b78b7bdc"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.1"
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -1,9 +1,18 @@
|
||||
[workspace]
|
||||
members = [ "pubky","pubky-*"]
|
||||
members = [
|
||||
"pubky",
|
||||
"pubky-*",
|
||||
|
||||
"examples/authz/authenticator"
|
||||
]
|
||||
|
||||
# See: https://github.com/rust-lang/rust/issues/90148#issuecomment-949194352
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
pkarr = { git = "https://github.com/Pubky/pkarr", branch = "v3", package = "pkarr", features = ["async"] }
|
||||
serde = { version = "^1.0.209", features = ["derive"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = 'z'
|
||||
|
||||
24
examples/authz/3rd-party-app/.gitignore
vendored
Normal file
24
examples/authz/3rd-party-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
26
examples/authz/3rd-party-app/index.html
Normal file
26
examples/authz/3rd-party-app/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/pubky.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pubky Auth Demo</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script type="module">
|
||||
import "@synonymdev/pubky"
|
||||
</script>
|
||||
<script type="module" src="/src/pubky-auth-widget.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<pubky-auth-widget
|
||||
relay="https://demo.httprelay.io/link/"
|
||||
caps="/pub/pubky.app/:rw,/pub/example.com/nested:rw"
|
||||
>
|
||||
</pubky-auth-widget>
|
||||
|
||||
<main>
|
||||
<h1>Third Party app!</h1>
|
||||
<p>this is a demo for using Pubky Auth in an unhosted (no backend) app.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
1146
examples/authz/3rd-party-app/package-lock.json
generated
Normal file
1146
examples/authz/3rd-party-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
examples/authz/3rd-party-app/package.json
Normal file
20
examples/authz/3rd-party-app/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pubky-auth-3rd-party",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npm run dev",
|
||||
"dev": "vite --host --open",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@synonymdev/pubky": "file:../../../pubky/pkg",
|
||||
"lit": "^3.2.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
1
examples/authz/3rd-party-app/public/pubky.svg
Normal file
1
examples/authz/3rd-party-app/public/pubky.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 1511 1511" width="1511" height="1511"><style>.a{fill:#fff}</style><path d="m269 0h973c148.6 0 269 120.4 269 269v973c0 148.6-120.4 269-269 269h-973c-148.6 0-269-120.4-269-269v-973c0-148.6 120.4-269 269-269z"/><path fill-rule="evenodd" class="a" d="m630.1 1064.3l14.9-59.6c50.5-27.7 90.8-71.7 113.7-124.8-47.3 51.2-115.2 83.3-190.5 83.3-51.9 0-100.1-15.1-140.4-41.2l-39.8 142.3c0 0-199.3 0-200 0l162.4-619.3h210.5l-0.1 0.1q3.7-0.1 7.4-0.1c77.6 0 147.2 34 194.7 88l22-88h201.9l-46.9 180.8 183.7-180.8h248.8l-322.8 332.6 223.9 286.7h-290.8l-116.6-154.6-40.3 154.6c0 0-195 0-195.7 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 655 B |
48
examples/authz/3rd-party-app/src/index.css
Normal file
48
examples/authz/3rd-party-app/src/index.css
Normal file
@@ -0,0 +1,48 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
color: white;
|
||||
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
transparent 20%,
|
||||
#151718 20%,
|
||||
#151718 80%,
|
||||
transparent 80%,
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
circle,
|
||||
transparent 20%,
|
||||
#151718 20%,
|
||||
#151718 80%,
|
||||
transparent 80%,
|
||||
transparent
|
||||
)
|
||||
25px 25px,
|
||||
linear-gradient(#202020 1px, transparent 2px) 0 -1px,
|
||||
linear-gradient(90deg, #202020 1px, #151718 2px) -1px 0;
|
||||
background-size: 50px 50px, 50px 50px, 25px 25px, 25px 25px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 3.2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
336
examples/authz/3rd-party-app/src/pubky-auth-widget.js
Normal file
336
examples/authz/3rd-party-app/src/pubky-auth-widget.js
Normal file
@@ -0,0 +1,336 @@
|
||||
import { LitElement, css, html } from 'lit'
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const DEFAULT_HTTP_RELAY = "https://demo.httprelay.io/link"
|
||||
|
||||
/**
|
||||
*/
|
||||
export class PubkyAuthWidget extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Relay endpoint for the widget to receive Pubky AuthTokens
|
||||
*
|
||||
* Internally, a random channel ID will be generated and a
|
||||
* GET request made for `${realy url}/${channelID}`
|
||||
*
|
||||
* If no relay is passed, the widget will use a default relay:
|
||||
* https://demo.httprelay.io/link
|
||||
*/
|
||||
relay: { type: String },
|
||||
/**
|
||||
* Capabilities requested or this application encoded as a string.
|
||||
*/
|
||||
caps: { type: String },
|
||||
/**
|
||||
* Widget's state (open or closed)
|
||||
*/
|
||||
open: { type: Boolean },
|
||||
/**
|
||||
* Show "copied to clipboard" note
|
||||
*/
|
||||
showCopied: { type: Boolean },
|
||||
}
|
||||
}
|
||||
|
||||
canvasRef = createRef();
|
||||
|
||||
constructor() {
|
||||
if (!window.pubky) {
|
||||
throw new Error("window.pubky is unavailable, make sure to import `@synonymdev/pubky` before this web component.")
|
||||
}
|
||||
|
||||
super()
|
||||
|
||||
this.open = false;
|
||||
|
||||
// TODO: allow using mainnet
|
||||
/** @type {import("@synonymdev/pubky").PubkyClient} */
|
||||
this.pubkyClient = window.pubky.PubkyClient.testnet();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
|
||||
let [url, promise] = this.pubkyClient.authRequest(this.relay || DEFAULT_HTTP_RELAY, this.caps);
|
||||
|
||||
promise.then(session => {
|
||||
console.log({ id: session.pubky().z32(), capabilities: session.capabilities() })
|
||||
alert(`Successfully signed in to ${session.pubky().z32()} with capabilities: ${session.capabilities().join(",")}`)
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
// let keypair = pubky.Keypair.random();
|
||||
// const Homeserver = pubky.PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
// this.pubkyClient.signup(keypair, Homeserver).then(() => {
|
||||
// this.pubkyClient.sendAuthToken(keypair, url)
|
||||
// })
|
||||
|
||||
this.authUrl = url
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
id="widget"
|
||||
class=${this.open ? "open" : ""}
|
||||
>
|
||||
<button class="header" @click=${this._switchOpen}>
|
||||
<svg id="pubky-icon" version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1511 1511"><path fill-rule="evenodd" d="m636.3 1066.7 14.9-59.7c50.5-27.7 90.8-71.7 113.7-124.9-47.3 51.3-115.2 83.4-190.6 83.4-51.9 0-100.1-15.1-140.5-41.2L394 1066.7H193.9L356.4 447H567l-.1.1q3.7-.1 7.4-.1c77.7 0 147.3 34 194.8 88l22-88h202.1l-47 180.9L1130 447h249l-323 332.8 224 286.9H989L872.4 912l-40.3 154.7H636.3z" style="fill:#fff"/></svg>
|
||||
<span class="text">
|
||||
Pubky Auth
|
||||
</span>
|
||||
</button>
|
||||
<div class="line"></div>
|
||||
<div id="widget-content">
|
||||
<p>Scan or copy Pubky auth URL</p>
|
||||
<div class="card">
|
||||
<canvas id="qr" ${ref(this._setQr)}></canvas>
|
||||
</div>
|
||||
<button class="card url" @click=${this._copyToClipboard}>
|
||||
<div class="copied ${this.showCopied ? "show" : ""}">Copied to Clipboard</div>
|
||||
<p>${this.authUrl}</p>
|
||||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="10" height="12" rx="2" fill="white"></rect><rect x="3" y="3" width="10" height="12" rx="2" fill="white" stroke="#3B3B3B"></rect></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
_setQr(canvas) {
|
||||
QRCode.toCanvas(canvas, this.authUrl, {
|
||||
margin: 2,
|
||||
scale: 8,
|
||||
|
||||
color: {
|
||||
light: '#fff',
|
||||
dark: '#000',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_switchOpen() {
|
||||
this.open = !this.open
|
||||
}
|
||||
|
||||
async _copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.authUrl);
|
||||
this.showCopied = true;
|
||||
setTimeout(() => { this.showCopied = false }, 1000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
id="widget"
|
||||
class=${this.open ? "open" : ""}
|
||||
>
|
||||
<button class="header" @click=${this._switchOpen}>
|
||||
<svg id="pubky-icon" version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1511 1511"><path fill-rule="evenodd" d="m636.3 1066.7 14.9-59.7c50.5-27.7 90.8-71.7 113.7-124.9-47.3 51.3-115.2 83.4-190.6 83.4-51.9 0-100.1-15.1-140.5-41.2L394 1066.7H193.9L356.4 447H567l-.1.1q3.7-.1 7.4-.1c77.7 0 147.3 34 194.8 88l22-88h202.1l-47 180.9L1130 447h249l-323 332.8 224 286.9H989L872.4 912l-40.3 154.7H636.3z" style="fill:#fff"/></svg>
|
||||
<span class="text">
|
||||
Pubky Auth
|
||||
</span>
|
||||
</button>
|
||||
<div class="line"></div>
|
||||
<div id="widget-content">
|
||||
<p>Scan or copy Pubky auth URL</p>
|
||||
<div class="card">
|
||||
<canvas id="qr" ${ref(this._setQr)}></canvas>
|
||||
</div>
|
||||
<button class="card url" @click=${this._copyToClipboard}>
|
||||
<div class="copied ${this.showCopied ? "show" : ""}">Copied to Clipboard</div>
|
||||
<p>${this.authUrl}</p>
|
||||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="10" height="12" rx="2" fill="white"></rect><rect x="3" y="3" width="10" height="12" rx="2" fill="white" stroke="#3B3B3B"></rect></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
--full-width: 22rem;
|
||||
--full-height: 31rem;
|
||||
--header-height: 3rem;
|
||||
--closed-width: 3rem;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/** End reset */
|
||||
|
||||
#widget {
|
||||
color: white;
|
||||
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
|
||||
background-color:red;
|
||||
|
||||
z-index: 99999;
|
||||
overflow: hidden;
|
||||
background: rgba(43, 43, 43, .7372549019607844);
|
||||
border: 1px solid #3c3c3c;
|
||||
box-shadow: 0 10px 34px -10px rgba(236, 243, 222, .05);
|
||||
border-radius: 8px;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
width: var(--closed-width);
|
||||
height: var(--header-height);
|
||||
|
||||
will-change: height,width;
|
||||
transition-property: height, width;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
#widget.open{
|
||||
width: var(--full-width);
|
||||
height: var(--full-height);
|
||||
}
|
||||
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#widget
|
||||
.header .text {
|
||||
display: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
#widget.open
|
||||
.header .text {
|
||||
display: block
|
||||
}
|
||||
|
||||
#widget.open
|
||||
.header {
|
||||
width: var(--full-width);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#pubky-icon {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#widget.open
|
||||
#pubky-icon {
|
||||
width: var(--header-height);
|
||||
height: 74%;
|
||||
}
|
||||
|
||||
#widget-content{
|
||||
width: var(--full-width);
|
||||
padding: 0 1rem
|
||||
}
|
||||
|
||||
#widget p {
|
||||
font-size: .87rem;
|
||||
line-height: 1rem;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
opacity: .5;
|
||||
|
||||
/* Fix flash wrap in open animation */
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
#qr {
|
||||
width: 18em !important;
|
||||
height: 18em !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: #3b3b3b;
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card.url {
|
||||
padding: .625rem;
|
||||
justify-content: space-between;
|
||||
max-width:100%;
|
||||
}
|
||||
|
||||
.url p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
line-height: 1!important;
|
||||
width: 93%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 1px;
|
||||
background-color: #3b3b3b;
|
||||
flex: 1 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.copied {
|
||||
will-change: opacity;
|
||||
transition-property: opacity;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -1.6rem;
|
||||
font-size: 0.9em;
|
||||
background: rgb(43 43 43 / 98%);
|
||||
padding: .5rem;
|
||||
border-radius: .3rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.copied.show {
|
||||
opacity:1
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('pubky-auth-widget', PubkyAuthWidget)
|
||||
29
examples/authz/README.md
Normal file
29
examples/authz/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Pubky Auth Example
|
||||
|
||||
This example shows 3rd party authorization in Pubky.
|
||||
|
||||
It consists of 2 parts:
|
||||
|
||||
1. [3rd party app](./3rd-party-app): A web component showing the how to implement a Pubky Auth widget.
|
||||
2. [Authenticator CLI](./authenticator): A CLI showing the authenticator (key chain) asking user for consent and generating the AuthToken.
|
||||
|
||||
## Usage
|
||||
|
||||
First you need to be running a local testnet Homeserver, in the root of this repo run
|
||||
|
||||
```bash
|
||||
cargo run --bin pubky_homeserver -- --testnet
|
||||
```
|
||||
|
||||
Run the frontend of the 3rd party app
|
||||
|
||||
```bash
|
||||
cd ./3rd-party-app
|
||||
npm start
|
||||
```
|
||||
|
||||
Copy the Pubky Auth URL from the frontend.
|
||||
|
||||
Finally run the CLI to paste the Pubky Auth in.
|
||||
|
||||
You should see the frontend reacting by showing the success of authorization and session details.
|
||||
1906
examples/authz/authenticator/Cargo.lock
generated
Normal file
1906
examples/authz/authenticator/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
examples/authz/authenticator/Cargo.toml
Normal file
14
examples/authz/authenticator/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "authenticator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
base64 = "0.22.1"
|
||||
clap = { version = "4.5.16", features = ["derive"] }
|
||||
pubky = { version = "0.1.0", path = "../../../pubky" }
|
||||
pubky-common = { version = "0.1.0", path = "../../../pubky-common" }
|
||||
rpassword = "7.3.1"
|
||||
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }
|
||||
url = "2.5.2"
|
||||
80
examples/authz/authenticator/src/main.rs
Normal file
80
examples/authz/authenticator/src/main.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use pubky::PubkyClient;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use pubky_common::{capabilities::Capability, crypto::PublicKey};
|
||||
|
||||
/// local testnet HOMESERVER
|
||||
const HOMESERVER: &str = "8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to a recovery_file of the Pubky you want to sign in with
|
||||
recovery_file: PathBuf,
|
||||
|
||||
/// Pubky Auth url
|
||||
url: Url,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let recovery_file = std::fs::read(&cli.recovery_file)?;
|
||||
println!("\nSuccessfully opened recovery file");
|
||||
|
||||
let url = cli.url;
|
||||
|
||||
let caps = url
|
||||
.query_pairs()
|
||||
.filter_map(|(key, value)| {
|
||||
if key == "caps" {
|
||||
return Some(
|
||||
value
|
||||
.split(',')
|
||||
.filter_map(|cap| Capability::try_from(cap).ok())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
};
|
||||
None
|
||||
})
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
|
||||
if !caps.is_empty() {
|
||||
println!("\nRequired Capabilities:");
|
||||
}
|
||||
|
||||
for cap in &caps {
|
||||
println!(" {} : {:?}", cap.scope, cap.actions);
|
||||
}
|
||||
|
||||
// === Consent form ===
|
||||
|
||||
println!("\nEnter your recovery_file's passphrase to confirm:");
|
||||
let passphrase = rpassword::read_password()?;
|
||||
|
||||
let keypair = pubky_common::recovery_file::decrypt_recovery_file(&recovery_file, &passphrase)?;
|
||||
|
||||
println!("Successfully decrypted recovery file...");
|
||||
println!("PublicKey: {}", keypair.public_key());
|
||||
|
||||
let client = PubkyClient::testnet();
|
||||
|
||||
// For the purposes of this demo, we need to make sure
|
||||
// the user has an account on the local homeserver.
|
||||
if client.signin(&keypair).await.is_err() {
|
||||
client
|
||||
.signup(&keypair, &PublicKey::try_from(HOMESERVER).unwrap())
|
||||
.await?;
|
||||
};
|
||||
|
||||
println!("Sending AuthToken to the 3rd party app...");
|
||||
|
||||
client.send_auth_token(&keypair, url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,12 +10,24 @@ base32 = "0.5.0"
|
||||
blake3 = "1.5.1"
|
||||
ed25519-dalek = "2.1.1"
|
||||
once_cell = "1.19.0"
|
||||
pkarr = "2.1.0"
|
||||
pkarr = { workspace = true }
|
||||
rand = "0.8.5"
|
||||
thiserror = "1.0.60"
|
||||
postcard = { version = "1.0.8", features = ["alloc"] }
|
||||
serde = { version = "1.0.204", features = ["derive"] }
|
||||
crypto_secretbox = { version = "0.1.1", features = ["std"] }
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3.69"
|
||||
|
||||
[dev-dependencies]
|
||||
postcard = "1.0.8"
|
||||
|
||||
[features]
|
||||
|
||||
serde = ["dep:serde", "ed25519-dalek/serde", "pkarr/serde"]
|
||||
full = ['serde']
|
||||
|
||||
default = ['full']
|
||||
|
||||
@@ -2,104 +2,153 @@
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ed25519_dalek::ed25519::SignatureBytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
crypto::{random_hash, Keypair, PublicKey, Signature},
|
||||
capabilities::{Capabilities, Capability},
|
||||
crypto::{Keypair, PublicKey, Signature},
|
||||
namespaces::PUBKY_AUTH,
|
||||
timestamp::Timestamp,
|
||||
};
|
||||
|
||||
// 30 seconds
|
||||
const TIME_INTERVAL: u64 = 30 * 1_000_000;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct AuthnSignature(Box<[u8]>);
|
||||
const CURRENT_VERSION: u8 = 0;
|
||||
// 45 seconds in the past or the future
|
||||
const TIMESTAMP_WINDOW: i64 = 45 * 1_000_000;
|
||||
|
||||
impl AuthnSignature {
|
||||
pub fn new(signer: &Keypair, audience: &PublicKey, token: Option<&[u8]>) -> Self {
|
||||
let mut bytes = Vec::with_capacity(96);
|
||||
|
||||
let time: u64 = Timestamp::now().into();
|
||||
let time_step = time / TIME_INTERVAL;
|
||||
|
||||
let token_hash = token.map_or(random_hash(), crate::crypto::hash);
|
||||
|
||||
let signature = signer
|
||||
.sign(&signable(
|
||||
&time_step.to_be_bytes(),
|
||||
&signer.public_key(),
|
||||
audience,
|
||||
token_hash.as_bytes(),
|
||||
))
|
||||
.to_bytes();
|
||||
|
||||
bytes.extend_from_slice(&signature);
|
||||
bytes.extend_from_slice(token_hash.as_bytes());
|
||||
|
||||
Self(bytes.into())
|
||||
}
|
||||
|
||||
/// Sign a randomly generated nonce
|
||||
pub fn generate(keypair: &Keypair, audience: &PublicKey) -> Self {
|
||||
AuthnSignature::new(keypair, audience, None)
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuthToken {
|
||||
/// Signature over the token.
|
||||
signature: Signature,
|
||||
/// A namespace to ensure this signature can't be used for any
|
||||
/// other purposes that share the same message structurea by accident.
|
||||
namespace: [u8; 10],
|
||||
/// Version of the [AuthToken], in case we need to upgrade it to support unforseen usecases.
|
||||
///
|
||||
/// Version 0:
|
||||
/// - Signer is implicitly the same as the root keypair for
|
||||
/// the [AuthToken::pubky], without any delegation.
|
||||
/// - Capabilities are only meant for resoucres on the homeserver.
|
||||
version: u8,
|
||||
/// Timestamp
|
||||
timestamp: Timestamp,
|
||||
/// The [PublicKey] of the owner of the resources being accessed by this token.
|
||||
pubky: PublicKey,
|
||||
// Variable length capabilities
|
||||
capabilities: Capabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthnVerifier {
|
||||
audience: PublicKey,
|
||||
inner: Arc<Mutex<Vec<[u8; 40]>>>,
|
||||
// TODO: Support permisisons
|
||||
// token_hashes: HashSet<[u8; 32]>,
|
||||
}
|
||||
impl AuthToken {
|
||||
pub fn sign(keypair: &Keypair, capabilities: impl Into<Capabilities>) -> Self {
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
impl AuthnVerifier {
|
||||
pub fn new(audience: PublicKey) -> Self {
|
||||
Self {
|
||||
audience,
|
||||
inner: Arc::new(Mutex::new(Vec::new())),
|
||||
let mut token = Self {
|
||||
signature: Signature::from_bytes(&[0; 64]),
|
||||
namespace: *PUBKY_AUTH,
|
||||
version: 0,
|
||||
timestamp,
|
||||
pubky: keypair.public_key(),
|
||||
capabilities: capabilities.into(),
|
||||
};
|
||||
|
||||
let serialized = token.serialize();
|
||||
|
||||
token.signature = keypair.sign(&serialized[65..]);
|
||||
|
||||
token
|
||||
}
|
||||
|
||||
pub fn capabilities(&self) -> &[Capability] {
|
||||
&self.capabilities.0
|
||||
}
|
||||
|
||||
pub fn verify(bytes: &[u8]) -> Result<Self, Error> {
|
||||
if bytes[75] > CURRENT_VERSION {
|
||||
return Err(Error::UnknownVersion);
|
||||
}
|
||||
|
||||
let token = AuthToken::deserialize(bytes)?;
|
||||
|
||||
match token.version {
|
||||
0 => {
|
||||
let now = Timestamp::now();
|
||||
|
||||
// Chcek timestamp;
|
||||
let diff = token.timestamp.difference(&now);
|
||||
if diff > TIMESTAMP_WINDOW {
|
||||
return Err(Error::TooFarInTheFuture);
|
||||
}
|
||||
if diff < -TIMESTAMP_WINDOW {
|
||||
return Err(Error::Expired);
|
||||
}
|
||||
|
||||
token
|
||||
.pubky
|
||||
.verify(AuthToken::signable(token.version, bytes), &token.signature)
|
||||
.map_err(|_| Error::InvalidSignature)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify(&self, bytes: &[u8], signer: &PublicKey) -> Result<(), AuthnSignatureError> {
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
postcard::to_allocvec(self).unwrap()
|
||||
}
|
||||
|
||||
pub fn deserialize(bytes: &[u8]) -> Result<Self, Error> {
|
||||
Ok(postcard::from_bytes(bytes)?)
|
||||
}
|
||||
|
||||
pub fn pubky(&self) -> &PublicKey {
|
||||
&self.pubky
|
||||
}
|
||||
|
||||
/// A unique ID for this [AuthToken], which is a concatenation of
|
||||
/// [AuthToken::pubky] and [AuthToken::timestamp].
|
||||
///
|
||||
/// Assuming that [AuthToken::timestamp] is unique for every [AuthToken::pubky].
|
||||
fn id(version: u8, bytes: &[u8]) -> Box<[u8]> {
|
||||
match version {
|
||||
0 => bytes[75..115].into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn signable(version: u8, bytes: &[u8]) -> &[u8] {
|
||||
match version {
|
||||
0 => bytes[65..].into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// Keeps track of used AuthToken until they expire.
|
||||
pub struct AuthVerifier {
|
||||
seen: Arc<Mutex<Vec<Box<[u8]>>>>,
|
||||
}
|
||||
|
||||
impl AuthVerifier {
|
||||
pub fn verify(&self, bytes: &[u8]) -> Result<AuthToken, Error> {
|
||||
self.gc();
|
||||
|
||||
if bytes.len() != 96 {
|
||||
return Err(AuthnSignatureError::InvalidLength(bytes.len()));
|
||||
let token = AuthToken::verify(bytes)?;
|
||||
|
||||
let mut seen = self.seen.lock().unwrap();
|
||||
|
||||
let id = AuthToken::id(token.version, bytes);
|
||||
|
||||
match seen.binary_search_by(|element| element.cmp(&id)) {
|
||||
Ok(_) => Err(Error::AlreadyUsed),
|
||||
Err(index) => {
|
||||
seen.insert(index, id);
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
let signature_bytes: SignatureBytes = bytes[0..64]
|
||||
.try_into()
|
||||
.expect("validate token length on instantiating");
|
||||
let signature = Signature::from(signature_bytes);
|
||||
|
||||
let token_hash: [u8; 32] = bytes[64..].try_into().expect("should not be reachable");
|
||||
|
||||
let now = Timestamp::now().into_inner();
|
||||
let past = now - TIME_INTERVAL;
|
||||
let future = now + TIME_INTERVAL;
|
||||
|
||||
let result = verify_at(now, self, &signature, signer, &token_hash);
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(AuthnSignatureError::AlreadyUsed) => return Err(AuthnSignatureError::AlreadyUsed),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let result = verify_at(past, self, &signature, signer, &token_hash);
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(AuthnSignatureError::AlreadyUsed) => return Err(AuthnSignatureError::AlreadyUsed),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
verify_at(future, self, &signature, signer, &token_hash)
|
||||
}
|
||||
|
||||
// === Private Methods ===
|
||||
@@ -108,7 +157,7 @@ impl AuthnVerifier {
|
||||
fn gc(&self) {
|
||||
let threshold = ((Timestamp::now().into_inner() / TIME_INTERVAL) - 2).to_be_bytes();
|
||||
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
let mut inner = self.seen.lock().unwrap();
|
||||
|
||||
match inner.binary_search_by(|element| element[0..8].cmp(&threshold)) {
|
||||
Ok(index) | Err(index) => {
|
||||
@@ -118,103 +167,113 @@ impl AuthnVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_at(
|
||||
time: u64,
|
||||
verifier: &AuthnVerifier,
|
||||
signature: &Signature,
|
||||
signer: &PublicKey,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<(), AuthnSignatureError> {
|
||||
let time_step = time / TIME_INTERVAL;
|
||||
let time_step_bytes = time_step.to_be_bytes();
|
||||
|
||||
let result = signer.verify(
|
||||
&signable(&time_step_bytes, signer, &verifier.audience, token_hash),
|
||||
signature,
|
||||
);
|
||||
|
||||
if result.is_ok() {
|
||||
let mut inner = verifier.inner.lock().unwrap();
|
||||
|
||||
let mut candidate = [0_u8; 40];
|
||||
candidate[..8].copy_from_slice(&time_step_bytes);
|
||||
candidate[8..].copy_from_slice(token_hash);
|
||||
|
||||
match inner.binary_search_by(|element| element.cmp(&candidate)) {
|
||||
Ok(index) | Err(index) => {
|
||||
inner.insert(index, candidate);
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(AuthnSignatureError::InvalidSignature)
|
||||
}
|
||||
|
||||
fn signable(
|
||||
time_step_bytes: &[u8; 8],
|
||||
signer: &PublicKey,
|
||||
audience: &PublicKey,
|
||||
token_hash: &[u8; 32],
|
||||
) -> [u8; 115] {
|
||||
let mut arr = [0; 115];
|
||||
|
||||
arr[..11].copy_from_slice(crate::namespaces::PUBKY_AUTHN);
|
||||
arr[11..19].copy_from_slice(time_step_bytes);
|
||||
arr[19..51].copy_from_slice(signer.as_bytes());
|
||||
arr[51..83].copy_from_slice(audience.as_bytes());
|
||||
arr[83..].copy_from_slice(token_hash);
|
||||
|
||||
arr
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AuthnSignatureError {
|
||||
#[error("AuthnSignature should be 96 bytes long, got {0} bytes instead")]
|
||||
InvalidLength(usize),
|
||||
|
||||
#[error("Invalid signature")]
|
||||
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("Unknown version")]
|
||||
UnknownVersion,
|
||||
#[error("AuthToken has a timestamp that is more than 45 seconds in the future")]
|
||||
TooFarInTheFuture,
|
||||
#[error("AuthToken has a timestamp that is more than 45 seconds in the past")]
|
||||
Expired,
|
||||
#[error("Invalid Signature")]
|
||||
InvalidSignature,
|
||||
|
||||
#[error("Authn signature already used")]
|
||||
#[error(transparent)]
|
||||
Postcard(#[from] postcard::Error),
|
||||
#[error("AuthToken already used")]
|
||||
AlreadyUsed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::crypto::Keypair;
|
||||
use crate::{
|
||||
auth::TIMESTAMP_WINDOW, capabilities::Capability, crypto::Keypair, timestamp::Timestamp,
|
||||
};
|
||||
|
||||
use super::{AuthnSignature, AuthnVerifier};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v0_id_signable() {
|
||||
let signer = Keypair::random();
|
||||
let capabilities = vec![Capability::root()];
|
||||
|
||||
let token = AuthToken::sign(&signer, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
let mut id = vec![];
|
||||
id.extend_from_slice(&token.timestamp.to_bytes());
|
||||
id.extend_from_slice(signer.public_key().as_bytes());
|
||||
|
||||
assert_eq!(AuthToken::id(token.version, serialized), id.into());
|
||||
|
||||
assert_eq!(
|
||||
AuthToken::signable(token.version, serialized),
|
||||
&serialized[65..]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_verify() {
|
||||
let keypair = Keypair::random();
|
||||
let signer = keypair.public_key();
|
||||
let audience = Keypair::random().public_key();
|
||||
let signer = Keypair::random();
|
||||
let capabilities = vec![Capability::root()];
|
||||
|
||||
let verifier = AuthnVerifier::new(audience.clone());
|
||||
let verifier = AuthVerifier::default();
|
||||
|
||||
let authn_signature = AuthnSignature::generate(&keypair, &audience);
|
||||
let token = AuthToken::sign(&signer, capabilities.clone());
|
||||
|
||||
verifier
|
||||
.verify(authn_signature.as_bytes(), &signer)
|
||||
.unwrap();
|
||||
let serialized = &token.serialize();
|
||||
|
||||
{
|
||||
// Invalid signable
|
||||
let mut invalid = authn_signature.as_bytes().to_vec();
|
||||
invalid[64..].copy_from_slice(&[0; 32]);
|
||||
verifier.verify(serialized).unwrap();
|
||||
|
||||
assert!(verifier.verify(&invalid, &signer).is_err())
|
||||
}
|
||||
assert_eq!(token.capabilities, capabilities.into());
|
||||
}
|
||||
|
||||
{
|
||||
// Invalid signer
|
||||
let mut invalid = authn_signature.as_bytes().to_vec();
|
||||
invalid[0..32].copy_from_slice(&[0; 32]);
|
||||
#[test]
|
||||
fn expired() {
|
||||
let signer = Keypair::random();
|
||||
let capabilities = Capabilities(vec![Capability::root()]);
|
||||
|
||||
assert!(verifier.verify(&invalid, &signer).is_err())
|
||||
}
|
||||
let verifier = AuthVerifier::default();
|
||||
|
||||
let timestamp = (&Timestamp::now()) - (TIMESTAMP_WINDOW as u64);
|
||||
|
||||
let mut signable = vec![];
|
||||
signable.extend_from_slice(signer.public_key().as_bytes());
|
||||
signable.extend_from_slice(&postcard::to_allocvec(&capabilities).unwrap());
|
||||
|
||||
let signature = signer.sign(&signable);
|
||||
|
||||
let token = AuthToken {
|
||||
signature,
|
||||
namespace: *PUBKY_AUTH,
|
||||
version: 0,
|
||||
timestamp,
|
||||
pubky: signer.public_key(),
|
||||
capabilities,
|
||||
};
|
||||
|
||||
let serialized = token.serialize();
|
||||
|
||||
let result = verifier.verify(&serialized);
|
||||
|
||||
assert_eq!(result, Err(Error::Expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_used() {
|
||||
let signer = Keypair::random();
|
||||
let capabilities = vec![Capability::root()];
|
||||
|
||||
let verifier = AuthVerifier::default();
|
||||
|
||||
let token = AuthToken::sign(&signer, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
verifier.verify(serialized).unwrap();
|
||||
|
||||
assert_eq!(token.capabilities, capabilities.into());
|
||||
|
||||
assert_eq!(verifier.verify(serialized), Err(Error::AlreadyUsed));
|
||||
}
|
||||
}
|
||||
|
||||
237
pubky-common/src/capabilities.rs
Normal file
237
pubky-common/src/capabilities.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Capability {
|
||||
pub scope: String,
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Create a root [Capability] at the `/` path with all the available [PubkyAbility]
|
||||
pub fn root() -> Self {
|
||||
Capability {
|
||||
scope: "/".to_string(),
|
||||
actions: vec![Action::Read, Action::Write],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Can read the scope at the specified path (GET requests).
|
||||
Read,
|
||||
/// Can write to the scope at the specified path (PUT/POST/DELETE requests).
|
||||
Write,
|
||||
/// Unknown ability
|
||||
Unknown(char),
|
||||
}
|
||||
|
||||
impl From<&Action> for char {
|
||||
fn from(value: &Action) -> Self {
|
||||
match value {
|
||||
Action::Read => 'r',
|
||||
Action::Write => 'w',
|
||||
Action::Unknown(char) => char.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<char> for Action {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: char) -> Result<Self, Error> {
|
||||
match value {
|
||||
'r' => Ok(Self::Read),
|
||||
'w' => Ok(Self::Write),
|
||||
_ => Err(Error::InvalidAction),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Capability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}:{}",
|
||||
self.scope,
|
||||
self.actions.iter().map(char::from).collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Capability {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Error> {
|
||||
value.as_str().try_into()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Capability {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Error> {
|
||||
if value.matches(':').count() != 1 {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
|
||||
if !value.starts_with('/') {
|
||||
return Err(Error::InvalidScope);
|
||||
}
|
||||
|
||||
let actions_str = value.rsplit(':').next().unwrap_or("");
|
||||
|
||||
let mut actions = Vec::new();
|
||||
|
||||
for char in actions_str.chars() {
|
||||
let ability = Action::try_from(char)?;
|
||||
|
||||
match actions.binary_search_by(|element| char::from(element).cmp(&char)) {
|
||||
Ok(_) => {}
|
||||
Err(index) => {
|
||||
actions.insert(index, ability);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scope = value[0..value.len() - actions_str.len() - 1].to_string();
|
||||
|
||||
Ok(Capability { scope, actions })
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Capability {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let string = self.to_string();
|
||||
|
||||
string.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capability {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let string: String = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
string.try_into().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("Capability: Invalid scope: does not start with `/`")]
|
||||
InvalidScope,
|
||||
#[error("Capability: Invalid format should be <scope>:<abilities>")]
|
||||
InvalidFormat,
|
||||
#[error("Capability: Invalid Action")]
|
||||
InvalidAction,
|
||||
#[error("Capabilities: Invalid capabilities format")]
|
||||
InvalidCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq, Eq)]
|
||||
/// A wrapper around `Vec<Capability>` to enable serialization without
|
||||
/// a varint. Useful when [Capabilities] are at the end of a struct.
|
||||
pub struct Capabilities(pub Vec<Capability>);
|
||||
|
||||
impl Capabilities {
|
||||
pub fn contains(&self, capability: &Capability) -> bool {
|
||||
self.0.contains(capability)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Capability>> for Capabilities {
|
||||
fn from(value: Vec<Capability>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Capabilities> for Vec<Capability> {
|
||||
fn from(value: Capabilities) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Capabilities {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
let mut caps = vec![];
|
||||
|
||||
for s in value.split(',') {
|
||||
if let Ok(cap) = Capability::try_from(s) {
|
||||
caps.push(cap);
|
||||
};
|
||||
}
|
||||
|
||||
Ok(Capabilities(caps))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Capabilities {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let string = self
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
write!(f, "{}", string)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Capabilities {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capabilities {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let string: String = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
let mut caps = vec![];
|
||||
|
||||
for s in string.split(',') {
|
||||
if let Ok(cap) = Capability::try_from(s) {
|
||||
caps.push(cap);
|
||||
};
|
||||
}
|
||||
|
||||
Ok(Capabilities(caps))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pubky_caps() {
|
||||
let cap = Capability {
|
||||
scope: "/pub/pubky.app/".to_string(),
|
||||
actions: vec![Action::Read, Action::Write],
|
||||
};
|
||||
|
||||
// Read and write withing directory `/pub/pubky.app/`.
|
||||
let expected_string = "/pub/pubky.app/:rw";
|
||||
|
||||
assert_eq!(cap.to_string(), expected_string);
|
||||
|
||||
assert_eq!(Capability::try_from(expected_string), Ok(cap))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod capabilities;
|
||||
pub mod crypto;
|
||||
pub mod namespaces;
|
||||
pub mod recovery_file;
|
||||
pub mod session;
|
||||
pub mod timestamp;
|
||||
|
||||
@@ -1 +1 @@
|
||||
pub const PUBKY_AUTHN: &[u8; 11] = b"PUBKY:AUTHN";
|
||||
pub const PUBKY_AUTH: &[u8; 10] = b"PUBKY:AUTH";
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use argon2::Argon2;
|
||||
use pkarr::Keypair;
|
||||
use pubky_common::crypto::{decrypt, encrypt};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::crypto::{decrypt, encrypt};
|
||||
|
||||
static SPEC_NAME: &str = "recovery";
|
||||
static SPEC_LINE: &str = "pubky.org/recovery";
|
||||
|
||||
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair> {
|
||||
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair, Error> {
|
||||
let encryption_key = recovery_file_encryption_key_from_passphrase(passphrase)?;
|
||||
|
||||
let newline_index = recovery_file
|
||||
@@ -39,7 +38,7 @@ pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<K
|
||||
Ok(Keypair::from_secret_key(&secret_key))
|
||||
}
|
||||
|
||||
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Vec<u8>> {
|
||||
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Vec<u8>, Error> {
|
||||
let encryption_key = recovery_file_encryption_key_from_passphrase(passphrase)?;
|
||||
let secret_key = keypair.secret_key();
|
||||
|
||||
@@ -54,7 +53,7 @@ pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Vec<u
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn recovery_file_encryption_key_from_passphrase(passphrase: &str) -> Result<[u8; 32]> {
|
||||
fn recovery_file_encryption_key_from_passphrase(passphrase: &str) -> Result<[u8; 32], Error> {
|
||||
let argon2id = Argon2::default();
|
||||
|
||||
let mut out = [0; 32];
|
||||
@@ -64,19 +63,39 @@ fn recovery_file_encryption_key_from_passphrase(passphrase: &str) -> Result<[u8;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
// === Recovery file ==
|
||||
#[error("Recovery file should start with a spec line, followed by a new line character")]
|
||||
RecoveryFileMissingSpecLine,
|
||||
|
||||
#[error("Recovery file should start with a spec line, followed by a new line character")]
|
||||
RecoveryFileVersionNotSupported,
|
||||
|
||||
#[error("Recovery file should contain an encrypted secret key after the new line character")]
|
||||
RecoverFileMissingEncryptedSecretKey,
|
||||
|
||||
#[error("Recovery file encrypted secret key should be 32 bytes, got {0}")]
|
||||
RecoverFileInvalidSecretKeyLength(usize),
|
||||
|
||||
#[error(transparent)]
|
||||
Argon(#[from] argon2::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Crypto(#[from] crate::crypto::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::PubkyClient;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_recovery_file() {
|
||||
let passphrase = "very secure password";
|
||||
let keypair = Keypair::random();
|
||||
|
||||
let recovery_file = PubkyClient::create_recovery_file(&keypair, passphrase).unwrap();
|
||||
let recovered = PubkyClient::decrypt_recovery_file(&recovery_file, passphrase).unwrap();
|
||||
let recovery_file = create_recovery_file(&keypair, passphrase).unwrap();
|
||||
let recovered = decrypt_recovery_file(&recovery_file, passphrase).unwrap();
|
||||
|
||||
assert_eq!(recovered.public_key(), keypair.public_key());
|
||||
}
|
||||
@@ -1,31 +1,48 @@
|
||||
use pkarr::PublicKey;
|
||||
use postcard::{from_bytes, to_allocvec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
extern crate alloc;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::timestamp::Timestamp;
|
||||
use crate::{auth::AuthToken, capabilities::Capability, timestamp::Timestamp};
|
||||
|
||||
// TODO: add IP address?
|
||||
// TODO: use https://crates.io/crates/user-agent-parser to parse the session
|
||||
// and get more informations from the user-agent.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct Session {
|
||||
pub version: usize,
|
||||
pub created_at: u64,
|
||||
version: usize,
|
||||
pubky: PublicKey,
|
||||
created_at: u64,
|
||||
/// User specified name, defaults to the user-agent.
|
||||
pub name: String,
|
||||
pub user_agent: String,
|
||||
name: String,
|
||||
user_agent: String,
|
||||
capabilities: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(token: &AuthToken, user_agent: Option<String>) -> Self {
|
||||
Self {
|
||||
version: 0,
|
||||
pubky: token.pubky().to_owned(),
|
||||
created_at: Timestamp::now().into_inner(),
|
||||
..Default::default()
|
||||
capabilities: token.capabilities().to_vec(),
|
||||
user_agent: user_agent.as_deref().unwrap_or("").to_string(),
|
||||
name: user_agent.as_deref().unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
pub fn pubky(&self) -> &PublicKey {
|
||||
&self.pubky
|
||||
}
|
||||
|
||||
pub fn capabilities(&self) -> &Vec<Capability> {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
// === Setters ===
|
||||
|
||||
pub fn set_user_agent(&mut self, user_agent: String) -> &mut Self {
|
||||
@@ -38,6 +55,12 @@ impl Session {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_capabilities(&mut self, capabilities: Vec<Capability>) -> &mut Self {
|
||||
self.capabilities = capabilities;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
// === Public Methods ===
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
@@ -51,6 +74,8 @@ impl Session {
|
||||
|
||||
Ok(from_bytes(bytes)?)
|
||||
}
|
||||
|
||||
// TODO: add `can_read()`, `can_write()` and `is_root()` methods
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
@@ -65,18 +90,34 @@ pub enum Error {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::crypto::Keypair;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialize() {
|
||||
let keypair = Keypair::from_secret_key(&[0; 32]);
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
let session = Session {
|
||||
user_agent: "foo".to_string(),
|
||||
..Default::default()
|
||||
capabilities: vec![Capability::root()],
|
||||
created_at: 0,
|
||||
pubky,
|
||||
version: 0,
|
||||
name: "".to_string(),
|
||||
};
|
||||
|
||||
let serialized = session.serialize();
|
||||
|
||||
assert_eq!(serialized, [0, 0, 0, 3, 102, 111, 111,]);
|
||||
assert_eq!(
|
||||
serialized,
|
||||
[
|
||||
0, 59, 106, 39, 188, 206, 182, 164, 45, 98, 163, 168, 208, 42, 111, 13, 115, 101,
|
||||
50, 21, 119, 29, 226, 67, 166, 58, 192, 72, 161, 139, 89, 218, 41, 0, 0, 3, 102,
|
||||
111, 111, 1, 4, 47, 58, 114, 119
|
||||
]
|
||||
);
|
||||
|
||||
let deseiralized = Session::deserialize(&serialized).unwrap();
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ impl Timestamp {
|
||||
self.0.to_be_bytes()
|
||||
}
|
||||
|
||||
pub fn difference(&self, rhs: &Timestamp) -> u64 {
|
||||
self.0.abs_diff(rhs.0)
|
||||
pub fn difference(&self, rhs: &Timestamp) -> i64 {
|
||||
(self.0 as i64) - (rhs.0 as i64)
|
||||
}
|
||||
|
||||
pub fn into_inner(&self) -> u64 {
|
||||
@@ -264,4 +264,17 @@ mod tests {
|
||||
|
||||
assert_eq!(decoded, timestamp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde() {
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
let serialized = postcard::to_allocvec(×tamp).unwrap();
|
||||
|
||||
assert_eq!(serialized, timestamp.to_bytes());
|
||||
|
||||
let deserialized: Timestamp = postcard::from_bytes(&serialized).unwrap();
|
||||
|
||||
assert_eq!(deserialized, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ anyhow = "1.0.82"
|
||||
axum = { version = "0.7.5", features = ["macros"] }
|
||||
axum-extra = { version = "0.9.3", features = ["typed-header", "async-read-body"] }
|
||||
base32 = "0.5.1"
|
||||
bytes = "1.6.1"
|
||||
bytes = "^1.7.1"
|
||||
clap = { version = "4.5.11", features = ["derive"] }
|
||||
dirs-next = "2.0.0"
|
||||
flume = "0.11.0"
|
||||
futures-util = "0.3.30"
|
||||
heed = "0.20.3"
|
||||
hex = "0.4.3"
|
||||
pkarr = { version = "2.1.0", features = ["async"] }
|
||||
pkarr = { workspace = true }
|
||||
postcard = { version = "1.0.8", features = ["alloc"] }
|
||||
pubky-common = { version = "0.1.0", path = "../pubky-common" }
|
||||
serde = { version = "1.0.204", features = ["derive"] }
|
||||
serde = { workspace = true }
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
toml = "0.8.19"
|
||||
tower-cookies = "0.10.0"
|
||||
|
||||
@@ -9,12 +9,18 @@ use heed::{Env, RwTxn};
|
||||
use blobs::{BlobsTable, BLOBS_TABLE};
|
||||
use entries::{EntriesTable, ENTRIES_TABLE};
|
||||
|
||||
use self::events::{EventsTable, EVENTS_TABLE};
|
||||
use self::{
|
||||
events::{EventsTable, EVENTS_TABLE},
|
||||
sessions::{SessionsTable, SESSIONS_TABLE},
|
||||
users::{UsersTable, USERS_TABLE},
|
||||
};
|
||||
|
||||
pub const TABLES_COUNT: u32 = 5;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tables {
|
||||
pub users: UsersTable,
|
||||
pub sessions: SessionsTable,
|
||||
pub blobs: BlobsTable,
|
||||
pub entries: EntriesTable,
|
||||
pub events: EventsTable,
|
||||
@@ -23,6 +29,12 @@ pub struct Tables {
|
||||
impl Tables {
|
||||
pub fn new(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
users: env
|
||||
.open_database(wtxn, Some(USERS_TABLE))?
|
||||
.expect("Users table already created"),
|
||||
sessions: env
|
||||
.open_database(wtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Sessions table already created"),
|
||||
blobs: env
|
||||
.open_database(wtxn, Some(BLOBS_TABLE))?
|
||||
.expect("Blobs table already created"),
|
||||
|
||||
@@ -5,7 +5,6 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use pubky_common::auth::AuthnSignatureError;
|
||||
|
||||
pub type Result<T, E = Error> = core::result::Result<T, E>;
|
||||
|
||||
@@ -71,8 +70,8 @@ impl From<PathRejection> for Error {
|
||||
|
||||
// === Pubky specific errors ===
|
||||
|
||||
impl From<AuthnSignatureError> for Error {
|
||||
fn from(error: AuthnSignatureError) -> Self {
|
||||
impl From<pubky_common::auth::Error> for Error {
|
||||
fn from(error: pubky_common::auth::Error) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, Some(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ mod root;
|
||||
fn base(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root::handler))
|
||||
.route("/:pubky", put(auth::signup))
|
||||
.route("/signup", post(auth::signup))
|
||||
.route("/session", post(auth::signin))
|
||||
.route("/:pubky/session", get(auth::session))
|
||||
.route("/:pubky/session", post(auth::signin))
|
||||
.route("/:pubky/session", delete(auth::signout))
|
||||
.route("/:pubky/*path", put(public::put))
|
||||
.route("/:pubky/*path", get(public::get))
|
||||
|
||||
@@ -13,7 +13,7 @@ use pubky_common::{crypto::random_bytes, session::Session, timestamp::Timestamp}
|
||||
use crate::{
|
||||
database::tables::{
|
||||
sessions::{SessionsTable, SESSIONS_TABLE},
|
||||
users::{User, UsersTable, USERS_TABLE},
|
||||
users::User,
|
||||
},
|
||||
error::{Error, Result},
|
||||
extractors::Pubky,
|
||||
@@ -25,13 +25,12 @@ pub async fn signup(
|
||||
State(state): State<AppState>,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
cookies: Cookies,
|
||||
pubky: Pubky,
|
||||
uri: Uri,
|
||||
body: Bytes,
|
||||
) -> Result<impl IntoResponse> {
|
||||
// TODO: Verify invitation link.
|
||||
// TODO: add errors in case of already axisting user.
|
||||
signin(State(state), user_agent, cookies, pubky, uri, body).await
|
||||
signin(State(state), user_agent, cookies, uri, body).await
|
||||
}
|
||||
|
||||
pub async fn session(
|
||||
@@ -90,21 +89,16 @@ pub async fn signin(
|
||||
State(state): State<AppState>,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
cookies: Cookies,
|
||||
pubky: Pubky,
|
||||
uri: Uri,
|
||||
body: Bytes,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let public_key = pubky.public_key();
|
||||
let token = state.verifier.verify(&body)?;
|
||||
|
||||
state.verifier.verify(&body, public_key)?;
|
||||
let public_key = token.pubky();
|
||||
|
||||
let mut wtxn = state.db.env.write_txn()?;
|
||||
let users: UsersTable = state
|
||||
.db
|
||||
.env
|
||||
.open_database(&wtxn, Some(USERS_TABLE))?
|
||||
.expect("Users table already created");
|
||||
|
||||
let users = state.db.tables.users;
|
||||
if let Some(existing) = users.get(&wtxn, public_key)? {
|
||||
users.put(&mut wtxn, public_key, &existing)?;
|
||||
} else {
|
||||
@@ -119,21 +113,16 @@ pub async fn signin(
|
||||
|
||||
let session_secret = base32::encode(base32::Alphabet::Crockford, &random_bytes::<16>());
|
||||
|
||||
let sessions: SessionsTable = state
|
||||
let session = Session::new(&token, user_agent.map(|ua| ua.to_string())).serialize();
|
||||
|
||||
state
|
||||
.db
|
||||
.env
|
||||
.open_database(&wtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Sessions table already created");
|
||||
|
||||
let mut session = Session::new();
|
||||
|
||||
if let Some(user_agent) = user_agent {
|
||||
session.set_user_agent(user_agent.to_string());
|
||||
}
|
||||
|
||||
sessions.put(&mut wtxn, &session_secret, &session.serialize())?;
|
||||
.tables
|
||||
.sessions
|
||||
.put(&mut wtxn, &session_secret, &session)?;
|
||||
|
||||
let mut cookie = Cookie::new(public_key.to_string(), session_secret);
|
||||
|
||||
cookie.set_path("/");
|
||||
if *uri.scheme().unwrap_or(&Scheme::HTTP) == Scheme::HTTPS {
|
||||
cookie.set_secure(true);
|
||||
@@ -145,5 +134,5 @@ pub async fn signin(
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
Ok(())
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ pub async fn put(
|
||||
let public_key = pubky.public_key().clone();
|
||||
let path = path.as_str();
|
||||
|
||||
authorize(&mut state, cookies, &public_key, path)?;
|
||||
verify(path)?;
|
||||
authorize(&mut state, cookies, &public_key, path)?;
|
||||
|
||||
let mut stream = body.into_data_stream();
|
||||
|
||||
@@ -134,20 +134,32 @@ pub async fn delete(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authorize write (PUT or DELETE) for Public paths.
|
||||
fn authorize(
|
||||
state: &mut AppState,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
_: &str,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
// TODO: can we move this logic to the extractor or a layer
|
||||
// to perform this validation?
|
||||
let _ = state
|
||||
let session = state
|
||||
.db
|
||||
.get_session(cookies, public_key)?
|
||||
.ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
Ok(())
|
||||
if session.pubky() == public_key
|
||||
&& session.capabilities().iter().any(|cap| {
|
||||
path.starts_with(&cap.scope[1..])
|
||||
&& cap
|
||||
.actions
|
||||
.contains(&pubky_common::capabilities::Action::Write)
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(Error::with_status(StatusCode::FORBIDDEN))
|
||||
}
|
||||
|
||||
fn verify(path: &str) -> Result<()> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{future::IntoFuture, net::SocketAddr};
|
||||
|
||||
use anyhow::{Error, Result};
|
||||
use pubky_common::auth::AuthnVerifier;
|
||||
use pubky_common::auth::AuthVerifier;
|
||||
use tokio::{net::TcpListener, signal, task::JoinSet};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct Homeserver {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AppState {
|
||||
pub verifier: AuthnVerifier,
|
||||
pub verifier: AuthVerifier,
|
||||
pub db: DB,
|
||||
pub pkarr_client: PkarrClientAsync,
|
||||
}
|
||||
@@ -31,7 +31,6 @@ impl Homeserver {
|
||||
debug!(?config);
|
||||
|
||||
let keypair = config.keypair();
|
||||
let public_key = keypair.public_key();
|
||||
|
||||
let db = DB::open(&config.storage()?)?;
|
||||
|
||||
@@ -46,7 +45,7 @@ impl Homeserver {
|
||||
.as_async();
|
||||
|
||||
let state = AppState {
|
||||
verifier: AuthnVerifier::new(public_key.clone()),
|
||||
verifier: AuthVerifier::default(),
|
||||
db,
|
||||
pkarr_client: pkarr_client.clone(),
|
||||
};
|
||||
@@ -75,7 +74,7 @@ impl Homeserver {
|
||||
|
||||
publish_server_packet(pkarr_client, &keypair, config.domain(), port).await?;
|
||||
|
||||
info!("Homeserver listening on pubky://{public_key}");
|
||||
info!("Homeserver listening on pubky://{}", keypair.public_key());
|
||||
|
||||
Ok(Self {
|
||||
tasks,
|
||||
|
||||
@@ -14,17 +14,17 @@ crate-type = ["cdylib", "rlib"]
|
||||
thiserror = "1.0.62"
|
||||
wasm-bindgen = "0.2.92"
|
||||
url = "2.5.2"
|
||||
bytes = "1.6.1"
|
||||
bytes = "^1.7.1"
|
||||
base64 = "0.22.1"
|
||||
|
||||
pubky-common = { version = "0.1.0", path = "../pubky-common" }
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
pkarr = { workspace = true, features = ["async"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
pkarr = { version="2.1.0", features = ["async"] }
|
||||
reqwest = { version = "0.12.5", features = ["cookies"], default-features = false }
|
||||
reqwest = { version = "0.12.5", features = ["cookies", "rustls-tls"], default-features = false }
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
pkarr = { version = "2.1.0", default-features = false }
|
||||
reqwest = { version = "0.12.5", default-features = false }
|
||||
|
||||
js-sys = "0.3.69"
|
||||
|
||||
@@ -67,22 +67,6 @@ await client.delete(url);
|
||||
let client = new PubkyClient()
|
||||
```
|
||||
|
||||
#### createRecoveryFile
|
||||
```js
|
||||
let recoveryFile = PubkyClient.createRecoveryFile(keypair, passphrase)
|
||||
```
|
||||
- keypair: An instance of [Keypair](#keypair).
|
||||
- passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/).
|
||||
- Returns: A recovery file with a spec line and an encrypted secret key.
|
||||
|
||||
#### createRecoveryFile
|
||||
```js
|
||||
let keypair = PubkyClient.decryptRecoveryfile(recoveryFile, passphrase)
|
||||
```
|
||||
- recoveryFile: An instance of Uint8Array containing the recovery file blob.
|
||||
- passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/).
|
||||
- Returns: An instance of [Keypair](#keypair).
|
||||
|
||||
#### signup
|
||||
```js
|
||||
await client.signup(keypair, homeserver)
|
||||
@@ -90,12 +74,58 @@ await client.signup(keypair, homeserver)
|
||||
- keypair: An instance of [Keypair](#keypair).
|
||||
- homeserver: An instance of [PublicKey](#publickey) representing the homeserver.
|
||||
|
||||
#### session
|
||||
Returns:
|
||||
- session: An instance of [Session](#session).
|
||||
|
||||
#### signin
|
||||
```js
|
||||
let session = await client.signin(keypair)
|
||||
```
|
||||
- keypair: An instance of [Keypair](#keypair).
|
||||
|
||||
Returns:
|
||||
- An instance of [Session](#session).
|
||||
|
||||
#### signout
|
||||
```js
|
||||
await client.signout(publicKey)
|
||||
```
|
||||
- publicKey: An instance of [PublicKey](#publicKey).
|
||||
|
||||
#### authRequest
|
||||
```js
|
||||
let [pubkyauthUrl, sessionPromise] = client.authRequest(relay, capabilities);
|
||||
|
||||
showQr(pubkyauthUrl);
|
||||
|
||||
let session = await sessionPromise;
|
||||
```
|
||||
|
||||
Sign in to a user's Homeserver, without access to their [Keypair](#keypair), nor even [PublicKey](#publickey),
|
||||
instead request permissions (showing the user pubkyauthUrl), and await a Session after the user consenting to that request.
|
||||
|
||||
- relay: A URL to an [HTTP relay](https://httprelay.io/features/link/) endpoint.
|
||||
- capabilities: A list of capabilities required for the app for example `/pub/pubky.app/:rw,/pub/example.com/:r`.
|
||||
|
||||
Returns:
|
||||
- pubkyauthUrl: A url to show to the user to scan or paste into an Authenticator app holding the user [Keypair](#keypair)
|
||||
- sessionPromise: A promise that resolves into a [Session](#session) on success.
|
||||
|
||||
#### sendAuthToken
|
||||
```js
|
||||
await client.sendAuthToken(keypair, pubkyauthUrl);
|
||||
```
|
||||
Consenting to authentication or authorization according to the required capabilities in the `pubkyauthUrl` , and sign and send an auth token to the requester.
|
||||
|
||||
- keypair: An instance of [KeyPair](#keypair)
|
||||
- pubkyauthUrl: A string `pubkyauth://` url
|
||||
|
||||
#### session {#session-method}
|
||||
```js
|
||||
let session = await client.session(publicKey)
|
||||
```
|
||||
- publicKey: An instance of [PublicKey](#publickey).
|
||||
- Returns: A session object if signed in, or undefined if not.
|
||||
- Returns: A [Session](#session) object if signed in, or undefined if not.
|
||||
|
||||
#### put
|
||||
```js
|
||||
@@ -144,7 +174,7 @@ let keypair = Keypair.fromSecretKey(secretKey)
|
||||
- Returns: A new Keypair.
|
||||
|
||||
|
||||
#### publicKey
|
||||
#### publicKey {#publickey-method}
|
||||
```js
|
||||
let publicKey = keypair.publicKey()
|
||||
```
|
||||
@@ -172,6 +202,38 @@ let pubky = publicKey.z32();
|
||||
```
|
||||
Returns: The z-base-32 encoded string representation of the PublicKey.
|
||||
|
||||
### Session
|
||||
|
||||
#### pubky
|
||||
```js
|
||||
let pubky = session.pubky();
|
||||
```
|
||||
Returns an instance of [PublicKey](#publickey)
|
||||
|
||||
#### capabilities
|
||||
```js
|
||||
let capabilities = session.capabilities();
|
||||
```
|
||||
Returns an array of capabilities, for example `["/pub/pubky.app/:rw"]`
|
||||
|
||||
### Helper functions
|
||||
|
||||
#### createRecoveryFile
|
||||
```js
|
||||
let recoveryFile = createRecoveryFile(keypair, passphrase)
|
||||
```
|
||||
- keypair: An instance of [Keypair](#keypair).
|
||||
- passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/).
|
||||
- Returns: A recovery file with a spec line and an encrypted secret key.
|
||||
|
||||
#### createRecoveryFile
|
||||
```js
|
||||
let keypair = decryptRecoveryfile(recoveryFile, passphrase)
|
||||
```
|
||||
- recoveryFile: An instance of Uint8Array containing the recovery file blob.
|
||||
- passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/).
|
||||
- Returns: An instance of [Keypair](#keypair).
|
||||
|
||||
## Test and Development
|
||||
|
||||
For test and development, you can run a local homeserver in a test network.
|
||||
|
||||
@@ -2,14 +2,15 @@ import test from 'tape'
|
||||
|
||||
import { PubkyClient, Keypair, PublicKey } from '../index.cjs'
|
||||
|
||||
const Homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
|
||||
test('auth', async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
|
||||
const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
await client.signup(keypair, homeserver)
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.ok(session, "signup")
|
||||
@@ -28,3 +29,35 @@ test('auth', async (t) => {
|
||||
t.ok(session, "signin")
|
||||
}
|
||||
})
|
||||
|
||||
test("3rd party signin", async (t) => {
|
||||
let keypair = Keypair.random();
|
||||
let pubky = keypair.publicKey().z32();
|
||||
|
||||
// Third party app side
|
||||
let capabilities = "/pub/pubky.app/:rw,/pub/foo.bar/file:r";
|
||||
let client = PubkyClient.testnet();
|
||||
let [pubkyauth_url, pubkyauthResponse] = client
|
||||
.authRequest("https://demo.httprelay.io/link", capabilities);
|
||||
|
||||
if (globalThis.document) {
|
||||
// Skip `sendAuthToken` in browser
|
||||
// TODO: figure out why does it fail in browser unit tests
|
||||
// but not in real browser (check pubky-auth-widget.js commented part)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticator side
|
||||
{
|
||||
let client = PubkyClient.testnet();
|
||||
|
||||
await client.signup(keypair, Homeserver);
|
||||
|
||||
await client.sendAuthToken(keypair, pubkyauth_url)
|
||||
}
|
||||
|
||||
let session = await pubkyauthResponse;
|
||||
|
||||
t.is(session.pubky().z32(), pubky)
|
||||
t.deepEqual(session.capabilities(), capabilities.split(','))
|
||||
})
|
||||
|
||||
@@ -2,13 +2,14 @@ import test from 'tape'
|
||||
|
||||
import { PubkyClient, Keypair, PublicKey } from '../index.cjs'
|
||||
|
||||
const Homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo');
|
||||
|
||||
test('public: put/get', async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random();
|
||||
|
||||
const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo');
|
||||
await client.signup(keypair, homeserver);
|
||||
await client.signup(keypair, Homeserver);
|
||||
|
||||
const publicKey = keypair.publicKey();
|
||||
|
||||
@@ -46,8 +47,7 @@ test("not found", async (t) => {
|
||||
|
||||
const keypair = Keypair.random();
|
||||
|
||||
const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo');
|
||||
await client.signup(keypair, homeserver);
|
||||
await client.signup(keypair, Homeserver);
|
||||
|
||||
const publicKey = keypair.publicKey();
|
||||
|
||||
@@ -64,8 +64,7 @@ test("unauthorized", async (t) => {
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
|
||||
const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
await client.signup(keypair, homeserver)
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.ok(session, "signup")
|
||||
@@ -92,8 +91,7 @@ test("forbidden", async (t) => {
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
|
||||
const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
await client.signup(keypair, homeserver)
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.ok(session, "signup")
|
||||
@@ -119,8 +117,7 @@ test("list", async (t) => {
|
||||
const publicKey = keypair.publicKey()
|
||||
const pubky = publicKey.z32()
|
||||
|
||||
const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
await client.signup(keypair, homeserver)
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
|
||||
|
||||
@@ -251,10 +248,7 @@ test('list shallow', async (t) => {
|
||||
const publicKey = keypair.publicKey()
|
||||
const pubky = publicKey.z32()
|
||||
|
||||
const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
await client.signup(keypair, homeserver)
|
||||
|
||||
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
let urls = [
|
||||
`pubky://${pubky}/pub/a.com/a.txt`,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import test from 'tape'
|
||||
|
||||
import { PubkyClient, Keypair } from '../index.cjs'
|
||||
import { Keypair, createRecoveryFile, decryptRecoveryFile } from '../index.cjs'
|
||||
|
||||
test('recovery', async (t) => {
|
||||
const keypair = Keypair.random();
|
||||
|
||||
const recoveryFile = PubkyClient.createRecoveryFile(keypair, 'very secure password');
|
||||
const recoveryFile = createRecoveryFile(keypair, 'very secure password');
|
||||
|
||||
t.is(recoveryFile.length, 91)
|
||||
t.deepEqual(
|
||||
@@ -13,7 +13,7 @@ test('recovery', async (t) => {
|
||||
[112, 117, 98, 107, 121, 46, 111, 114, 103, 47, 114, 101, 99, 111, 118, 101, 114, 121, 10]
|
||||
)
|
||||
|
||||
const recovered = PubkyClient.decryptRecoveryFile(recoveryFile, 'very secure password')
|
||||
const recovered = decryptRecoveryFile(recoveryFile, 'very secure password')
|
||||
|
||||
t.is(recovered.publicKey().z32(), keypair.publicKey().z32())
|
||||
})
|
||||
|
||||
@@ -20,17 +20,15 @@ const patched = content
|
||||
.replace("require(`util`)", "globalThis")
|
||||
// attach to `imports` instead of module.exports
|
||||
.replace("= module.exports", "= imports")
|
||||
|
||||
// add suffix Class
|
||||
.replace(/\nclass (.*?) \{/g, "\nclass $1Class {")
|
||||
.replace(/\nmodule\.exports\.(.*?) = (.*?);/g, "\nexport const $1 = imports.$1 = $1Class")
|
||||
|
||||
// quick and dirty fix for a bug caused by the previous replace
|
||||
.replace(/__wasmClass/g, "wasm")
|
||||
|
||||
.replace(/\nmodule\.exports\.(.*?)\s+/g, "\nexport const $1 = imports.$1 ")
|
||||
// Export classes
|
||||
.replace(/\nclass (.*?) \{/g, "\n export class $1 {")
|
||||
// Export functions
|
||||
.replace(/\nmodule.exports.(.*?) = function/g, "\nimports.$1 = $1;\nexport function $1")
|
||||
// Add exports to 'imports'
|
||||
.replace(/\nmodule\.exports\.(.*?)\s+/g, "\nimports.$1")
|
||||
// Export default
|
||||
.replace(/$/, 'export default imports')
|
||||
// inline bytes Uint8Array
|
||||
// inline wasm bytes
|
||||
.replace(
|
||||
/\nconst path.*\nconst bytes.*\n/,
|
||||
`
|
||||
@@ -56,7 +54,7 @@ const bytes = __toBinary(${JSON.stringify(await readFile(path.join(__dirname, `.
|
||||
`,
|
||||
);
|
||||
|
||||
await writeFile(path.join(__dirname, `../../pkg/browser.js`), patched);
|
||||
await writeFile(path.join(__dirname, `../../pkg/browser.js`), patched + "\nglobalThis['pubky'] = imports");
|
||||
|
||||
// Move outside of nodejs
|
||||
|
||||
|
||||
@@ -15,19 +15,6 @@ pub enum Error {
|
||||
#[error("Could not resolve endpoint for {0}")]
|
||||
ResolveEndpoint(String),
|
||||
|
||||
// === Recovery file ==
|
||||
#[error("Recovery file should start with a spec line, followed by a new line character")]
|
||||
RecoveryFileMissingSpecLine,
|
||||
|
||||
#[error("Recovery file should start with a spec line, followed by a new line character")]
|
||||
RecoveryFileVersionNotSupported,
|
||||
|
||||
#[error("Recovery file should contain an encrypted secret key after the new line character")]
|
||||
RecoverFileMissingEncryptedSecretKey,
|
||||
|
||||
#[error("Recovery file encrypted secret key should be 32 bytes, got {0}")]
|
||||
RecoverFileInvalidSecretKeyLength(usize),
|
||||
|
||||
#[error("Could not convert the passed type into a Url")]
|
||||
InvalidUrl,
|
||||
|
||||
@@ -51,7 +38,10 @@ pub enum Error {
|
||||
Crypto(#[from] pubky_common::crypto::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Argon(#[from] argon2::Error),
|
||||
RecoveryFile(#[from] pubky_common::recovery_file::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
AuthToken(#[from] pubky_common::auth::Error),
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
use ::pkarr::{mainline::dht::Testnet, PkarrClient, PublicKey, SignedPacket};
|
||||
use bytes::Bytes;
|
||||
use pkarr::Keypair;
|
||||
use pubky_common::session::Session;
|
||||
use pubky_common::{
|
||||
capabilities::Capabilities,
|
||||
recovery_file::{create_recovery_file, decrypt_recovery_file},
|
||||
session::Session,
|
||||
};
|
||||
use reqwest::{RequestBuilder, Response};
|
||||
use tokio::sync::oneshot;
|
||||
use url::Url;
|
||||
|
||||
use pkarr::Keypair;
|
||||
|
||||
use ::pkarr::{mainline::dht::Testnet, PkarrClient, PublicKey, SignedPacket};
|
||||
|
||||
use crate::{
|
||||
error::Result,
|
||||
shared::{
|
||||
list_builder::ListBuilder,
|
||||
recovery_file::{create_recovery_file, decrypt_recovery_file},
|
||||
},
|
||||
error::{Error, Result},
|
||||
shared::list_builder::ListBuilder,
|
||||
PubkyClient,
|
||||
};
|
||||
|
||||
@@ -84,6 +88,15 @@ impl PubkyClient {
|
||||
PubkyClientBuilder::default()
|
||||
}
|
||||
|
||||
/// Create a client connected to the local network
|
||||
/// with the bootstrapping node: `localhost:6881`
|
||||
pub fn testnet() -> Self {
|
||||
Self::test(&Testnet {
|
||||
bootstrap: vec!["localhost:6881".to_string()],
|
||||
nodes: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a [PubkyClient] with:
|
||||
/// - DHT bootstrap nodes set to the `testnet` bootstrap nodes.
|
||||
/// - DHT request timout set to 500 milliseconds. (unless in CI, then it is left as default 2000)
|
||||
@@ -105,7 +118,7 @@ impl PubkyClient {
|
||||
///
|
||||
/// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key
|
||||
/// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"
|
||||
pub async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result<()> {
|
||||
pub async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result<Session> {
|
||||
self.inner_signup(keypair, homeserver).await
|
||||
}
|
||||
|
||||
@@ -123,7 +136,7 @@ impl PubkyClient {
|
||||
}
|
||||
|
||||
/// Signin to a homeserver.
|
||||
pub async fn signin(&self, keypair: &Keypair) -> Result<()> {
|
||||
pub async fn signin(&self, keypair: &Keypair) -> Result<Session> {
|
||||
self.inner_signin(keypair).await
|
||||
}
|
||||
|
||||
@@ -156,12 +169,58 @@ impl PubkyClient {
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Vec<u8>> {
|
||||
create_recovery_file(keypair, passphrase)
|
||||
Ok(create_recovery_file(keypair, passphrase)?)
|
||||
}
|
||||
|
||||
/// Recover a keypair from a recovery file by decrypting the secret key using `passphrase`.
|
||||
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair> {
|
||||
decrypt_recovery_file(recovery_file, passphrase)
|
||||
Ok(decrypt_recovery_file(recovery_file, passphrase)?)
|
||||
}
|
||||
|
||||
/// Return `pubkyauth://` url and wait for the incoming [AuthToken]
|
||||
/// verifying that AuthToken, and if capabilities were requested, signing in to
|
||||
/// the Pubky's homeserver and returning the [Session] information.
|
||||
pub fn auth_request(
|
||||
&self,
|
||||
relay: impl TryInto<Url>,
|
||||
capabilities: &Capabilities,
|
||||
) -> Result<(Url, tokio::sync::oneshot::Receiver<Option<Session>>)> {
|
||||
let mut relay: Url = relay
|
||||
.try_into()
|
||||
.map_err(|_| Error::Generic("Invalid relay Url".into()))?;
|
||||
|
||||
let (pubkyauth_url, client_secret) = self.create_auth_request(&mut relay, capabilities)?;
|
||||
|
||||
let (tx, rx) = oneshot::channel::<Option<Session>>();
|
||||
|
||||
let this = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let to_send = this
|
||||
.subscribe_to_auth_response(relay, &client_secret)
|
||||
.await?;
|
||||
|
||||
tx.send(to_send)
|
||||
.map_err(|_| Error::Generic("Failed to send the session after signing in with token, since the receiver is dropped".into()))?;
|
||||
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
|
||||
Ok((pubkyauth_url, rx))
|
||||
}
|
||||
|
||||
/// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the
|
||||
/// source of the pubkyauth request url.
|
||||
pub async fn send_auth_token<T: TryInto<Url>>(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
pubkyauth_url: T,
|
||||
) -> Result<()> {
|
||||
let url: Url = pubkyauth_url.try_into().map_err(|_| Error::InvalidUrl)?;
|
||||
|
||||
self.inner_send_auth_token(keypair, url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +246,6 @@ impl PubkyClient {
|
||||
self.http.request(method, url)
|
||||
}
|
||||
|
||||
pub(crate) fn store_session(&self, _: Response) {}
|
||||
pub(crate) fn store_session(&self, _: &Response) {}
|
||||
pub(crate) fn remove_session(&self, _: &PublicKey) {}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use base64::{alphabet::URL_SAFE, engine::general_purpose::NO_PAD, Engine};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use url::Url;
|
||||
|
||||
use pkarr::{Keypair, PublicKey};
|
||||
use pubky_common::{auth::AuthnSignature, session::Session};
|
||||
use pubky_common::{
|
||||
auth::AuthToken,
|
||||
capabilities::{Capabilities, Capability},
|
||||
crypto::{decrypt, encrypt, hash, random_bytes},
|
||||
session::Session,
|
||||
};
|
||||
|
||||
use crate::{error::Result, PubkyClient};
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
PubkyClient,
|
||||
};
|
||||
|
||||
use super::pkarr::Endpoint;
|
||||
|
||||
@@ -16,33 +28,28 @@ impl PubkyClient {
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
homeserver: &PublicKey,
|
||||
) -> Result<()> {
|
||||
) -> Result<Session> {
|
||||
let homeserver = homeserver.to_string();
|
||||
|
||||
let public_key = &keypair.public_key();
|
||||
let Endpoint { mut url, .. } = self.resolve_endpoint(&homeserver).await?;
|
||||
|
||||
let Endpoint {
|
||||
public_key: audience,
|
||||
mut url,
|
||||
} = self.resolve_endpoint(&homeserver).await?;
|
||||
url.set_path("/signup");
|
||||
|
||||
url.set_path(&format!("/{}", public_key));
|
||||
|
||||
let body = AuthnSignature::generate(keypair, &audience)
|
||||
.as_bytes()
|
||||
.to_owned();
|
||||
let body = AuthToken::sign(keypair, vec![Capability::root()]).serialize();
|
||||
|
||||
let response = self
|
||||
.request(Method::PUT, url.clone())
|
||||
.request(Method::POST, url.clone())
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
self.store_session(response);
|
||||
self.store_session(&response);
|
||||
|
||||
self.publish_pubky_homeserver(keypair, &homeserver).await?;
|
||||
|
||||
Ok(())
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
Ok(Session::deserialize(&bytes)?)
|
||||
}
|
||||
|
||||
/// Check the current sesison for a given Pubky in its homeserver.
|
||||
@@ -83,26 +90,141 @@ impl PubkyClient {
|
||||
}
|
||||
|
||||
/// Signin to a homeserver.
|
||||
pub(crate) async fn inner_signin(&self, keypair: &Keypair) -> Result<()> {
|
||||
let pubky = keypair.public_key();
|
||||
pub(crate) async fn inner_signin(&self, keypair: &Keypair) -> Result<Session> {
|
||||
let token = AuthToken::sign(keypair, vec![Capability::root()]);
|
||||
|
||||
let Endpoint {
|
||||
public_key: audience,
|
||||
mut url,
|
||||
} = self.resolve_pubky_homeserver(&pubky).await?;
|
||||
self.signin_with_authtoken(&token).await
|
||||
}
|
||||
|
||||
url.set_path(&format!("/{}/session", &pubky));
|
||||
pub(crate) async fn inner_send_auth_token(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
pubkyauth_url: Url,
|
||||
) -> Result<()> {
|
||||
let query_params: HashMap<String, String> =
|
||||
pubkyauth_url.query_pairs().into_owned().collect();
|
||||
|
||||
let body = AuthnSignature::generate(keypair, &audience)
|
||||
.as_bytes()
|
||||
.to_owned();
|
||||
let relay = query_params
|
||||
.get("relay")
|
||||
.map(|r| url::Url::parse(r).expect("Relay query param to be valid URL"))
|
||||
.expect("Missing relay query param");
|
||||
|
||||
let response = self.request(Method::POST, url).body(body).send().await?;
|
||||
let client_secret = query_params
|
||||
.get("secret")
|
||||
.map(|s| {
|
||||
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
||||
let bytes = engine.decode(s).expect("invalid client_secret");
|
||||
let arr: [u8; 32] = bytes.try_into().expect("invalid client_secret");
|
||||
|
||||
self.store_session(response);
|
||||
arr
|
||||
})
|
||||
.expect("Missing client secret");
|
||||
|
||||
let capabilities = query_params
|
||||
.get("caps")
|
||||
.map(|caps_string| {
|
||||
caps_string
|
||||
.split(',')
|
||||
.filter_map(|cap| Capability::try_from(cap).ok())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = AuthToken::sign(keypair, capabilities);
|
||||
|
||||
let encrypted_token = encrypt(&token.serialize(), &client_secret)?;
|
||||
|
||||
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
||||
|
||||
let mut callback = relay.clone();
|
||||
let mut path_segments = callback.path_segments_mut().unwrap();
|
||||
path_segments.pop_if_empty();
|
||||
let channel_id = engine.encode(hash(&client_secret).as_bytes());
|
||||
path_segments.push(&channel_id);
|
||||
drop(path_segments);
|
||||
|
||||
self.request(Method::POST, callback)
|
||||
.body(encrypted_token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn inner_third_party_signin(
|
||||
&self,
|
||||
encrypted_token: &[u8],
|
||||
client_secret: &[u8; 32],
|
||||
) -> Result<PublicKey> {
|
||||
let decrypted = decrypt(encrypted_token, client_secret)?;
|
||||
let token = AuthToken::deserialize(&decrypted)?;
|
||||
|
||||
self.signin_with_authtoken(&token).await?;
|
||||
|
||||
Ok(token.pubky().to_owned())
|
||||
}
|
||||
|
||||
pub async fn signin_with_authtoken(&self, token: &AuthToken) -> Result<Session> {
|
||||
let mut url = Url::parse(&format!("https://{}/session", token.pubky()))?;
|
||||
|
||||
self.resolve_url(&mut url).await?;
|
||||
|
||||
let response = self
|
||||
.request(Method::POST, url)
|
||||
.body(token.serialize())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
self.store_session(&response);
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
Ok(Session::deserialize(&bytes)?)
|
||||
}
|
||||
|
||||
pub(crate) fn create_auth_request(
|
||||
&self,
|
||||
relay: &mut Url,
|
||||
capabilities: &Capabilities,
|
||||
) -> Result<(Url, [u8; 32])> {
|
||||
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
||||
|
||||
let client_secret: [u8; 32] = random_bytes::<32>();
|
||||
|
||||
let pubkyauth_url = Url::parse(&format!(
|
||||
"pubkyauth:///?caps={capabilities}&secret={}&relay={relay}",
|
||||
engine.encode(client_secret)
|
||||
))?;
|
||||
|
||||
let mut segments = relay
|
||||
.path_segments_mut()
|
||||
.map_err(|_| Error::Generic("Invalid relay".into()))?;
|
||||
// remove trailing slash if any.
|
||||
segments.pop_if_empty();
|
||||
let channel_id = &engine.encode(hash(&client_secret).as_bytes());
|
||||
segments.push(channel_id);
|
||||
drop(segments);
|
||||
|
||||
Ok((pubkyauth_url, client_secret))
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribe_to_auth_response(
|
||||
&self,
|
||||
relay: Url,
|
||||
client_secret: &[u8; 32],
|
||||
) -> Result<Option<Session>> {
|
||||
let response = self.http.request(Method::GET, relay).send().await?;
|
||||
let encrypted_token = response.bytes().await?;
|
||||
let token_bytes = decrypt(&encrypted_token, client_secret)?;
|
||||
let token = AuthToken::verify(&token_bytes)?;
|
||||
|
||||
if token.capabilities().is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
let session = self.signin_with_authtoken(&token).await?;
|
||||
Ok(Some(session))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -111,8 +233,9 @@ mod tests {
|
||||
use crate::*;
|
||||
|
||||
use pkarr::{mainline::Testnet, Keypair};
|
||||
use pubky_common::session::Session;
|
||||
use pubky_common::capabilities::{Capabilities, Capability};
|
||||
use pubky_homeserver::Homeserver;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn basic_authn() {
|
||||
@@ -131,7 +254,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session, Session { ..session.clone() });
|
||||
assert!(session.capabilities().contains(&Capability::root()));
|
||||
|
||||
client.signout(&keypair.public_key()).await.unwrap();
|
||||
|
||||
@@ -150,7 +273,71 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session, Session { ..session.clone() });
|
||||
assert_eq!(session.pubky(), &keypair.public_key());
|
||||
assert!(session.capabilities().contains(&Capability::root()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authz() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let keypair = Keypair::random();
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
// Third party app side
|
||||
let capabilities: Capabilities =
|
||||
"/pub/pubky.app/:rw,/pub/foo.bar/file:r".try_into().unwrap();
|
||||
let client = PubkyClient::test(&testnet);
|
||||
let (pubkyauth_url, pubkyauth_response) = client
|
||||
.auth_request("https://demo.httprelay.io/link", &capabilities)
|
||||
.unwrap();
|
||||
|
||||
// Authenticator side
|
||||
{
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
client
|
||||
.send_auth_token(&keypair, pubkyauth_url)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let session = pubkyauth_response.await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(session.pubky(), &pubky);
|
||||
assert_eq!(session.capabilities(), &capabilities.0);
|
||||
|
||||
// Test access control enforcement
|
||||
|
||||
client
|
||||
.put(format!("pubky://{pubky}/pub/pubky.app/foo").as_str(), &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client
|
||||
.put(format!("pubky://{pubky}/pub/pubky.app").as_str(), &[])
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::Error::Reqwest(e) => e.status(),
|
||||
_ => None,
|
||||
}),
|
||||
Err(Some(StatusCode::FORBIDDEN))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
client
|
||||
.put(format!("pubky://{pubky}/pub/foo.bar/file").as_str(), &[])
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::Error::Reqwest(e) => e.status(),
|
||||
_ => None,
|
||||
}),
|
||||
Err(Some(StatusCode::FORBIDDEN))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,3 @@ pub mod auth;
|
||||
pub mod list_builder;
|
||||
pub mod pkarr;
|
||||
pub mod public;
|
||||
pub mod recovery_file;
|
||||
|
||||
@@ -132,9 +132,15 @@ impl PubkyClient {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if PublicKey::try_from(origin.as_str()).is_ok() {
|
||||
return Err(Error::ResolveEndpoint(original_target.into()));
|
||||
}
|
||||
|
||||
if let Some(public_key) = endpoint_public_key {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://{}",
|
||||
@@ -151,10 +157,23 @@ impl PubkyClient {
|
||||
|
||||
Err(Error::ResolveEndpoint(original_target.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_url(&self, url: &mut Url) -> Result<()> {
|
||||
if let Some(Ok(pubky)) = url.host_str().map(PublicKey::try_from) {
|
||||
let Endpoint { url: x, .. } = self.resolve_endpoint(&format!("_pubky.{pubky}")).await?;
|
||||
|
||||
url.set_host(x.host_str())?;
|
||||
url.set_port(x.port()).expect("should work!");
|
||||
url.set_scheme(x.scheme()).expect("should work!");
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Endpoint {
|
||||
// TODO: we don't use this at all?
|
||||
pub public_key: PublicKey,
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ use std::{
|
||||
};
|
||||
|
||||
use js_sys::{Array, Uint8Array};
|
||||
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use reqwest::{IntoUrl, Method, RequestBuilder, Response};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
shared::recovery_file::{create_recovery_file, decrypt_recovery_file},
|
||||
PubkyClient,
|
||||
};
|
||||
use pubky_common::capabilities::Capabilities;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::PubkyClient;
|
||||
|
||||
mod http;
|
||||
mod keys;
|
||||
mod pkarr;
|
||||
mod recovery_file;
|
||||
mod session;
|
||||
|
||||
use keys::{Keypair, PublicKey};
|
||||
@@ -53,30 +53,6 @@ impl PubkyClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
#[wasm_bindgen(js_name = "createRecoveryFile")]
|
||||
pub fn create_recovery_file(
|
||||
keypair: &Keypair,
|
||||
passphrase: &str,
|
||||
) -> Result<Uint8Array, JsValue> {
|
||||
create_recovery_file(keypair.as_inner(), passphrase)
|
||||
.map(|b| b.as_slice().into())
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
#[wasm_bindgen(js_name = "decryptRecoveryFile")]
|
||||
pub fn decrypt_recovery_file(
|
||||
recovery_file: &[u8],
|
||||
passphrase: &str,
|
||||
) -> Result<Keypair, JsValue> {
|
||||
decrypt_recovery_file(recovery_file, passphrase)
|
||||
.map(Keypair::from)
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Set Pkarr relays used for publishing and resolving Pkarr packets.
|
||||
///
|
||||
/// By default, [PubkyClient] will use `["https://relay.pkarr.org"]`
|
||||
@@ -97,10 +73,16 @@ impl PubkyClient {
|
||||
/// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key
|
||||
/// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"
|
||||
#[wasm_bindgen]
|
||||
pub async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result<(), JsValue> {
|
||||
self.inner_signup(keypair.as_inner(), homeserver.as_inner())
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
pub async fn signup(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
homeserver: &PublicKey,
|
||||
) -> Result<Session, JsValue> {
|
||||
Ok(Session(
|
||||
self.inner_signup(keypair.as_inner(), homeserver.as_inner())
|
||||
.await
|
||||
.map_err(|e| JsValue::from(e))?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Check the current sesison for a given Pubky in its homeserver.
|
||||
@@ -123,14 +105,73 @@ impl PubkyClient {
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Signin to a homeserver.
|
||||
/// Signin to a homeserver using the root Keypair.
|
||||
#[wasm_bindgen]
|
||||
pub async fn signin(&self, keypair: &Keypair) -> Result<(), JsValue> {
|
||||
self.inner_signin(keypair.as_inner())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Return `pubkyauth://` url and wait for the incoming [AuthToken]
|
||||
/// verifying that AuthToken, and if capabilities were requested, signing in to
|
||||
/// the Pubky's homeserver and returning the [Session] information.
|
||||
///
|
||||
/// Returns a tuple of [pubkyAuthUrl, Promise<Session>]
|
||||
#[wasm_bindgen(js_name = "authRequest")]
|
||||
pub fn auth_request(&self, relay: &str, capabilities: &str) -> Result<js_sys::Array, JsValue> {
|
||||
let mut relay: Url = relay
|
||||
.try_into()
|
||||
.map_err(|_| Error::Generic("Invalid relay Url".into()))?;
|
||||
|
||||
let (pubkyauth_url, client_secret) = self.create_auth_request(
|
||||
&mut relay,
|
||||
&Capabilities::try_from(capabilities).map_err(|_| "Invalid capaiblities")?,
|
||||
)?;
|
||||
|
||||
let this = self.clone();
|
||||
|
||||
let future = async move {
|
||||
this.subscribe_to_auth_response(relay, &client_secret)
|
||||
.await
|
||||
.map(|opt| {
|
||||
opt.map_or_else(
|
||||
|| JsValue::NULL, // Convert `None` to `JsValue::NULL`
|
||||
|session| JsValue::from(Session(session)),
|
||||
)
|
||||
})
|
||||
.map_err(|err| JsValue::from_str(&format!("{:?}", err)))
|
||||
};
|
||||
|
||||
let promise = wasm_bindgen_futures::future_to_promise(future);
|
||||
|
||||
// Return the URL and the promise
|
||||
let js_tuple = js_sys::Array::new();
|
||||
js_tuple.push(&JsValue::from_str(pubkyauth_url.as_ref()));
|
||||
js_tuple.push(&promise);
|
||||
|
||||
Ok(js_tuple)
|
||||
}
|
||||
|
||||
/// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the
|
||||
/// source of the pubkyauth request url.
|
||||
#[wasm_bindgen(js_name = "sendAuthToken")]
|
||||
pub async fn send_auth_token(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
pubkyauth_url: &str,
|
||||
) -> Result<(), JsValue> {
|
||||
let pubkyauth_url: Url = pubkyauth_url
|
||||
.try_into()
|
||||
.map_err(|_| Error::Generic("Invalid relay Url".into()))?;
|
||||
|
||||
self.inner_send_auth_token(keypair.as_inner(), pubkyauth_url)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Public data ===
|
||||
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -3,8 +3,6 @@ use crate::PubkyClient;
|
||||
use reqwest::{Method, RequestBuilder, Response};
|
||||
use url::Url;
|
||||
|
||||
use ::pkarr::PublicKey;
|
||||
|
||||
impl PubkyClient {
|
||||
pub(crate) fn request(&self, method: Method, url: Url) -> RequestBuilder {
|
||||
let mut request = self.http.request(method, url).fetch_credentials_include();
|
||||
@@ -18,7 +16,7 @@ impl PubkyClient {
|
||||
|
||||
// Support cookies for nodejs
|
||||
|
||||
pub(crate) fn store_session(&self, response: Response) {
|
||||
pub(crate) fn store_session(&self, response: &Response) {
|
||||
if let Some(cookie) = response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Keypair {
|
||||
}
|
||||
|
||||
let len = secret_key.byte_length();
|
||||
if (len != 32) {
|
||||
if len != 32 {
|
||||
return Err(format!("Expected secret_key to be 32 bytes, got {len}"))?;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ impl From<pkarr::Keypair> for Keypair {
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct PublicKey(pkarr::PublicKey);
|
||||
pub struct PublicKey(pub(crate) pkarr::PublicKey);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl PublicKey {
|
||||
@@ -91,3 +91,9 @@ impl PublicKey {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pkarr::PublicKey> for PublicKey {
|
||||
fn from(value: pkarr::PublicKey) -> Self {
|
||||
PublicKey(value)
|
||||
}
|
||||
}
|
||||
|
||||
24
pubky/src/wasm/recovery_file.rs
Normal file
24
pubky/src/wasm/recovery_file.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use js_sys::Uint8Array;
|
||||
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::keys::Keypair;
|
||||
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
#[wasm_bindgen(js_name = "createRecoveryFile")]
|
||||
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Uint8Array, JsValue> {
|
||||
pubky_common::recovery_file::create_recovery_file(keypair.as_inner(), passphrase)
|
||||
.map(|b| b.as_slice().into())
|
||||
.map_err(|e| Error::from(e).into())
|
||||
}
|
||||
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
#[wasm_bindgen(js_name = "decryptRecoveryFile")]
|
||||
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair, JsValue> {
|
||||
pubky_common::recovery_file::decrypt_recovery_file(recovery_file, passphrase)
|
||||
.map(Keypair::from)
|
||||
.map_err(|e| Error::from(e).into())
|
||||
}
|
||||
@@ -2,5 +2,26 @@ use pubky_common::session;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::keys::PublicKey;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Session(pub(crate) session::Session);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Session {
|
||||
/// Return the [PublicKey] of this session
|
||||
#[wasm_bindgen]
|
||||
pub fn pubky(&self) -> PublicKey {
|
||||
self.0.pubky().clone().into()
|
||||
}
|
||||
|
||||
/// Return the capabilities that this session has.
|
||||
#[wasm_bindgen]
|
||||
pub fn capabilities(&self) -> Vec<String> {
|
||||
self.0
|
||||
.capabilities()
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user