Summary
src/auth/nostr.js verifies the NIP-98 payload tag by hashing JSON.stringify(request.body) — i.e. the parsed-then-re-serialized (compact) body — instead of the raw request bytes the client actually signed. Any NIP-98 client that sends non-minified JSON (pretty-printed, or a different key order / string escaping than Node's JSON.stringify produces) fails with Payload hash mismatch → 401, even though the signature, URL, method, and timestamp are all valid.
Where
src/auth/nostr.js, the payload-hash block (~L244–262):
const payloadTag = getTagValue(event, 'payload');
if (payloadTag && request.body) {
let bodyString;
if (typeof request.body === 'string') {
bodyString = request.body;
} else if (Buffer.isBuffer(request.body)) {
bodyString = request.body.toString();
} else {
bodyString = JSON.stringify(request.body); // <-- re-serializes the PARSED object (compact)
}
const expectedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
if (payloadTag.toLowerCase() !== expectedHash.toLowerCase()) {
return { webId: null, error: 'Payload hash mismatch' };
}
}
When the JSON body parser turns the request into an object, request.body is that object, so the else branch runs JSON.stringify(request.body) → compact JSON. The client (per NIP-98) hashed the exact body bytes it sent. If those bytes were pretty-printed (JSON.stringify(x, null, 2)) — or just serialized by a different implementation — the two hashes differ.
Repro
PUT a WAC-protected resource with NIP-98 auth and a pretty-printed JSON body:
PUT /…/pool/entries/<pubkey>.json
Authorization: Nostr <signed kind-27235 event, payload = sha256(prettyBody)>
Content-Type: application/json
Body: JSON.stringify(entry, null, 2)
→ 401. Server log:
{"level":40,"authError":"Payload hash mismatch","method":"PUT","urlPath":"/…/pool/entries/<pubkey>.json","hasAuth":true,"msg":"Auth error"}
Sending the same entry as compact JSON.stringify(entry) succeeds — because it happens to match the server's re-serialization. That's a brittle coincidence, not correctness.
Impact
- NIP-98 writes only work if the client's body is byte-identical to Node's
JSON.stringify(parsed) — i.e. minified, with Node's exact key order and escaping. Pretty-printed bodies, or bodies from other serializers, always 401.
- It silently pushes every NIP-98 client toward "send minified JSON," which isn't a real spec requirement and is easy to get wrong. (We hit this from a browser; worked around it by forcing compact
JSON.stringify.)
- DPoP/Solid writes are unaffected (no payload-hash check), so the bug only bites Nostr auth — making it look like "NIP-98 writes are broken" when the signature is actually fine.
Proposed fix
Hash the raw request body bytes, never a re-serialization. NIP-98's payload tag is sha256(request body) over the bytes on the wire. Options:
- Capture the raw body (e.g. a Fastify content-type parser that stashes the original buffer/string, or
fastify-raw-body) and hash that.
- Then
bodyString is the original bytes regardless of pretty/compact/escaping, and any conformant NIP-98 client verifies.
Keep the existing string/Buffer branches as-is; only the else { JSON.stringify(...) } fallback is wrong — there should be no fallback that re-serializes the parsed object.
Notes
- Found while onboarding a Nostr guest key (xlogin
guestLogin) writing a pool entry from a browser; the signature/URL/method all verified, only the payload hash failed.
Summary
src/auth/nostr.jsverifies the NIP-98payloadtag by hashingJSON.stringify(request.body)— i.e. the parsed-then-re-serialized (compact) body — instead of the raw request bytes the client actually signed. Any NIP-98 client that sends non-minified JSON (pretty-printed, or a different key order / string escaping than Node'sJSON.stringifyproduces) fails withPayload hash mismatch→ 401, even though the signature, URL, method, and timestamp are all valid.Where
src/auth/nostr.js, the payload-hash block (~L244–262):When the JSON body parser turns the request into an object,
request.bodyis that object, so theelsebranch runsJSON.stringify(request.body)→ compact JSON. The client (per NIP-98) hashed the exact body bytes it sent. If those bytes were pretty-printed (JSON.stringify(x, null, 2)) — or just serialized by a different implementation — the two hashes differ.Repro
PUTa WAC-protected resource with NIP-98 auth and a pretty-printed JSON body:→
401. Server log:Sending the same entry as compact
JSON.stringify(entry)succeeds — because it happens to match the server's re-serialization. That's a brittle coincidence, not correctness.Impact
JSON.stringify(parsed)— i.e. minified, with Node's exact key order and escaping. Pretty-printed bodies, or bodies from other serializers, always 401.JSON.stringify.)Proposed fix
Hash the raw request body bytes, never a re-serialization. NIP-98's
payloadtag issha256(request body)over the bytes on the wire. Options:fastify-raw-body) and hash that.bodyStringis the original bytes regardless of pretty/compact/escaping, and any conformant NIP-98 client verifies.Keep the existing string/Buffer branches as-is; only the
else { JSON.stringify(...) }fallback is wrong — there should be no fallback that re-serializes the parsed object.Notes
guestLogin) writing a pool entry from a browser; the signature/URL/method all verified, only the payload hash failed.