Skip to content

[DISCUSSION] createNonce / verifyNonce are misleading for auth — and redundant with signatures #168

@kjartan221

Description

@kjartan221

createNonce / verifyNonce are misleading for auth — and redundant with signatures

TL;DR

createNonce / verifyNonce in @bsv/sdk are easy to mistake for a
ready-made "prove the caller owns this key" auth mechanism. They aren't a good
fit for that, for three reasons:

  1. The name "nonce" implies single-use / replay protection that the
    primitive does not provide.
  2. For request authentication they are redundant with createSignature /
    verifySignature
    , which do the same job better (asymmetric, and able to
    sign arbitrary data such as an expiry).
  3. Used as a standalone auth token, the only way to make them truly single-use
    is to store every nonce forever, because they never expire — which isn't
    plausible.

What the primitive actually is

createNonce(wallet, counterparty):
  bytes = Random(16)
  hmac  = wallet.createHmac({ protocolID: [2,'server hmac'], keyID: utf8(bytes), data: bytes, counterparty })
  return base64(bytes ‖ hmac)

verifyNonce(nonce, wallet, counterparty):
  re-derive the HMAC over the first 16 bytes and compare

So a "nonce" here is 16 random bytes plus an HMAC of those bytes, keyed by
the ECDH shared secret between the two identities. There is no timestamp, no
expiry, and no embedded caller data — the only thing authenticated is the random
bytes the function generated itself.

1. The name oversells it

"Nonce" means number used once — it implies freshness and replay protection.
This primitive provides neither: it has no expiry and no single-use bookkeeping.
It's really a keyed token, not a nonce in the anti-replay sense. Developers
reasonably assume "verifyNonce passed → this is a fresh, one-time request," and
that assumption is wrong.

2. It's redundant with signatures for auth

If the goal is "prove the request came from the holder of identity key K",
createSignature / verifySignature already do that, and strictly better:

  • Asymmetric. A signature can only be produced by K's private key. The
    nonce HMAC is keyed by the ECDH shared secret, so it's symmetric — either
    party (client or server) can produce a value that verifies.
  • Can carry data. A signature can sign an arbitrary payload, so you can bind
    an expiry, an action, and an audience into the thing you verify.
    The nonce can carry none of that — it only MACs random bytes.

So once you have signatures, a separate "nonce" primitive adds no security to an
auth flow; it just looks like it does.

3. As a security control it would need unbounded, permanent storage

The only way to turn a never-expiring token into a genuine single-use one is to
remember every token you've ever accepted — forever. The moment the server
evicts a used nonce, that nonce becomes replayable again, because nothing in the
nonce itself says "this is stale." Permanent unbounded storage of every login
handshake is not plausible in practice.

Contrast a signed payload with an expiry: the server rejects anything past
expiresAt, and only needs to remember used tokens until they expire
bounded storage (e.g. a TTL collection / short-lived cache). Replay is closed
without storing anything forever.

What the primitive was actually designed for

In the SDK's own auth protocol (Peer / BRC-103), createNonce is called with
counterparty 'self': the server issues a challenge it can later recognise as
its own
(a stateless self-MAC), and the actual authentication comes from
ECDSA signatures over the challenge pair plus a session manager that tracks
nonces
for freshness/single-use. The nonce there is challenge plumbing inside a
challenge–response handshake — not a standalone bearer proof.

The trap is that copying createNonce + verifyNonce into a one-shot "login"
endpoint silently drops the two things that made them safe in context: the
signature, and the tracking.

Note that the session manager's nonce tracking runs into the same storage
problem from section 3
— a never-expiring nonce can only be "used once" if you
remember it forever. The difference is what the tracking is guarding: in the
intended design the ECDSA signature is the real authentication, so the nonce
tracking is only defense-in-depth, and bounding/forgetting it is effectively a
no-op for security. In the misuse case the nonce is the guard, so that same
unbounded-storage problem becomes fatal rather than cosmetic.

Suggestions

  • Clarify in the docs/JSDoc that these are challenge tokens for a
    challenge–response handshake
    , not a standalone proof of key ownership, and
    that they carry no expiry or replay protection on their own.
  • Point auth use-cases at createSignature / verifySignature over a payload
    that includes an expiry (+ a short-lived single-use store), rather than at
    createNonce.
  • Consider a name that doesn't imply single-use (e.g. "challenge"), or a brief
    "Don't use this for login" note with a pointer to the signature approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions