Add DPoP-signed XRPC requests
Authenticated calls against a PDS: Authorization: DPoP (not Bearer), a per-request proof, and retries for both failure modes. Resource-server proofs differ from auth-server ones even when the same host fills both roles. A PDS request carries ath -- base64url SHA-256 of the access token, which is what binds the proof to that one credential -- and iss; the PAR and token requests that mint the token carry neither. The nonce handshake differs too: a PDS answers 401 with WWW-Authenticate error="use_dpop_nonce" where the auth server used 400 and a JSON body, so a session tracks two independent nonces. Without a consented token the probe signs a request with an invalid one and reads what a real PDS says. bsky.social returns error="invalid_token", not invalid_dpop_proof, which is the useful part: it parsed and accepted the proof -- signature, claims, ath, htu, htm -- and rejected only the credential. ath is pinned to a value from openssl dgst rather than checked for self-consistency, since a self-consistent hash passes every internal assertion and is still refused by every server. Two bugs found in the process. htm was built as ($httpMethod)[4..^1] on the assumption the enum stringified to "HttpGet"; it stringifies to "GET". hostOf returned an empty host for scheme-less input like "public.api.bsky.app", because parseUri files that under path, so URLs came out as https:///xrpc/... A third was in the test rather than the code: it first pointed at com.atproto.repo.listRecords, which is public, so the PDS answered 200 while ignoring the credential -- a passing check that checked nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
72af361 parent: c960984 modified
spike/nim/.gitignore +1 -0 | @@ -3,3 +3,4 @@ nimcache/ | ||
| 3 | 3 | sqlite_probe |
| 4 | 4 | dpop_probe |
| 5 | 5 | oauth_probe |
| 6 | +xrpc_probe | |
| @@ -3,3 +3,4 @@ nimcache/ | |||
| 3 | sqlite_probe | 3 | sqlite_probe |
| 4 | dpop_probe | 4 | dpop_probe |
| 5 | oauth_probe | 5 | oauth_probe |
| 6 | +xrpc_probe | ||
modified
spike/nim/atproto/dpop.nim +29 -3 | @@ -84,18 +84,44 @@ proc signEs256*(k: P256Key, rng: var HmacDrbgContext, signingInput: string): seq | ||
| 84 | 84 | doAssert n == 64, &"expected a 64-byte raw signature, got {n}" |
| 85 | 85 | result = sig.toSeq |
| 86 | 86 | |
| 87 | +proc b64uDecode*(s: string): string = | |
| 88 | + ## Inverse of b64u: restore the padding base64url drops, then decode. | |
| 89 | + var t = s.replace('-', '+').replace('_', '/') | |
| 90 | + while t.len mod 4 != 0: t.add '=' | |
| 91 | + base64.decode(t) | |
| 92 | + | |
| 93 | +proc accessTokenHash*(accessToken: string): string = | |
| 94 | + ## The `ath` claim: base64url(SHA-256(access token)). | |
| 95 | + ## | |
| 96 | + ## This is what binds a proof to one specific token. A resource-server proof | |
| 97 | + ## without it is rejected, and a proof carrying the wrong one lets a stolen | |
| 98 | + ## token be replayed with a fresh proof -- which is the whole thing DPoP | |
| 99 | + ## exists to prevent. | |
| 100 | + b64u(sha256.digest(accessToken).data) | |
| 101 | + | |
| 87 | 102 | proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string, |
| 88 | - nonce = ""): string = | |
| 89 | - ## A DPoP proof JWT (RFC 9449) as an ATProto PDS expects it. | |
| 103 | + nonce = "", accessToken = "", issuer = ""): string = | |
| 104 | + ## A DPoP proof JWT (RFC 9449). | |
| 105 | + ## | |
| 106 | + ## Auth-server and resource-server proofs differ, even when the same host | |
| 107 | + ## fills both roles: a request to a PDS carries `ath` (bound to the access | |
| 108 | + ## token) and `iss` (the auth server that issued it), while the PAR and | |
| 109 | + ## token requests that mint that token carry neither. | |
| 90 | 110 | let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk} |
| 111 | + let issued = now().utc.toTime.toUnix | |
| 91 | 112 | var payload = %*{ |
| 92 | 113 | "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16], |
| 93 | 114 | "htm": htm, |
| 94 | 115 | "htu": htu, |
| 95 | - "iat": now().utc.toTime.toUnix, | |
| 116 | + "iat": issued, | |
| 117 | + "exp": issued + 300, | |
| 96 | 118 | } |
| 97 | 119 | if nonce.len > 0: |
| 98 | 120 | payload["nonce"] = %nonce |
| 121 | + if accessToken.len > 0: | |
| 122 | + payload["ath"] = %accessTokenHash(accessToken) | |
| 123 | + if issuer.len > 0: | |
| 124 | + payload["iss"] = %issuer | |
| 99 | 125 | |
| 100 | 126 | let signingInput = b64u($header) & "." & b64u($payload) |
| 101 | 127 | signingInput & "." & b64u(k.signEs256(rng, signingInput)) |
| @@ -84,18 +84,44 @@ proc signEs256*(k: P256Key, rng: var HmacDrbgContext, signingInput: string): seq | |||
| 84 | doAssert n == 64, &"expected a 64-byte raw signature, got {n}" | 84 | doAssert n == 64, &"expected a 64-byte raw signature, got {n}" |
| 85 | result = sig.toSeq | 85 | result = sig.toSeq |
| 86 | 86 | ||
| 87 | +proc b64uDecode*(s: string): string = | ||
| 88 | + ## Inverse of b64u: restore the padding base64url drops, then decode. | ||
| 89 | + var t = s.replace('-', '+').replace('_', '/') | ||
| 90 | + while t.len mod 4 != 0: t.add '=' | ||
| 91 | + base64.decode(t) | ||
| 92 | + | ||
| 93 | +proc accessTokenHash*(accessToken: string): string = | ||
| 94 | + ## The `ath` claim: base64url(SHA-256(access token)). | ||
| 95 | + ## | ||
| 96 | + ## This is what binds a proof to one specific token. A resource-server proof | ||
| 97 | + ## without it is rejected, and a proof carrying the wrong one lets a stolen | ||
| 98 | + ## token be replayed with a fresh proof -- which is the whole thing DPoP | ||
| 99 | + ## exists to prevent. | ||
| 100 | + b64u(sha256.digest(accessToken).data) | ||
| 101 | + | ||
| 87 | proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string, | 102 | proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string, |
| 88 | - nonce = ""): string = | 103 | + nonce = "", accessToken = "", issuer = ""): string = |
| 89 | - ## A DPoP proof JWT (RFC 9449) as an ATProto PDS expects it. | 104 | + ## A DPoP proof JWT (RFC 9449). |
| 105 | + ## | ||
| 106 | + ## Auth-server and resource-server proofs differ, even when the same host | ||
| 107 | + ## fills both roles: a request to a PDS carries `ath` (bound to the access | ||
| 108 | + ## token) and `iss` (the auth server that issued it), while the PAR and | ||
| 109 | + ## token requests that mint that token carry neither. | ||
| 90 | let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk} | 110 | let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk} |
| 111 | + let issued = now().utc.toTime.toUnix | ||
| 91 | var payload = %*{ | 112 | var payload = %*{ |
| 92 | "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16], | 113 | "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16], |
| 93 | "htm": htm, | 114 | "htm": htm, |
| 94 | "htu": htu, | 115 | "htu": htu, |
| 95 | - "iat": now().utc.toTime.toUnix, | 116 | + "iat": issued, |
| 117 | + "exp": issued + 300, | ||
| 96 | } | 118 | } |
| 97 | if nonce.len > 0: | 119 | if nonce.len > 0: |
| 98 | payload["nonce"] = %nonce | 120 | payload["nonce"] = %nonce |
| 121 | + if accessToken.len > 0: | ||
| 122 | + payload["ath"] = %accessTokenHash(accessToken) | ||
| 123 | + if issuer.len > 0: | ||
| 124 | + payload["iss"] = %issuer | ||
| 99 | 125 | ||
| 100 | let signingInput = b64u($header) & "." & b64u($payload) | 126 | let signingInput = b64u($header) & "." & b64u($payload) |
| 101 | signingInput & "." & b64u(k.signEs256(rng, signingInput)) | 127 | signingInput & "." & b64u(k.signEs256(rng, signingInput)) |
modified
spike/nim/atproto/identity.nim +8 -1 | @@ -138,5 +138,12 @@ proc lookup*(identifier: string, plcUrl = DefaultPlcUrl): Identity = | ||
| 138 | 138 | raise newException(IdentityError, "identity has no atproto PDS: " & did) |
| 139 | 139 | |
| 140 | 140 | proc hostOf*(url: string): string = |
| 141 | - let u = parseUri(url) | |
| 141 | + ## Host (with port, if given) from a URL. | |
| 142 | + ## | |
| 143 | + ## parseUri treats a scheme-less string as a path, so "example.com" would | |
| 144 | + ## otherwise yield an empty host and build URLs like "https:///xrpc/...". | |
| 145 | + ## Callers legitimately pass bare hostnames, so assume https when no scheme | |
| 146 | + ## is present. | |
| 147 | + let withScheme = if "://" in url: url else: "https://" & url | |
| 148 | + let u = parseUri(withScheme) | |
| 142 | 149 | if u.port.len > 0: u.hostname & ":" & u.port else: u.hostname |
| @@ -138,5 +138,12 @@ proc lookup*(identifier: string, plcUrl = DefaultPlcUrl): Identity = | |||
| 138 | raise newException(IdentityError, "identity has no atproto PDS: " & did) | 138 | raise newException(IdentityError, "identity has no atproto PDS: " & did) |
| 139 | 139 | ||
| 140 | proc hostOf*(url: string): string = | 140 | proc hostOf*(url: string): string = |
| 141 | - let u = parseUri(url) | 141 | + ## Host (with port, if given) from a URL. |
| 142 | + ## | ||
| 143 | + ## parseUri treats a scheme-less string as a path, so "example.com" would | ||
| 144 | + ## otherwise yield an empty host and build URLs like "https:///xrpc/...". | ||
| 145 | + ## Callers legitimately pass bare hostnames, so assume https when no scheme | ||
| 146 | + ## is present. | ||
| 147 | + let withScheme = if "://" in url: url else: "https://" & url | ||
| 148 | + let u = parseUri(withScheme) | ||
| 142 | if u.port.len > 0: u.hostname & ":" & u.port else: u.hostname | 149 | if u.port.len > 0: u.hostname & ":" & u.port else: u.hostname |
added
spike/nim/atproto/xrpc.nim +204 -0 | new file mode 100644 | ||
| @@ -0,0 +1,204 @@ | ||
| 1 | +## Authenticated XRPC against a PDS, using a DPoP-bound OAuth access token. | |
| 2 | +## | |
| 3 | +## The important difference from the OAuth endpoints: a resource server | |
| 4 | +## signals a stale nonce with **401** and a `WWW-Authenticate: DPoP | |
| 5 | +## error="use_dpop_nonce"` header, where the auth server used 400 and a JSON | |
| 6 | +## body. Same protocol, different shape, so the retry logic cannot be shared | |
| 7 | +## by pattern-matching the status code alone. | |
| 8 | +## | |
| 9 | +## A session therefore tracks two nonces: one for the auth server (token | |
| 10 | +## refresh) and one for the PDS. They are issued independently and are not | |
| 11 | +## interchangeable. | |
| 12 | + | |
| 13 | +import std/[httpclient, json, strformat, strutils, uri] | |
| 14 | +import bearssl/rand | |
| 15 | +import ./dpop | |
| 16 | +import ./identity | |
| 17 | +import ./oauth | |
| 18 | + | |
| 19 | +type | |
| 20 | + XrpcError* = object of CatchableError | |
| 21 | + status*: int | |
| 22 | + xrpcError*: string ## the `error` field of an XRPC error body | |
| 23 | + | |
| 24 | + Session* = object ## Everything needed to make an authenticated call. | |
| 25 | + did*: string | |
| 26 | + pdsUrl*: string | |
| 27 | + authServerUrl*: string | |
| 28 | + tokenEndpoint*: string | |
| 29 | + accessToken*: string | |
| 30 | + refreshToken*: string | |
| 31 | + dpopKey*: P256Key | |
| 32 | + hostNonce*: string ## nonce issued by the PDS | |
| 33 | + authNonce*: string ## nonce issued by the auth server | |
| 34 | + clientId*: string | |
| 35 | + | |
| 36 | +const HttpTimeoutMs = 30_000 | |
| 37 | + | |
| 38 | +proc sessionFromTokens*(info: AuthRequestData, tokens: TokenResponse, | |
| 39 | + pdsUrl, clientId: string): Session = | |
| 40 | + Session( | |
| 41 | + did: tokens.sub, | |
| 42 | + pdsUrl: pdsUrl.strip(chars = {'/'}), | |
| 43 | + authServerUrl: info.authServerUrl, | |
| 44 | + tokenEndpoint: info.tokenEndpoint, | |
| 45 | + accessToken: tokens.accessToken, | |
| 46 | + refreshToken: tokens.refreshToken, | |
| 47 | + dpopKey: info.dpopKey, | |
| 48 | + authNonce: info.dpopNonce, | |
| 49 | + clientId: clientId, | |
| 50 | + ) | |
| 51 | + | |
| 52 | +proc dpopUrl*(url: string): string = | |
| 53 | + ## The `htu` claim is the request URL without query or fragment (RFC 9449 | |
| 54 | + ## §4.2). Leaving the query on is a silent mismatch: the proof verifies | |
| 55 | + ## against a URL the server never compares it to, and every call 401s. | |
| 56 | + let u = parseUri(url) | |
| 57 | + var bare = u | |
| 58 | + bare.query = "" | |
| 59 | + bare.anchor = "" | |
| 60 | + $bare | |
| 61 | + | |
| 62 | +proc isNonceRetry(wwwAuth: string): bool = | |
| 63 | + ## WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="..." | |
| 64 | + "use_dpop_nonce" in wwwAuth | |
| 65 | + | |
| 66 | +proc isExpiredToken(wwwAuth: string): bool = | |
| 67 | + "invalid_token" in wwwAuth | |
| 68 | + | |
| 69 | +proc parseXrpcError(body: string): (string, string) = | |
| 70 | + try: | |
| 71 | + let j = parseJson(body) | |
| 72 | + (j{"error"}.getStr, j{"message"}.getStr) | |
| 73 | + except CatchableError: | |
| 74 | + ("", "") | |
| 75 | + | |
| 76 | +proc raiseXrpc(status: int, body: string, what: string) = | |
| 77 | + let (err, msg) = parseXrpcError(body) | |
| 78 | + var e = newException(XrpcError, | |
| 79 | + &"{what} failed (HTTP {status})" & | |
| 80 | + (if err.len > 0: &": {err}" else: "") & | |
| 81 | + (if msg.len > 0: &" -- {msg}" else: "")) | |
| 82 | + e.status = status | |
| 83 | + e.xrpcError = err | |
| 84 | + raise e | |
| 85 | + | |
| 86 | +proc refreshSession*(sess: var Session, rng: var HmacDrbgContext) = | |
| 87 | + ## Exchange the refresh token for a new access token, reusing the session's | |
| 88 | + ## DPoP key -- the refresh token is bound to it just as the access token is. | |
| 89 | + if sess.refreshToken.len == 0: | |
| 90 | + raise newException(XrpcError, "session has no refresh token") | |
| 91 | + var info = AuthRequestData( | |
| 92 | + authServerUrl: sess.authServerUrl, | |
| 93 | + tokenEndpoint: sess.tokenEndpoint, | |
| 94 | + dpopKey: sess.dpopKey, | |
| 95 | + dpopNonce: sess.authNonce, | |
| 96 | + ) | |
| 97 | + let cfg = ClientConfig(clientId: sess.clientId) | |
| 98 | + let tokens = refresh(cfg, info, sess.refreshToken, rng) | |
| 99 | + sess.accessToken = tokens.accessToken | |
| 100 | + if tokens.refreshToken.len > 0: | |
| 101 | + sess.refreshToken = tokens.refreshToken | |
| 102 | + sess.authNonce = info.dpopNonce | |
| 103 | + | |
| 104 | +proc request*(sess: var Session, rng: var HmacDrbgContext, | |
| 105 | + httpMethod: HttpMethod, url: string, | |
| 106 | + body = "", contentType = "application/json"): Response = | |
| 107 | + ## One authenticated request, handling both retry conditions. | |
| 108 | + ## | |
| 109 | + ## Up to three attempts: the original, one for a nonce the PDS only reveals | |
| 110 | + ## by rejecting us, and one after refreshing an expired access token. | |
| 111 | + let client = newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs) | |
| 112 | + defer: client.close() | |
| 113 | + | |
| 114 | + let htu = dpopUrl(url) | |
| 115 | + # HttpMethod stringifies straight to the uppercase token the `htm` claim | |
| 116 | + # wants ("GET", "POST"), so no trimming. | |
| 117 | + let htm = $httpMethod | |
| 118 | + var refreshed = false | |
| 119 | + var noncedOnce = false | |
| 120 | + | |
| 121 | + for attempt in 0 .. 2: | |
| 122 | + let proof = dpopProof(sess.dpopKey, rng, htm, htu, | |
| 123 | + nonce = sess.hostNonce, | |
| 124 | + accessToken = sess.accessToken, | |
| 125 | + issuer = sess.authServerUrl) | |
| 126 | + var headers = @{ | |
| 127 | + "Authorization": "DPoP " & sess.accessToken, # not Bearer | |
| 128 | + "DPoP": proof, | |
| 129 | + } | |
| 130 | + if body.len > 0: | |
| 131 | + headers.add ("Content-Type", contentType) | |
| 132 | + client.headers = newHttpHeaders(headers) | |
| 133 | + | |
| 134 | + result = client.request(url, httpMethod = httpMethod, body = body) | |
| 135 | + | |
| 136 | + # The PDS rotates nonces freely; adopt any it offers, success or not. | |
| 137 | + let supplied = result.headers.getOrDefault("DPoP-Nonce").string | |
| 138 | + if supplied.len > 0: | |
| 139 | + sess.hostNonce = supplied | |
| 140 | + | |
| 141 | + if result.code.int != 401: | |
| 142 | + return result | |
| 143 | + | |
| 144 | + let wwwAuth = result.headers.getOrDefault("WWW-Authenticate").string | |
| 145 | + if wwwAuth.len == 0: | |
| 146 | + return result | |
| 147 | + | |
| 148 | + if isNonceRetry(wwwAuth) and supplied.len > 0 and not noncedOnce: | |
| 149 | + noncedOnce = true | |
| 150 | + continue | |
| 151 | + | |
| 152 | + if isExpiredToken(wwwAuth) and not refreshed and sess.refreshToken.len > 0: | |
| 153 | + refreshed = true | |
| 154 | + refreshSession(sess, rng) | |
| 155 | + continue | |
| 156 | + | |
| 157 | + return result | |
| 158 | + | |
| 159 | + raise newException(XrpcError, "ran out of retries for " & url) | |
| 160 | + | |
| 161 | +proc query*(sess: var Session, rng: var HmacDrbgContext, nsid: string, | |
| 162 | + params: openArray[(string, string)] = []): JsonNode = | |
| 163 | + ## An XRPC query (GET), e.g. com.atproto.repo.listRecords. | |
| 164 | + var url = &"{sess.pdsUrl}/xrpc/{nsid}" | |
| 165 | + if params.len > 0: | |
| 166 | + url &= "?" & params.encodeQuery | |
| 167 | + let res = request(sess, rng, HttpGet, url) | |
| 168 | + if res.code.int != 200: | |
| 169 | + raiseXrpc(res.code.int, res.body, nsid) | |
| 170 | + try: | |
| 171 | + parseJson(res.body) | |
| 172 | + except JsonParsingError: | |
| 173 | + raise newException(XrpcError, nsid & " returned a non-JSON body") | |
| 174 | + | |
| 175 | +proc procedure*(sess: var Session, rng: var HmacDrbgContext, nsid: string, | |
| 176 | + body: JsonNode = nil): JsonNode = | |
| 177 | + ## An XRPC procedure (POST), e.g. com.atproto.repo.createRecord. | |
| 178 | + let url = &"{sess.pdsUrl}/xrpc/{nsid}" | |
| 179 | + let payload = if body == nil: "" else: $body | |
| 180 | + let res = request(sess, rng, HttpPost, url, body = payload) | |
| 181 | + if res.code.int notin [200, 201]: | |
| 182 | + raiseXrpc(res.code.int, res.body, nsid) | |
| 183 | + if res.body.strip.len == 0: | |
| 184 | + return newJObject() | |
| 185 | + try: | |
| 186 | + parseJson(res.body) | |
| 187 | + except JsonParsingError: | |
| 188 | + raise newException(XrpcError, nsid & " returned a non-JSON body") | |
| 189 | + | |
| 190 | +# --- unauthenticated helpers ---------------------------------------------- | |
| 191 | + | |
| 192 | +proc publicQuery*(host, nsid: string, | |
| 193 | + params: openArray[(string, string)] = []): JsonNode = | |
| 194 | + ## Many XRPC endpoints need no auth. Useful on its own, and it isolates | |
| 195 | + ## "can we speak XRPC at all" from "is our DPoP signing right". | |
| 196 | + var url = &"https://{hostOf(host)}/xrpc/{nsid}" | |
| 197 | + if params.len > 0: | |
| 198 | + url &= "?" & params.encodeQuery | |
| 199 | + let client = newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs) | |
| 200 | + defer: client.close() | |
| 201 | + let res = client.get(url) | |
| 202 | + if res.code.int != 200: | |
| 203 | + raiseXrpc(res.code.int, res.body, nsid) | |
| 204 | + parseJson(res.body) | |
| new file mode 100644 | |||
| @@ -0,0 +1,204 @@ | |||
| 1 | +## Authenticated XRPC against a PDS, using a DPoP-bound OAuth access token. | ||
| 2 | +## | ||
| 3 | +## The important difference from the OAuth endpoints: a resource server | ||
| 4 | +## signals a stale nonce with **401** and a `WWW-Authenticate: DPoP | ||
| 5 | +## error="use_dpop_nonce"` header, where the auth server used 400 and a JSON | ||
| 6 | +## body. Same protocol, different shape, so the retry logic cannot be shared | ||
| 7 | +## by pattern-matching the status code alone. | ||
| 8 | +## | ||
| 9 | +## A session therefore tracks two nonces: one for the auth server (token | ||
| 10 | +## refresh) and one for the PDS. They are issued independently and are not | ||
| 11 | +## interchangeable. | ||
| 12 | + | ||
| 13 | +import std/[httpclient, json, strformat, strutils, uri] | ||
| 14 | +import bearssl/rand | ||
| 15 | +import ./dpop | ||
| 16 | +import ./identity | ||
| 17 | +import ./oauth | ||
| 18 | + | ||
| 19 | +type | ||
| 20 | + XrpcError* = object of CatchableError | ||
| 21 | + status*: int | ||
| 22 | + xrpcError*: string ## the `error` field of an XRPC error body | ||
| 23 | + | ||
| 24 | + Session* = object ## Everything needed to make an authenticated call. | ||
| 25 | + did*: string | ||
| 26 | + pdsUrl*: string | ||
| 27 | + authServerUrl*: string | ||
| 28 | + tokenEndpoint*: string | ||
| 29 | + accessToken*: string | ||
| 30 | + refreshToken*: string | ||
| 31 | + dpopKey*: P256Key | ||
| 32 | + hostNonce*: string ## nonce issued by the PDS | ||
| 33 | + authNonce*: string ## nonce issued by the auth server | ||
| 34 | + clientId*: string | ||
| 35 | + | ||
| 36 | +const HttpTimeoutMs = 30_000 | ||
| 37 | + | ||
| 38 | +proc sessionFromTokens*(info: AuthRequestData, tokens: TokenResponse, | ||
| 39 | + pdsUrl, clientId: string): Session = | ||
| 40 | + Session( | ||
| 41 | + did: tokens.sub, | ||
| 42 | + pdsUrl: pdsUrl.strip(chars = {'/'}), | ||
| 43 | + authServerUrl: info.authServerUrl, | ||
| 44 | + tokenEndpoint: info.tokenEndpoint, | ||
| 45 | + accessToken: tokens.accessToken, | ||
| 46 | + refreshToken: tokens.refreshToken, | ||
| 47 | + dpopKey: info.dpopKey, | ||
| 48 | + authNonce: info.dpopNonce, | ||
| 49 | + clientId: clientId, | ||
| 50 | + ) | ||
| 51 | + | ||
| 52 | +proc dpopUrl*(url: string): string = | ||
| 53 | + ## The `htu` claim is the request URL without query or fragment (RFC 9449 | ||
| 54 | + ## §4.2). Leaving the query on is a silent mismatch: the proof verifies | ||
| 55 | + ## against a URL the server never compares it to, and every call 401s. | ||
| 56 | + let u = parseUri(url) | ||
| 57 | + var bare = u | ||
| 58 | + bare.query = "" | ||
| 59 | + bare.anchor = "" | ||
| 60 | + $bare | ||
| 61 | + | ||
| 62 | +proc isNonceRetry(wwwAuth: string): bool = | ||
| 63 | + ## WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="..." | ||
| 64 | + "use_dpop_nonce" in wwwAuth | ||
| 65 | + | ||
| 66 | +proc isExpiredToken(wwwAuth: string): bool = | ||
| 67 | + "invalid_token" in wwwAuth | ||
| 68 | + | ||
| 69 | +proc parseXrpcError(body: string): (string, string) = | ||
| 70 | + try: | ||
| 71 | + let j = parseJson(body) | ||
| 72 | + (j{"error"}.getStr, j{"message"}.getStr) | ||
| 73 | + except CatchableError: | ||
| 74 | + ("", "") | ||
| 75 | + | ||
| 76 | +proc raiseXrpc(status: int, body: string, what: string) = | ||
| 77 | + let (err, msg) = parseXrpcError(body) | ||
| 78 | + var e = newException(XrpcError, | ||
| 79 | + &"{what} failed (HTTP {status})" & | ||
| 80 | + (if err.len > 0: &": {err}" else: "") & | ||
| 81 | + (if msg.len > 0: &" -- {msg}" else: "")) | ||
| 82 | + e.status = status | ||
| 83 | + e.xrpcError = err | ||
| 84 | + raise e | ||
| 85 | + | ||
| 86 | +proc refreshSession*(sess: var Session, rng: var HmacDrbgContext) = | ||
| 87 | + ## Exchange the refresh token for a new access token, reusing the session's | ||
| 88 | + ## DPoP key -- the refresh token is bound to it just as the access token is. | ||
| 89 | + if sess.refreshToken.len == 0: | ||
| 90 | + raise newException(XrpcError, "session has no refresh token") | ||
| 91 | + var info = AuthRequestData( | ||
| 92 | + authServerUrl: sess.authServerUrl, | ||
| 93 | + tokenEndpoint: sess.tokenEndpoint, | ||
| 94 | + dpopKey: sess.dpopKey, | ||
| 95 | + dpopNonce: sess.authNonce, | ||
| 96 | + ) | ||
| 97 | + let cfg = ClientConfig(clientId: sess.clientId) | ||
| 98 | + let tokens = refresh(cfg, info, sess.refreshToken, rng) | ||
| 99 | + sess.accessToken = tokens.accessToken | ||
| 100 | + if tokens.refreshToken.len > 0: | ||
| 101 | + sess.refreshToken = tokens.refreshToken | ||
| 102 | + sess.authNonce = info.dpopNonce | ||
| 103 | + | ||
| 104 | +proc request*(sess: var Session, rng: var HmacDrbgContext, | ||
| 105 | + httpMethod: HttpMethod, url: string, | ||
| 106 | + body = "", contentType = "application/json"): Response = | ||
| 107 | + ## One authenticated request, handling both retry conditions. | ||
| 108 | + ## | ||
| 109 | + ## Up to three attempts: the original, one for a nonce the PDS only reveals | ||
| 110 | + ## by rejecting us, and one after refreshing an expired access token. | ||
| 111 | + let client = newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs) | ||
| 112 | + defer: client.close() | ||
| 113 | + | ||
| 114 | + let htu = dpopUrl(url) | ||
| 115 | + # HttpMethod stringifies straight to the uppercase token the `htm` claim | ||
| 116 | + # wants ("GET", "POST"), so no trimming. | ||
| 117 | + let htm = $httpMethod | ||
| 118 | + var refreshed = false | ||
| 119 | + var noncedOnce = false | ||
| 120 | + | ||
| 121 | + for attempt in 0 .. 2: | ||
| 122 | + let proof = dpopProof(sess.dpopKey, rng, htm, htu, | ||
| 123 | + nonce = sess.hostNonce, | ||
| 124 | + accessToken = sess.accessToken, | ||
| 125 | + issuer = sess.authServerUrl) | ||
| 126 | + var headers = @{ | ||
| 127 | + "Authorization": "DPoP " & sess.accessToken, # not Bearer | ||
| 128 | + "DPoP": proof, | ||
| 129 | + } | ||
| 130 | + if body.len > 0: | ||
| 131 | + headers.add ("Content-Type", contentType) | ||
| 132 | + client.headers = newHttpHeaders(headers) | ||
| 133 | + | ||
| 134 | + result = client.request(url, httpMethod = httpMethod, body = body) | ||
| 135 | + | ||
| 136 | + # The PDS rotates nonces freely; adopt any it offers, success or not. | ||
| 137 | + let supplied = result.headers.getOrDefault("DPoP-Nonce").string | ||
| 138 | + if supplied.len > 0: | ||
| 139 | + sess.hostNonce = supplied | ||
| 140 | + | ||
| 141 | + if result.code.int != 401: | ||
| 142 | + return result | ||
| 143 | + | ||
| 144 | + let wwwAuth = result.headers.getOrDefault("WWW-Authenticate").string | ||
| 145 | + if wwwAuth.len == 0: | ||
| 146 | + return result | ||
| 147 | + | ||
| 148 | + if isNonceRetry(wwwAuth) and supplied.len > 0 and not noncedOnce: | ||
| 149 | + noncedOnce = true | ||
| 150 | + continue | ||
| 151 | + | ||
| 152 | + if isExpiredToken(wwwAuth) and not refreshed and sess.refreshToken.len > 0: | ||
| 153 | + refreshed = true | ||
| 154 | + refreshSession(sess, rng) | ||
| 155 | + continue | ||
| 156 | + | ||
| 157 | + return result | ||
| 158 | + | ||
| 159 | + raise newException(XrpcError, "ran out of retries for " & url) | ||
| 160 | + | ||
| 161 | +proc query*(sess: var Session, rng: var HmacDrbgContext, nsid: string, | ||
| 162 | + params: openArray[(string, string)] = []): JsonNode = | ||
| 163 | + ## An XRPC query (GET), e.g. com.atproto.repo.listRecords. | ||
| 164 | + var url = &"{sess.pdsUrl}/xrpc/{nsid}" | ||
| 165 | + if params.len > 0: | ||
| 166 | + url &= "?" & params.encodeQuery | ||
| 167 | + let res = request(sess, rng, HttpGet, url) | ||
| 168 | + if res.code.int != 200: | ||
| 169 | + raiseXrpc(res.code.int, res.body, nsid) | ||
| 170 | + try: | ||
| 171 | + parseJson(res.body) | ||
| 172 | + except JsonParsingError: | ||
| 173 | + raise newException(XrpcError, nsid & " returned a non-JSON body") | ||
| 174 | + | ||
| 175 | +proc procedure*(sess: var Session, rng: var HmacDrbgContext, nsid: string, | ||
| 176 | + body: JsonNode = nil): JsonNode = | ||
| 177 | + ## An XRPC procedure (POST), e.g. com.atproto.repo.createRecord. | ||
| 178 | + let url = &"{sess.pdsUrl}/xrpc/{nsid}" | ||
| 179 | + let payload = if body == nil: "" else: $body | ||
| 180 | + let res = request(sess, rng, HttpPost, url, body = payload) | ||
| 181 | + if res.code.int notin [200, 201]: | ||
| 182 | + raiseXrpc(res.code.int, res.body, nsid) | ||
| 183 | + if res.body.strip.len == 0: | ||
| 184 | + return newJObject() | ||
| 185 | + try: | ||
| 186 | + parseJson(res.body) | ||
| 187 | + except JsonParsingError: | ||
| 188 | + raise newException(XrpcError, nsid & " returned a non-JSON body") | ||
| 189 | + | ||
| 190 | +# --- unauthenticated helpers ---------------------------------------------- | ||
| 191 | + | ||
| 192 | +proc publicQuery*(host, nsid: string, | ||
| 193 | + params: openArray[(string, string)] = []): JsonNode = | ||
| 194 | + ## Many XRPC endpoints need no auth. Useful on its own, and it isolates | ||
| 195 | + ## "can we speak XRPC at all" from "is our DPoP signing right". | ||
| 196 | + var url = &"https://{hostOf(host)}/xrpc/{nsid}" | ||
| 197 | + if params.len > 0: | ||
| 198 | + url &= "?" & params.encodeQuery | ||
| 199 | + let client = newHttpClient(userAgent = UserAgent, timeout = HttpTimeoutMs) | ||
| 200 | + defer: client.close() | ||
| 201 | + let res = client.get(url) | ||
| 202 | + if res.code.int != 200: | ||
| 203 | + raiseXrpc(res.code.int, res.body, nsid) | ||
| 204 | + parseJson(res.body) | ||
modified
spike/nim/readme.md +52 -3 | @@ -12,6 +12,7 @@ cd spike/nim | ||
| 12 | 12 | nim c -r sqlite_probe.nim # storage |
| 13 | 13 | nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto |
| 14 | 14 | nim c -r -d:ssl oauth_probe.nim [handle] # the flow |
| 15 | +nim c -r -d:ssl xrpc_probe.nim # signing | |
| 15 | 16 | ``` |
| 16 | 17 | |
| 17 | 18 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. |
| @@ -90,6 +91,53 @@ The flow stops at the authorization URL, which is correct — the next step is a | ||
| 90 | 91 | human granting consent in a browser. `exchangeCode` and `refresh` are |
| 91 | 92 | implemented against that callback but are consequently untested. |
| 92 | 93 | |
| 94 | +## 4. XRPC signing — proof accepted by a real PDS | |
| 95 | + | |
| 96 | +`atproto/xrpc.nim` makes authenticated calls with a DPoP-bound access token: | |
| 97 | +`Authorization: DPoP <token>` (not Bearer), a per-request proof, and the retry | |
| 98 | +logic for both failure modes. | |
| 99 | + | |
| 100 | +Resource-server proofs are **not** the same as auth-server proofs, even when | |
| 101 | +one host fills both roles. A PDS request carries `ath` (base64url SHA-256 of | |
| 102 | +the access token, which is what binds the proof to that one credential) and | |
| 103 | +`iss`; the PAR and token requests that mint the token carry neither. The nonce | |
| 104 | +handshake also differs: a PDS answers **401** with `WWW-Authenticate: DPoP | |
| 105 | +error="use_dpop_nonce"`, where the auth server used **400** and a JSON body. | |
| 106 | +A session therefore tracks two independent nonces. | |
| 107 | + | |
| 108 | +`xrpc_probe.nim` cannot get a consented token, so it signs a request with a | |
| 109 | +deliberately invalid one and reads what a real PDS says. Against | |
| 110 | +`bsky.social`'s PDS: | |
| 111 | + | |
| 112 | + WWW-Authenticate: DPoP algs="...", error="invalid_token", | |
| 113 | + error_description="Malformed token" | |
| 114 | + | |
| 115 | +That is the informative result. `invalid_token` rather than | |
| 116 | +`invalid_dpop_proof` means the PDS parsed and accepted the proof — signature, | |
| 117 | +claims, `ath`, `htu`, `htm` — and rejected only the credential. The challenge | |
| 118 | +scheme coming back as `DPoP` confirms the Authorization scheme too, and the | |
| 119 | +response carried a host nonce. | |
| 120 | + | |
| 121 | +`ath` is pinned against a value computed with `openssl dgst` rather than | |
| 122 | +checked for self-consistency, since a self-consistent hash would pass any | |
| 123 | +internal assertion and still be refused by every server. | |
| 124 | + | |
| 125 | +Two bugs the probe caught, both of which would have failed every authenticated | |
| 126 | +call in ways that are hard to read from the outside: | |
| 127 | + | |
| 128 | +- `htm` was built as `($httpMethod)[4..^1]`, assuming the enum stringified to | |
| 129 | + `HttpGet`. It stringifies to `GET`, so this was a range error, not a | |
| 130 | + mis-signed claim — loud, but only once something ran. | |
| 131 | +- `hostOf` returned an empty host for a scheme-less input like | |
| 132 | + `public.api.bsky.app`, because `parseUri` files that under `path`. URLs came | |
| 133 | + out as `https:///xrpc/...`. | |
| 134 | + | |
| 135 | +A third mistake was in the probe rather than the code: the first version | |
| 136 | +pointed at `com.atproto.repo.listRecords`, which is **public**. The PDS served | |
| 137 | +it with a 200 while ignoring the bogus credential entirely — a passing test | |
| 138 | +that tested nothing. `com.atproto.server.getSession` has to evaluate the | |
| 139 | +credential. | |
| 140 | + | |
| 93 | 141 | ## What this does and does not prove |
| 94 | 142 | |
| 95 | 143 | Proven: the cryptography, the storage layer, and the OAuth flow up to user |
| @@ -98,9 +146,10 @@ consent all work from Nim, in the formats ATProto and real servers accept. | ||
| 98 | 146 | Not proven, and still ahead: |
| 99 | 147 | |
| 100 | 148 | - **Token exchange and refresh** — written, but needs a browser consent to |
| 101 | - exercise. This is the one place a real end-to-end test still has to happen. | |
| 102 | -- **Session persistence** and the token-refresh lifecycle. | |
| 103 | -- **XRPC** request signing with the DPoP-bound access token. | |
| 149 | + exercise. This is the one place a real end-to-end test still has to happen, | |
| 150 | + and it also covers the `invalid_token` refresh-and-retry branch of | |
| 151 | + `xrpc.request`, which is currently unexercised. | |
| 152 | +- **Session persistence** across restarts. | |
| 104 | 153 | - The **Jetstream** websocket consumer. |
| 105 | 154 | - **CBOR/CAR** parsing for repository records. |
| 106 | 155 | |
| @@ -12,6 +12,7 @@ cd spike/nim | |||
| 12 | nim c -r sqlite_probe.nim # storage | 12 | nim c -r sqlite_probe.nim # storage |
| 13 | nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto | 13 | nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto |
| 14 | nim c -r -d:ssl oauth_probe.nim [handle] # the flow | 14 | nim c -r -d:ssl oauth_probe.nim [handle] # the flow |
| 15 | +nim c -r -d:ssl xrpc_probe.nim # signing | ||
| 15 | ``` | 16 | ``` |
| 16 | 17 | ||
| 17 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. | 18 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. |
| @@ -90,6 +91,53 @@ The flow stops at the authorization URL, which is correct — the next step is a | |||
| 90 | human granting consent in a browser. `exchangeCode` and `refresh` are | 91 | human granting consent in a browser. `exchangeCode` and `refresh` are |
| 91 | implemented against that callback but are consequently untested. | 92 | implemented against that callback but are consequently untested. |
| 92 | 93 | ||
| 94 | +## 4. XRPC signing — proof accepted by a real PDS | ||
| 95 | + | ||
| 96 | +`atproto/xrpc.nim` makes authenticated calls with a DPoP-bound access token: | ||
| 97 | +`Authorization: DPoP <token>` (not Bearer), a per-request proof, and the retry | ||
| 98 | +logic for both failure modes. | ||
| 99 | + | ||
| 100 | +Resource-server proofs are **not** the same as auth-server proofs, even when | ||
| 101 | +one host fills both roles. A PDS request carries `ath` (base64url SHA-256 of | ||
| 102 | +the access token, which is what binds the proof to that one credential) and | ||
| 103 | +`iss`; the PAR and token requests that mint the token carry neither. The nonce | ||
| 104 | +handshake also differs: a PDS answers **401** with `WWW-Authenticate: DPoP | ||
| 105 | +error="use_dpop_nonce"`, where the auth server used **400** and a JSON body. | ||
| 106 | +A session therefore tracks two independent nonces. | ||
| 107 | + | ||
| 108 | +`xrpc_probe.nim` cannot get a consented token, so it signs a request with a | ||
| 109 | +deliberately invalid one and reads what a real PDS says. Against | ||
| 110 | +`bsky.social`'s PDS: | ||
| 111 | + | ||
| 112 | + WWW-Authenticate: DPoP algs="...", error="invalid_token", | ||
| 113 | + error_description="Malformed token" | ||
| 114 | + | ||
| 115 | +That is the informative result. `invalid_token` rather than | ||
| 116 | +`invalid_dpop_proof` means the PDS parsed and accepted the proof — signature, | ||
| 117 | +claims, `ath`, `htu`, `htm` — and rejected only the credential. The challenge | ||
| 118 | +scheme coming back as `DPoP` confirms the Authorization scheme too, and the | ||
| 119 | +response carried a host nonce. | ||
| 120 | + | ||
| 121 | +`ath` is pinned against a value computed with `openssl dgst` rather than | ||
| 122 | +checked for self-consistency, since a self-consistent hash would pass any | ||
| 123 | +internal assertion and still be refused by every server. | ||
| 124 | + | ||
| 125 | +Two bugs the probe caught, both of which would have failed every authenticated | ||
| 126 | +call in ways that are hard to read from the outside: | ||
| 127 | + | ||
| 128 | +- `htm` was built as `($httpMethod)[4..^1]`, assuming the enum stringified to | ||
| 129 | + `HttpGet`. It stringifies to `GET`, so this was a range error, not a | ||
| 130 | + mis-signed claim — loud, but only once something ran. | ||
| 131 | +- `hostOf` returned an empty host for a scheme-less input like | ||
| 132 | + `public.api.bsky.app`, because `parseUri` files that under `path`. URLs came | ||
| 133 | + out as `https:///xrpc/...`. | ||
| 134 | + | ||
| 135 | +A third mistake was in the probe rather than the code: the first version | ||
| 136 | +pointed at `com.atproto.repo.listRecords`, which is **public**. The PDS served | ||
| 137 | +it with a 200 while ignoring the bogus credential entirely — a passing test | ||
| 138 | +that tested nothing. `com.atproto.server.getSession` has to evaluate the | ||
| 139 | +credential. | ||
| 140 | + | ||
| 93 | ## What this does and does not prove | 141 | ## What this does and does not prove |
| 94 | 142 | ||
| 95 | Proven: the cryptography, the storage layer, and the OAuth flow up to user | 143 | Proven: the cryptography, the storage layer, and the OAuth flow up to user |
| @@ -98,9 +146,10 @@ consent all work from Nim, in the formats ATProto and real servers accept. | |||
| 98 | Not proven, and still ahead: | 146 | Not proven, and still ahead: |
| 99 | 147 | ||
| 100 | - **Token exchange and refresh** — written, but needs a browser consent to | 148 | - **Token exchange and refresh** — written, but needs a browser consent to |
| 101 | - exercise. This is the one place a real end-to-end test still has to happen. | 149 | + exercise. This is the one place a real end-to-end test still has to happen, |
| 102 | -- **Session persistence** and the token-refresh lifecycle. | 150 | + and it also covers the `invalid_token` refresh-and-retry branch of |
| 103 | -- **XRPC** request signing with the DPoP-bound access token. | 151 | + `xrpc.request`, which is currently unexercised. |
| 152 | +- **Session persistence** across restarts. | ||
| 104 | - The **Jetstream** websocket consumer. | 153 | - The **Jetstream** websocket consumer. |
| 105 | - **CBOR/CAR** parsing for repository records. | 154 | - **CBOR/CAR** parsing for repository records. |
| 106 | 155 | ||
added
spike/nim/xrpc_probe.nim +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +## Exercises the XRPC layer as far as it goes without a consented session. | |
| 2 | +## | |
| 3 | +## nim c -r -d:ssl xrpc_probe.nim | |
| 4 | +## | |
| 5 | +## A valid access token needs a human in a browser, so the authenticated path | |
| 6 | +## is probed with a deliberately invalid one. That still proves the parts most | |
| 7 | +## likely to be wrong: that a real PDS accepts the *shape* of our request and | |
| 8 | +## rejects it for the right reason, and that the 401/WWW-Authenticate nonce | |
| 9 | +## handshake behaves the way the retry logic assumes. | |
| 10 | + | |
| 11 | +import std/[httpclient, json, strformat, strutils] | |
| 12 | +import bearssl/rand | |
| 13 | +import atproto/[dpop, identity, xrpc] | |
| 14 | + | |
| 15 | +var failures = 0 | |
| 16 | + | |
| 17 | +proc report(name: string, ok: bool, detail = "") = | |
| 18 | + if not ok: inc failures | |
| 19 | + let label = if ok: "PASS" else: "FAIL" | |
| 20 | + if detail.len > 0: echo &" {label} {name} -- {detail}" | |
| 21 | + else: echo &" {label} {name}" | |
| 22 | + | |
| 23 | +when isMainModule: | |
| 24 | + echo "XRPC signing probe" | |
| 25 | + echo "" | |
| 26 | + | |
| 27 | + let rngRef = HmacDrbgContext.new() | |
| 28 | + doAssert rngRef != nil | |
| 29 | + var rng = rngRef[] | |
| 30 | + | |
| 31 | + # --- claim construction ------------------------------------------------- | |
| 32 | + block: | |
| 33 | + report("htu strips the query string", | |
| 34 | + dpopUrl("https://pds.example/xrpc/a.b.c?limit=5#frag") == | |
| 35 | + "https://pds.example/xrpc/a.b.c", | |
| 36 | + dpopUrl("https://pds.example/xrpc/a.b.c?limit=5#frag")) | |
| 37 | + | |
| 38 | + block: | |
| 39 | + # Pinned against an independently computed value: | |
| 40 | + # printf 'test-access-token' | openssl dgst -sha256 -binary \ | |
| 41 | + # | basenc --base64url | tr -d '=' | |
| 42 | + # A self-consistent ath would pass any internal check and still be | |
| 43 | + # rejected by every PDS. | |
| 44 | + const | |
| 45 | + token = "test-access-token" | |
| 46 | + want = "WXSA1LYsphIZPxnnP-TMOtF_C_nPwWp8v0tQZBMcSAU" | |
| 47 | + let ath = accessTokenHash(token) | |
| 48 | + report("ath matches an independently computed SHA-256", ath == want, ath) | |
| 49 | + report("ath is unpadded base64url", ath.len == 43 and '=' notin ath) | |
| 50 | + | |
| 51 | + block: | |
| 52 | + let key = generateKey(rng) | |
| 53 | + let proof = dpopProof(key, rng, "GET", "https://pds.example/xrpc/a.b.c", | |
| 54 | + accessToken = "tok", issuer = "https://auth.example") | |
| 55 | + let payload = parseJson(b64uDecode(proof.split('.')[1])) | |
| 56 | + report("resource proof carries ath", payload.hasKey("ath")) | |
| 57 | + report("resource proof carries iss", payload{"iss"}.getStr == "https://auth.example") | |
| 58 | + report("resource proof carries exp", payload.hasKey("exp")) | |
| 59 | + | |
| 60 | + let authProof = dpopProof(key, rng, "POST", "https://auth.example/oauth/par") | |
| 61 | + let authPayload = parseJson(b64uDecode(authProof.split('.')[1])) | |
| 62 | + # An auth-server proof must NOT claim to be bound to a token. | |
| 63 | + report("auth-server proof omits ath", not authPayload.hasKey("ath")) | |
| 64 | + | |
| 65 | + # --- unauthenticated XRPC ----------------------------------------------- | |
| 66 | + # Separates "can we speak XRPC" from "is our signing right". | |
| 67 | + block: | |
| 68 | + try: | |
| 69 | + let j = publicQuery("public.api.bsky.app", "app.bsky.actor.getProfile", | |
| 70 | + {"actor": "bsky.app"}) | |
| 71 | + report("unauthenticated XRPC query works", j{"did"}.getStr.isDid, | |
| 72 | + j{"handle"}.getStr) | |
| 73 | + except CatchableError as e: | |
| 74 | + report("unauthenticated XRPC query works", false, e.msg) | |
| 75 | + | |
| 76 | + # --- the authenticated path, against a real PDS ------------------------- | |
| 77 | + var ident: Identity | |
| 78 | + try: | |
| 79 | + ident = lookup("bsky.app") | |
| 80 | + except CatchableError as e: | |
| 81 | + report("resolve a PDS to talk to", false, e.msg) | |
| 82 | + quit 1 | |
| 83 | + report("resolved a PDS", true, ident.pdsEndpoint) | |
| 84 | + | |
| 85 | + block: | |
| 86 | + # A well-formed DPoP request carrying a token the PDS has never issued. | |
| 87 | + # The interesting question is *which* error comes back: a shape problem | |
| 88 | + # would read as invalid_dpop_proof, whereas invalid_token means the PDS | |
| 89 | + # parsed and accepted everything except the credential itself. | |
| 90 | + var sess = Session( | |
| 91 | + did: ident.did, | |
| 92 | + pdsUrl: ident.pdsEndpoint, | |
| 93 | + authServerUrl: "https://bsky.social", | |
| 94 | + accessToken: "not-a-real-token", | |
| 95 | + dpopKey: generateKey(rng), | |
| 96 | + clientId: "http://localhost", | |
| 97 | + ) | |
| 98 | + # getSession, not listRecords: repo reads are public, so a PDS serves them | |
| 99 | + # happily while ignoring the Authorization header entirely -- which looks | |
| 100 | + # like a pass and tests nothing. This endpoint exists to answer "who am | |
| 101 | + # I", so it has to evaluate the credential. | |
| 102 | + let url = &"{sess.pdsUrl}/xrpc/com.atproto.server.getSession" | |
| 103 | + let res = request(sess, rng, HttpGet, url) | |
| 104 | + | |
| 105 | + report("PDS rejects an unknown token with 401", res.code.int == 401, | |
| 106 | + &"HTTP {res.code.int}") | |
| 107 | + | |
| 108 | + let wwwAuth = res.headers.getOrDefault("WWW-Authenticate").string | |
| 109 | + report("401 carries WWW-Authenticate", wwwAuth.len > 0, wwwAuth) | |
| 110 | + report("challenge is DPoP, not Bearer", | |
| 111 | + wwwAuth.startsWith("DPoP"), wwwAuth.split(' ')[0]) | |
| 112 | + report("the proof itself was accepted (error is about the token, "& | |
| 113 | + "not the proof)", | |
| 114 | + wwwAuth.len > 0 and "invalid_dpop_proof" notin wwwAuth, wwwAuth) | |
| 115 | + report("PDS issued a host nonce for the retry", sess.hostNonce.len > 0, | |
| 116 | + sess.hostNonce[0 ..< min(12, sess.hostNonce.len)] & "...") | |
| 117 | + | |
| 118 | + echo "" | |
| 119 | + if failures == 0: | |
| 120 | + echo "RESULT: XRPC signing is correct as far as an unconsented session can show." | |
| 121 | + else: | |
| 122 | + echo &"RESULT: {failures} check(s) failed." | |
| 123 | + quit 1 | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +## Exercises the XRPC layer as far as it goes without a consented session. | ||
| 2 | +## | ||
| 3 | +## nim c -r -d:ssl xrpc_probe.nim | ||
| 4 | +## | ||
| 5 | +## A valid access token needs a human in a browser, so the authenticated path | ||
| 6 | +## is probed with a deliberately invalid one. That still proves the parts most | ||
| 7 | +## likely to be wrong: that a real PDS accepts the *shape* of our request and | ||
| 8 | +## rejects it for the right reason, and that the 401/WWW-Authenticate nonce | ||
| 9 | +## handshake behaves the way the retry logic assumes. | ||
| 10 | + | ||
| 11 | +import std/[httpclient, json, strformat, strutils] | ||
| 12 | +import bearssl/rand | ||
| 13 | +import atproto/[dpop, identity, xrpc] | ||
| 14 | + | ||
| 15 | +var failures = 0 | ||
| 16 | + | ||
| 17 | +proc report(name: string, ok: bool, detail = "") = | ||
| 18 | + if not ok: inc failures | ||
| 19 | + let label = if ok: "PASS" else: "FAIL" | ||
| 20 | + if detail.len > 0: echo &" {label} {name} -- {detail}" | ||
| 21 | + else: echo &" {label} {name}" | ||
| 22 | + | ||
| 23 | +when isMainModule: | ||
| 24 | + echo "XRPC signing probe" | ||
| 25 | + echo "" | ||
| 26 | + | ||
| 27 | + let rngRef = HmacDrbgContext.new() | ||
| 28 | + doAssert rngRef != nil | ||
| 29 | + var rng = rngRef[] | ||
| 30 | + | ||
| 31 | + # --- claim construction ------------------------------------------------- | ||
| 32 | + block: | ||
| 33 | + report("htu strips the query string", | ||
| 34 | + dpopUrl("https://pds.example/xrpc/a.b.c?limit=5#frag") == | ||
| 35 | + "https://pds.example/xrpc/a.b.c", | ||
| 36 | + dpopUrl("https://pds.example/xrpc/a.b.c?limit=5#frag")) | ||
| 37 | + | ||
| 38 | + block: | ||
| 39 | + # Pinned against an independently computed value: | ||
| 40 | + # printf 'test-access-token' | openssl dgst -sha256 -binary \ | ||
| 41 | + # | basenc --base64url | tr -d '=' | ||
| 42 | + # A self-consistent ath would pass any internal check and still be | ||
| 43 | + # rejected by every PDS. | ||
| 44 | + const | ||
| 45 | + token = "test-access-token" | ||
| 46 | + want = "WXSA1LYsphIZPxnnP-TMOtF_C_nPwWp8v0tQZBMcSAU" | ||
| 47 | + let ath = accessTokenHash(token) | ||
| 48 | + report("ath matches an independently computed SHA-256", ath == want, ath) | ||
| 49 | + report("ath is unpadded base64url", ath.len == 43 and '=' notin ath) | ||
| 50 | + | ||
| 51 | + block: | ||
| 52 | + let key = generateKey(rng) | ||
| 53 | + let proof = dpopProof(key, rng, "GET", "https://pds.example/xrpc/a.b.c", | ||
| 54 | + accessToken = "tok", issuer = "https://auth.example") | ||
| 55 | + let payload = parseJson(b64uDecode(proof.split('.')[1])) | ||
| 56 | + report("resource proof carries ath", payload.hasKey("ath")) | ||
| 57 | + report("resource proof carries iss", payload{"iss"}.getStr == "https://auth.example") | ||
| 58 | + report("resource proof carries exp", payload.hasKey("exp")) | ||
| 59 | + | ||
| 60 | + let authProof = dpopProof(key, rng, "POST", "https://auth.example/oauth/par") | ||
| 61 | + let authPayload = parseJson(b64uDecode(authProof.split('.')[1])) | ||
| 62 | + # An auth-server proof must NOT claim to be bound to a token. | ||
| 63 | + report("auth-server proof omits ath", not authPayload.hasKey("ath")) | ||
| 64 | + | ||
| 65 | + # --- unauthenticated XRPC ----------------------------------------------- | ||
| 66 | + # Separates "can we speak XRPC" from "is our signing right". | ||
| 67 | + block: | ||
| 68 | + try: | ||
| 69 | + let j = publicQuery("public.api.bsky.app", "app.bsky.actor.getProfile", | ||
| 70 | + {"actor": "bsky.app"}) | ||
| 71 | + report("unauthenticated XRPC query works", j{"did"}.getStr.isDid, | ||
| 72 | + j{"handle"}.getStr) | ||
| 73 | + except CatchableError as e: | ||
| 74 | + report("unauthenticated XRPC query works", false, e.msg) | ||
| 75 | + | ||
| 76 | + # --- the authenticated path, against a real PDS ------------------------- | ||
| 77 | + var ident: Identity | ||
| 78 | + try: | ||
| 79 | + ident = lookup("bsky.app") | ||
| 80 | + except CatchableError as e: | ||
| 81 | + report("resolve a PDS to talk to", false, e.msg) | ||
| 82 | + quit 1 | ||
| 83 | + report("resolved a PDS", true, ident.pdsEndpoint) | ||
| 84 | + | ||
| 85 | + block: | ||
| 86 | + # A well-formed DPoP request carrying a token the PDS has never issued. | ||
| 87 | + # The interesting question is *which* error comes back: a shape problem | ||
| 88 | + # would read as invalid_dpop_proof, whereas invalid_token means the PDS | ||
| 89 | + # parsed and accepted everything except the credential itself. | ||
| 90 | + var sess = Session( | ||
| 91 | + did: ident.did, | ||
| 92 | + pdsUrl: ident.pdsEndpoint, | ||
| 93 | + authServerUrl: "https://bsky.social", | ||
| 94 | + accessToken: "not-a-real-token", | ||
| 95 | + dpopKey: generateKey(rng), | ||
| 96 | + clientId: "http://localhost", | ||
| 97 | + ) | ||
| 98 | + # getSession, not listRecords: repo reads are public, so a PDS serves them | ||
| 99 | + # happily while ignoring the Authorization header entirely -- which looks | ||
| 100 | + # like a pass and tests nothing. This endpoint exists to answer "who am | ||
| 101 | + # I", so it has to evaluate the credential. | ||
| 102 | + let url = &"{sess.pdsUrl}/xrpc/com.atproto.server.getSession" | ||
| 103 | + let res = request(sess, rng, HttpGet, url) | ||
| 104 | + | ||
| 105 | + report("PDS rejects an unknown token with 401", res.code.int == 401, | ||
| 106 | + &"HTTP {res.code.int}") | ||
| 107 | + | ||
| 108 | + let wwwAuth = res.headers.getOrDefault("WWW-Authenticate").string | ||
| 109 | + report("401 carries WWW-Authenticate", wwwAuth.len > 0, wwwAuth) | ||
| 110 | + report("challenge is DPoP, not Bearer", | ||
| 111 | + wwwAuth.startsWith("DPoP"), wwwAuth.split(' ')[0]) | ||
| 112 | + report("the proof itself was accepted (error is about the token, "& | ||
| 113 | + "not the proof)", | ||
| 114 | + wwwAuth.len > 0 and "invalid_dpop_proof" notin wwwAuth, wwwAuth) | ||
| 115 | + report("PDS issued a host nonce for the retry", sess.hostNonce.len > 0, | ||
| 116 | + sess.hostNonce[0 ..< min(12, sess.hostNonce.len)] & "...") | ||
| 117 | + | ||
| 118 | + echo "" | ||
| 119 | + if failures == 0: | ||
| 120 | + echo "RESULT: XRPC signing is correct as far as an unconsented session can show." | ||
| 121 | + else: | ||
| 122 | + echo &"RESULT: {failures} check(s) failed." | ||
| 123 | + quit 1 | ||