Skip to content

NIP-98 payload-hash check hashes re-serialized body, not raw — rejects valid non-minified JSON (401) #565

Description

@melvincarvalho

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 mismatch401, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions