nandi/gleanpublic Fork
⑂ 72af361
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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>
nandithebull committed 2026-09-20T21:24:31-07:00 Browse files
72af361 parent: c960984
modified spike/nim/.gitignore +1 -0
@@ -3,3 +3,4 @@ nimcache/
33 sqlite_probe
44 dpop_probe
55 oauth_probe
6+xrpc_probe
@@ -3,3 +3,4 @@ nimcache/
3 sqlite_probe3 sqlite_probe
4 dpop_probe4 dpop_probe
5 oauth_probe5 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
8484 doAssert n == 64, &"expected a 64-byte raw signature, got {n}"
8585 result = sig.toSeq
8686
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+
87102 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.
90110 let header = %*{"typ": "dpop+jwt", "alg": "ES256", "jwk": k.publicJwk}
111+ let issued = now().utc.toTime.toUnix
91112 var payload = %*{
92113 "jti": b64u(sha256.digest(&"{htm}{htu}{epochTime()}").data)[0 ..< 16],
93114 "htm": htm,
94115 "htu": htu,
95- "iat": now().utc.toTime.toUnix,
116+ "iat": issued,
117+ "exp": issued + 300,
96118 }
97119 if nonce.len > 0:
98120 payload["nonce"] = %nonce
121+ if accessToken.len > 0:
122+ payload["ath"] = %accessTokenHash(accessToken)
123+ if issuer.len > 0:
124+ payload["iss"] = %issuer
99125
100126 let signingInput = b64u($header) & "." & b64u($payload)
101127 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.toSeq85 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"] = %nonce120 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 =
138138 raise newException(IdentityError, "identity has no atproto PDS: " & did)
139139
140140 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)
142149 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.hostname149 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
1212 nim c -r sqlite_probe.nim # storage
1313 nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto
1414 nim c -r -d:ssl oauth_probe.nim [handle] # the flow
15+nim c -r -d:ssl xrpc_probe.nim # signing
1516 ```
1617
1718 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
9091 human granting consent in a browser. `exchangeCode` and `refresh` are
9192 implemented against that callback but are consequently untested.
9293
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+
93141 ## What this does and does not prove
94142
95143 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.
98146 Not proven, and still ahead:
99147
100148 - **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.
104153 - The **Jetstream** websocket consumer.
105154 - **CBOR/CAR** parsing for repository records.
106155
@@ -12,6 +12,7 @@ cd spike/nim
12 nim c -r sqlite_probe.nim # storage12 nim c -r sqlite_probe.nim # storage
13 nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto13 nim c -r dpop_probe.nim && ./dpop_probe | (cd verify && go run .) # crypto
14 nim c -r -d:ssl oauth_probe.nim [handle] # the flow14 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` are91 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 prove141 ## What this does and does not prove
94 142
95 Proven: the cryptography, the storage layer, and the OAuth flow up to user143 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 to148 - **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