Class VoucherCanonicalBytes

java.lang.Object
xyz.tcheeric.cashu.voucher.domain.VoucherCanonicalBytes

public final class VoucherCanonicalBytes extends Object
Renders the exact bytes an issuer signature commits to.

This is the single definition of the voucher signing preimage. It is deliberately its own class rather than a detail of VoucherSignatureService: the bytes are a wire contract shared with every verifier, including the offline TypeScript wallet, so anything that needs to reproduce them must be able to call the one implementation instead of copying it.

The form is [kind, "data_hex", "nonce", [[tag, value...], ...]], matching WellKnownSecretSerializer, with issuer_sig and issuer_pubkey omitted because they are only added after signing.

The kind is read from the secret

It used to be the literal "VOUCHER". Two kinds now carry voucher metadata — VOUCHER and P2PK_VOUCHER, the latter being a voucher that is also P2PK-locked — and the signature has to commit to which one it is. A fixed string would let a signature made for an unlocked voucher verify against a locked one carrying the same metadata, and vice versa, so the kind would not be covered by what the issuer signed.

This does not change the bytes for a VOUCHER secret: the value read is the same string that was previously hardcoded, so every signature made under the old code still verifies.

Why the tag key decides what is numeric

NUT-10 carries every tag value as a string, so the runtime type of a value says nothing about how it was written when a signature was made. An earlier version keyed off value instanceof Number; when cashu-lib began modelling tag values as String, every numeric tag silently changed from 1000 to "1000" and every voucher signature ever issued would have stopped verifying. The voucher tag schema is fixed and known, so the key is the durable record of which values are numbers.

See Also:
  • Nested Class Summary

    Nested Classes
    Modifier and Type
    Class
    Description
    static enum 
    How numeric tag values are rendered.
  • Method Summary

    Modifier and Type
    Method
    Description
    static boolean
    hasCanonicalNumericTags(@NonNull xyz.tcheeric.cashu.common.nut10.WellKnownSecret secret)
    Whether every numeric tag is already written in its canonical form.
    static byte[]
    of(@NonNull xyz.tcheeric.cashu.common.nut10.WellKnownSecret secret)
    Renders the canonical signing bytes for a voucher secret.
    static byte[]
    of(@NonNull xyz.tcheeric.cashu.common.nut10.WellKnownSecret secret, @NonNull VoucherCanonicalBytes.NumericTagForm numericForm)
    Renders the canonical signing bytes, choosing how numeric values are written.

    Methods inherited from class java.lang.Object

    clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait
  • Method Details

    • of

      public static byte[] of(@NonNull @NonNull xyz.tcheeric.cashu.common.nut10.WellKnownSecret secret)
      Renders the canonical signing bytes for a voucher secret.
      Parameters:
      secret - the voucher secret to render; either a VoucherSecret or a P2PKVoucherSecret
      Returns:
      the bytes that are hashed and signed
    • of

      public static byte[] of(@NonNull @NonNull xyz.tcheeric.cashu.common.nut10.WellKnownSecret secret, @NonNull @NonNull VoucherCanonicalBytes.NumericTagForm numericForm)
      Renders the canonical signing bytes, choosing how numeric values are written.
      Parameters:
      secret - the voucher secret to render
      numericForm - the rendering of numeric tag values
      Returns:
      the bytes that are hashed and signed
      Throws:
      IllegalArgumentException - if the secret's kind carries no voucher metadata
    • hasCanonicalNumericTags

      public static boolean hasCanonicalNumericTags(@NonNull @NonNull xyz.tcheeric.cashu.common.nut10.WellKnownSecret secret)
      Whether every numeric tag is already written in its canonical form.

      Why a signature is not enough on its own

      of(xyz.tcheeric.cashu.common.nut10.WellKnownSecret) NORMALISES numeric tags before hashing, which it must: the hash has to be stable across the Java and TypeScript readers. The side effect is that "1000", "01000", "1000.0" and "1e3" all hash the same, so one signature verifies over all four.

      That would be harmless if every reader agreed on the value. They do not. VoucherSecret.getFaceValue parses with Long.parseLong, which refuses "1000.0" and "1e3" and so yields NO face value, while the wallet's Number() reads both as 1000. One signed voucher, two readings, and a holder picks whichever suits them. Dropping the Java face-value clamp is what that buys.

      So verification asks this as well: were the bytes already canonical, or did they only become canonical because we normalised them? A voucher in the second category is refused. Issuers produce the canonical form, so nothing genuine is affected.

      cashu-voucher#48. The same rule is what lets the gateway safely sign a secret a wallet supplied: an ambiguous number must never reach a signing key.

      Parameters:
      secret - the voucher secret whose tags to inspect
      Returns:
      true when no numeric tag needed normalising