Merge pull request #28 from pubky/feat/authz

Feat/authz
This commit is contained in:
Nuh
2024-09-07 20:59:25 +03:00
committed by GitHub
44 changed files with 5085 additions and 438 deletions

295
Cargo.lock generated
View File

@@ -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"

View File

@@ -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
View 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?

View 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>

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

View 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;
}

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"

View 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(())
}

View File

@@ -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']

View File

@@ -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));
}
}

View 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))
}
}

View File

@@ -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;

View File

@@ -1 +1 @@
pub const PUBKY_AUTHN: &[u8; 11] = b"PUBKY:AUTHN";
pub const PUBKY_AUTH: &[u8; 10] = b"PUBKY:AUTH";

View File

@@ -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());
}

View File

@@ -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();

View File

@@ -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(&timestamp).unwrap();
assert_eq!(serialized, timestamp.to_bytes());
let deserialized: Timestamp = postcard::from_bytes(&serialized).unwrap();
assert_eq!(deserialized, timestamp);
}
}

View File

@@ -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"

View File

@@ -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"),

View File

@@ -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))
}
}

View File

@@ -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))

View File

@@ -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)
}

View File

@@ -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<()> {

View File

@@ -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,

View File

@@ -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"

View File

@@ -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.

View File

@@ -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(','))
})

View File

@@ -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`,

View File

@@ -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())
})

View File

@@ -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

View File

@@ -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")]

View File

@@ -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) {}
}

View File

@@ -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))
);
}
}

View File

@@ -2,4 +2,3 @@ pub mod auth;
pub mod list_builder;
pub mod pkarr;
pub mod public;
pub mod recovery_file;

View 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,
}

View File

@@ -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]

View File

@@ -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")

View File

@@ -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)
}
}

View 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())
}

View File

@@ -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()
}
}