Internet-Draft Rollatini October 2026
Schlesinger, et al. Expires 11 April 2027 [Page]
Workgroup:
Network Working Group
Internet-Draft:
draft-authors-mole-rollatini-latest
Published:
Intended Status:
Informational
Expires:
Authors:
S. Schlesinger
Google LLC
W. Ladd
Akamai Technologies
D. I. Mohan
Georgia Institute of Technology

Rollatini: An Issuer-Hiding Anonymous Token

Abstract

This document specifies Rollatini, a cryptographic construction used to produce and consume MoLE Endorsements. An Endorsement is an anonymous token that an Anchor issues to a Client, and that the Client later redeems at a Moderator without the Anchor being able to link the redemption to the issuance.

This document defines the endorsement issuance protocol, built from a pairing-free partially blind signature scheme, together with the group, encoding, and context-binding rules that both the Anchor and the Client follow.

About This Document

This note is to be removed before publishing as an RFC.

The latest revision of this draft can be found at https://moderation-of-unlinkable-endorsements.github.io/internet-drafts/draft-authors-mole-rollatini.html. Status information for this document may be found at https://datatracker.ietf.org/doc/draft-authors-mole-rollatini/.

Source for this draft and an issue tracker can be found at https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts.

Status of This Memo

This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.

Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.

Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."

This Internet-Draft will expire on 11 April 2027.

▲

Table of Contents

1. Introduction

MoLE Endorsements have a number of constraints imposed by the architecture [ARCH]. They must be unlinkable by the Anchor that issued them, they must be publicly verifiable, and a redemption must hide which Anchor issued the Endorsement among the set of Anchors a Moderator accepts. Existing systems do not meet all of these needs. This document defines such a system, Rollatini, an Issuer-Hiding Anonymous Token (IHAT).

Rollatini follows the construction described in Section 3.4 of [FFKLLS26], which in turn is based on the pairing-free partially blind signature of [TESSZHU]. An Anchor holds a signing key and issues, in three moves, a signature on a Client-chosen message that the Anchor never sees. Each Endorsement is also bound at issuance time to two contexts. These may be used to limit the validity scope of each Endorsement, i.e., when and for whom it may later be used:

[PROTOCOLS] maps the cryptographic algorithms to the MoLE grant and redemption APIs. It supplies the issuance and redemption contexts; this document treats those contexts as opaque byte strings. Configuration encodings and discovery remain open work in [PROTOCOLS].

The remainder of this document is organized as follows. After defining conventions and cryptographic preliminaries (Section 3), we given an overview of issuance and specify some functionalities common to issuance and redemption Section 4. Section 5 specifies the issuance flow, including message encodings and session handling. Section 6 specifies issuer-hiding redemption, including key rerandomization and the proof that the issuing Anchor belongs to the Moderator's Anchor Set. Section 7 defines the supported ciphersuites, and Section 8 discusses the construction's security and deployment considerations. Section 9 covers IANA considerations, and Appendix A provides test vectors for the ciphersuites defined here.

2. Conventions and Definitions

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.

The terms Client, Anchor, Moderator, Endorsement, Credential, and Anchor Set are used as defined in [ARCH].

Unless otherwise specified, this document encodes protocol messages in TLS notation (Section 3 of [TLS13]). Moreover, all constants are in network byte order. This document also uses the variable-size vector convention <V> defined in [HTTP-TRANSPORT]: the length prefix of such a vector is a variable-length integer in its minimum-size encoding, so its width depends on the length of the contents it carries.

Algorithms are specified in Python and use the following functions, types, and notation.

Type bytes is used for byte strings, int for integers, and Sequence for a read-only sequence.

For any byte string x, len(x) denotes its length in bytes.

For two byte strings x and y, x + y denotes their concatenation, and, if they are of equal length, _xor(x, y) denotes their bytewise exclusive or.

For a byte string x, x[i:j] denotes the substring of x that begins at its byte with index i and ends just before its byte with index j, where indices start at zero. Its length is j - i bytes.

For a list x, x[i] denotes its element at index i, counting from zero, x[i:j] denotes the sublist from index i up to but not including index j, and len(x) denotes the number of elements it holds.

I2OSP(value, length) converts a nonnegative integer into a byte string of the requested length in big-endian byte order, as described in [I2OSP]. We write U16Prefixed(value) for the concatenation of I2OSP(len(value), 2) and byte string value.

random(n) returns n uniformly random bytes. Implementations MUST generate them with a cryptographically secure random number generator. It is the only source of randomness in this document: every other value that has to be unpredictable is derived from its output (Section 4.2).

Seed(x, k) denotes the k-th seed in a byte string of concatenated seeds, that is x[k * Nseed:(k + 1) * Nseed], with k counted from zero. Section 7 fixes the seed length Nseed. This function is defined as follows:

def Seed(value: bytes, index: int) -> bytes:
    return value[index * Nseed : (index + 1) * Nseed]

Byte strings such as b"Challenge" contain the corresponding ASCII bytes and do not include a terminating NUL byte.

The definitions of the record types named in function signatures are implicit. Parameters become constant values once the ciphersuite is fixed. An algorithm that can fail raises an error; the errors used in this document are listed in Section 3.2.

3. Preliminaries

The construction has two dependencies:

Group:

A prime-order group implementing the interface in Section 3.1. Section 7 gives concrete instances.

Hash:

A cryptographic hash function whose output length is Nh bytes.

3.1. Prime-Order Group

This document uses an additive, prime-order group, denoted G, of order p, as described in Section 2.1 of [OPRF]. The types Element and Scalar denote elements of the group and of its scalar field respectively. Group elements are added with + and subtracted with -; scalar multiplication of an Element A by a Scalar r is written r * A. Scalars are added, subtracted, and multiplied modulo p. In the Python snippets, G.scalar(x) converts an integer x in [0, p) to a Scalar.

The group also provides G.DeriveScalars, G.ExpandScalars, G.DeriveNonces, G.DeriveKeyPair, and G.GenerateKeyPair, defined in Section 4.2, Section 4.3, and Section 4.4.

The following member functions are used. Except where noted, they are as defined in Section 2.1 of [OPRF].

Order():

Outputs the order p of the group.

Identity():

Outputs the identity element of the group.

Generator():

Outputs the generator element B of the group.

ScalarMultGen(r):

Outputs r * B, where B is the group generator.

HashToGroup(x, DST):

Deterministically maps a byte string x to an Element, using the byte string DST as the domain separation tag. The map is specified in Section 7.

HashToGroup(x):

Equivalent to HashToGroup(x, DST=b"HashToGroup-" + ctx_proto) where ctx_proto is as defined in Section 4.1.

HashToScalar(x, DST):

Deterministically maps a byte string x to a Scalar, using the byte string DST as the domain separation tag. The map is specified in Section 7.

HashToScalar(x):

Equivalent to HashToScalar(x, DST=b"HashToScalar-" + ctx_proto) where ctx_proto is as defined in Section 4.1.

ScalarInverse(s):

Outputs the multiplicative inverse of the nonzero Scalar s modulo p.

SerializeElement(A):

Maps an Element A other than the identity element to a canonical byte string of fixed length Ne. Raises a ValueError if A is the identity element, which has no such encoding.

DeserializeElement(buf):

Attempts to map a byte string buf to an Element. Raises a DeserializeError if buf is not the canonical encoding of a group element, or if it encodes the identity element.

SerializeScalar(s):

Maps a Scalar s to a canonical byte string of fixed length Ns.

DeserializeScalar(buf):

Attempts to map a byte string buf to a Scalar. Raises a DeserializeError if buf does not encode a Scalar in the range [0, p-1].

This document does not use the RandomScalar() member of Section 2.1 of [OPRF]. Every scalar that has to be unpredictable is instead obtained from G.DeriveScalars (Section 4.2), G.DeriveNonces (Section 4.3), or G.DeriveKeyPair (Section 4.4), each deterministic in its random input. Each algorithm is therefore a deterministic function of its random input, which the test vectors of Appendix A fix.

3.2. Errors

The following errors are used.

DeserializeError:

A byte string is not a canonical encoding of the expected type.

VerifyError:

A received value failed a verification check.

SessionError:

A message was received for a session that is not in the expected state.

DeriveError:

A deterministic derivation failed to produce a usable scalar. See Section 4.2.

ValueError:

An input has an invalid length or is outside its permitted range.

An implementation that raises an error MUST abort the protocol run. Errors are fatal to the affected session; see Section 5.7.

4. The Endorsement Scheme

Issuance is a three-move protocol between a Client and an Anchor, followed by a local finalization step at the Client. The Anchor moves first and holds per-session state between its two moves.

   Client(pkA, ctx_iss, ctx_red)                Anchor(skA, ctx_iss)
 ---------------------------------------------------------------------
                               state, commitment = Commit(ctx_iss)

                             commitment
                              <--------

   state, challenge = Challenge(pkA, ctx_iss, ctx_red, commitment)

                              challenge
                              -------->

                          response = Respond(skA, state, challenge)

                              response
                              <--------

   endorsement = Finalize(pkA, state, response)
Figure 1: Endorsement issuance overview

The Anchor speaks first. This document does not prescribe how the three messages are carried, nor how a Client that wants an Endorsement reaches an Anchor in the first place; both are the business of the transport, and a Client will in general have to signal its intent by some means that carries no protocol data. For example, over HTTP a Client might ask for issuance in a request with an empty body, receive the commitment in the response, send the challenge in a second request, and receive the response in the reply to that. Section 5.6 specifies the encoding of the three messages and their mapping onto the exchanges of [PROTOCOLS].

Neither context is carried in these messages. Both parties already hold the issuance context, having agreed on it out of band; the redemption context is known only to the Client.

The Client's output is an Endorsement that is publicly verifiable under the Anchor's public key pkA (Section 5.5). The Anchor learns neither the nullifier nor the redemption context bound into it, and cannot link the Endorsement to the session that produced it.

4.1. Configuration and Protocol Context

A ciphersuite (Section 7) is identified by an ASCII string identifier. Both parties MUST agree on the ciphersuite before running the protocol; [PROTOCOLS] describes how this agreement is reached.

The protocol context, written ctx_proto, is the domain separation tag that this document derives from that identifier:

def CreateProtocolContext(identifier: bytes) -> bytes:
    return b"Rollatiniv1-" + identifier

Throughout the remainder of this document, ctx_proto denotes the output of CreateProtocolContext for the ciphersuite in use. It is distinct from the issuance and redemption contexts of Section 4.5: those are inputs to the protocol, chosen by its participants, whereas ctx_proto is fixed by the ciphersuite.

Every hash this document computes, other than the round functions of the permutation P (Section 6.2.3), is domain-separated by ctx_proto, which appears in its DST. HashToGroup and HashToScalar are so parameterized (Section 7), as are G.DeriveScalars and G.ExpandScalars (Section 4.2) and G.DeriveNonces (Section 4.3). Every algorithm below therefore depends on ctx_proto, even where it does not appear, and a value produced under one ciphersuite does not verify under another. The Python group instance G stores this context as G.ctx_proto. The group methods below use self for that instance, so the context is fixed when G is constructed.

4.2. Deriving Scalars

The group method G.DeriveScalars derives count values in the scalar field of G from Nseed bytes of randomness rand. An algorithm that needs random scalars derives them all with one call, under an info string that names the algorithm. rand and info are hashed into a key, which is expanded into the scalars:

def DeriveScalars(
    self, rand: bytes, info: bytes, count: int
) -> list[Scalar]:
    if len(rand) != Nseed:
        raise ValueError(f"rand must be exactly {Nseed} bytes")
    key = expand_message_xmd(
        rand + U16Prefixed(info),
        b"DeriveScalars-" + self.ctx_proto,
        Nh,
    )
    return self.ExpandScalars(key, count)

The group method G.ExpandScalars hashes the key and an index to each scalar in turn:

def ExpandScalars(self, key: bytes, count: int) -> list[Scalar]:
    scalars = []
    for i in range(count):
        s = self.HashToScalar(
            key + I2OSP(i, 4), DST=b"ExpandScalars-" + self.ctx_proto
        )
        if s.isZero():
            raise DeriveError
        scalars.append(s)
    return scalars

expand_message_xmd is that of Section 5.3.1 of [HASH2CURVE], over the hash function of the ciphersuite. rand MUST be Nseed bytes of output of random and MUST NOT be used for more than one derivation. See Section 7.2. An A derived scalar is zero, and DeriveError raised, with negligible probability; implementations might choose to panic rather than handle the exception.

The two-stage derivation used here and in Section 4.3 avoids the output-length limit of a single hash_to_field call using expand_message_xmd. XMD produces at most 255 * Nh bytes per call, which allows at most 170 scalars with SHA-256 and L = 48 (see Rollatini(P-256, SHA-256) Section 7). Although Rollatini's issuer-hiding proof needs fewer scalars, other protocols that share these methods need larger vectors.

4.3. Deriving Nonces

The nonces of a proof of knowledge, and the other secret values of its first move, must not be reused under a different challenge. The group method G.DeriveNonces derives them together from rand, a secret the party holds, and a public description of the operation, as the hedged signatures of [DETSIGS] derive theirs:

def DeriveNonces(
    self,
    secret: bytes,
    label: bytes,
    instance: bytes,
    rand: bytes,
    count: int,
) -> list[Scalar]:
    if len(rand) != Nseed:
        raise ValueError(f"rand must be exactly {Nseed} bytes")
    derive_nonce_input = (
        PadToBlock(rand)
        + PadToBlock(I2OSP(len(secret), 4) + secret)
        + U16Prefixed(label)
        + I2OSP(len(instance), 4)
        + instance
    )
    key = expand_message_xmd(
        derive_nonce_input, b"DeriveNonces-" + self.ctx_proto, Nh
    )
    return self.ExpandScalars(key, count)

PadToBlock appends zero bytes up to a multiple of s_in_bytes, the input block size of the hash function (Section 5.3.1 of [HASH2CURVE]):

def PadToBlock(value: bytes) -> bytes:
    return value + bytes(-len(value) % s_in_bytes)

expand_message_xmd starts its input with a block of zeros, so the hash function absorbs rand in a block of its own and then secret in blocks of its own, before any public input, as [DETSIGS] recommends against side-channel and fault attacks. When rand is uniformly random, the nonces are distributed as derived scalars (Section 4.2). If the random source fails in a way that does not depend on secret, the nonces are still hash outputs on an input that contains secret, so to an adversary that does not know secret a change to any part of rand or instance changes every nonce unpredictably, except with negligible probability, and no nonce is reused under a different challenge. Repeating all of rand for the same operation reproduces the same proof. Every instance in this document is an unambiguous encoding, with its variable-length parts length-prefixed. Implementations MUST wipe secret, derive_nonce_input, key, and the returned values once they have been used.

4.4. Key Generation

An Anchor holds a key pair (skA, pkA). It is derived from a seed, which is what allows the test vectors in Appendix A to fix a key. The procedure is the key generation of Section 3.2 of [OPRF]. The secrecy of skA rests on that of seed; info is usually public.

def DeriveKeyPair(
    self, seed: bytes, info: bytes
) -> tuple[Scalar, Element]:
    if len(seed) != Nseed:
        raise ValueError(f"seed must be exactly {Nseed} bytes")
    derive_input = seed + U16Prefixed(info)
    for counter in range(256):
        skA = self.HashToScalar(
            derive_input + I2OSP(counter, 1),
            DST=b"DeriveKeyPair-" + self.ctx_proto,
        )
        if not skA.isZero():
            return (skA, self.ScalarMultGen(skA))
    raise DeriveError

The derivation differs from that of Section 3.2 of [OPRF] only in its domain separation tag, which comes from the protocol context of this document rather than from an OPRF context string, and in the length of the seed. The loop terminates after one iteration except with probability about 1/p.

A fresh key pair is generated by deriving one from a random seed.

def GenerateKeyPair(self) -> tuple[Scalar, Element]:
    seed = random(Nseed)
    return self.DeriveKeyPair(seed, b"GenerateKeyPair")

The Anchor publishes SerializeElement(pkA) in its configuration; see [PROTOCOLS].

4.5. Context Binding

Each Endorsement is bound at issuance to two contexts, the issuance context and the redemption context, and a redemption succeeds only if the Client and the Moderator agree on both values. Both are opaque byte strings. ctx_iss is at most 2^16 - 1 bytes. Because the encoded message is itself passed to U16Prefixed, ctx_red is at most 2^16 - 1 - Nn - 4 bytes. These bounds are enforced by U16Prefixed. The two contexts are bound by deliberately different means, reflecting who is trusted to choose each.

[PROTOCOLS] specifies how MoLE obtains these contexts from configuration. The examples below illustrate their cryptographic roles and do not define alternative context encodings.

The issuance context, written ctx_iss, restricts when, and potentially where, an Endorsement may be redeemed; it might for example name the epoch the Endorsement was issued in. Both parties hold it. It is bound by deriving the second commitment base from it:

def CreateContextBase(ctx_iss: bytes) -> Element:
    context_base_input = U16Prefixed(ctx_iss) + b"ContextBase"
    return G.HashToGroup(context_base_input)

The Anchor forms its commitment under this base, and the base is recomputed at verification time. The Client and the Anchor MUST agree on the issuance context. Disagreement causes issuance to fail: a Client that uses any other value fails the commitment-opening check in Finalize. The binding is therefore enforced by the construction rather than by an explicit check, and a Client cannot bind an Endorsement to an issuance context of its own choosing.

The redemption context, written ctx_red, restricts where an Endorsement may be redeemed: a redemption succeeds only under the value the Endorsement was issued under. It might for example identify the Moderator the Client intends to redeem at, in which case the Endorsement is redeemable at that Moderator and at no other. It is chosen by the Client and is hidden from the Anchor. It is bound by placing it, together with a fresh Client-chosen nullifier nf of Nn = 32 bytes, in the signed message:

def Message(nf: bytes, ctx_red: bytes) -> bytes:
    if len(nf) != Nn:
        raise ValueError(f"nullifier must be exactly {Nn} bytes")
    return U16Prefixed(nf) + U16Prefixed(ctx_red)

A redemption under a different redemption context recomputes a different message, for which the Client holds no valid signature. Two consequences follow. A Client has to fix ctx_red before it runs Challenge, that is, before the Endorsement exists; and an Endorsement cannot afterwards be re-bound to another value, so a Client that needs to redeem under several redemption contexts needs a separate Endorsement, and so a separate issuance, for each. Anchors bound how many Endorsements they grant a given Client in order to keep Endorsements scarce ([ARCH]), so that budget is consumed per redemption context rather than per Client.

The nullifier nf MUST be a fresh string of Nn uniformly random bytes, generated by the Client, and MUST NOT be reused across Endorsements. It is revealed during redemption, so the Moderator ensures each Endorsement is redeemed at most once.

The values the two contexts take determine the anonymity set a Client redeems in, and a deployment can destroy the unlinkability the construction provides without breaking any of its cryptographic properties; see Section 8.

5. Endorsement Issuance

Issuance produces a signature on the message Message(nf, ctx_red) relative to the public input ctx_iss. It consists of four algorithms, run in the order

  Commit -> Challenge -> Respond -> Finalize

Commit and Respond are run by the Anchor; Challenge and Finalize are run by the Client. Both parties input the issuance context ctx_iss; only the Client inputs the redemption context ctx_red.

Each of the first three algorithms outputs one protocol message, and the next algorithm takes that message as a single input. The messages are the commitment, the pair (A, C); the challenge, a single scalar; and the response, the triple (s, y, t). The types Commitment and Response denote the first and the last of these. The wire format of each message is defined in Section 5.6. All four algorithms treat G, ctx_proto, Nn, and Nseed as global variables.

5.1. Anchor Commitment

The Anchor opens a session by committing to the values it will later reveal.

def Commit(ctx_iss: bytes) -> tuple[AnchorState, Commitment]:
    Z = CreateContextBase(ctx_iss)

    rand = random(Nseed)
    (a, t, y) = G.DeriveScalars(rand, b"Commit", 3)

    A = G.ScalarMultGen(a)
    C = G.ScalarMultGen(t) + y * Z

    return (AnchorState(a, y, t), Commitment(A, C))

The Anchor stores state for the duration of the session and sends commitment to the Client in a CommitMessage (Section 5.6). The signing key is not needed until Respond.

Commit draws all of its randomness in one call and derives its three scalars from it together (Section 4.2). A test vector fixes the single value rand; y is nonzero by construction.

5.2. Client Challenge

The Client blinds the Anchor's commitment, derives the challenge over the blinded values, and returns the challenge in blinded form.

def Challenge(
    pkA: Element,
    ctx_iss: bytes,
    ctx_red: bytes,
    commitment: Commitment,
) -> tuple[ClientState, Scalar]:
    if pkA.isIdentity():
        raise VerifyError

    (A, C) = commitment

    rand = random(Nn + Nseed)
    nf = rand[:Nn]
    (r1, r2, gamma1, gamma2) = G.DeriveScalars(
        rand[Nn:], b"Challenge", 4
    )

    m = Message(nf, ctx_red)
    gamma = gamma1 * G.ScalarInverse(gamma2)

    blinded_A = G.ScalarMultGen(r1) + gamma * A
    blinded_C = gamma1 * C + G.ScalarMultGen(r2)
    blinded_commitment = Commitment(blinded_A, blinded_C)

    c = ComputeChallenge(ctx_iss, blinded_commitment, m)
    if c.isZero():
        raise VerifyError

    challenge = c * gamma2
    state = ClientState(
        nf,
        ctx_iss,
        commitment,
        r1,
        r2,
        gamma1,
        gamma2,
        challenge,
        c,
    )
    return (state, challenge)

As in Commit, all randomness is drawn in one call: the first Nn bytes are the nullifier, and the remaining Nseed bytes derive the four blinding scalars. ComputeChallenge is as follows:

def ComputeChallenge(ctx_iss: bytes, commitment: Commitment, m: bytes) -> Scalar:
    (A, C) = commitment

    Am = G.SerializeElement(A)
    Cm = G.SerializeElement(C)

    challenge_transcript = (
        U16Prefixed(ctx_iss)
        + U16Prefixed(Am)
        + U16Prefixed(Cm)
        + U16Prefixed(m)
        + b"Challenge"
    )

    c = G.HashToScalar(challenge_transcript)

    return c

Two challenge values appear here: c is computed over the blinded commitment and is the value that ends up in the Endorsement (Section 5.4); it never leaves the Client. The challenge message the Anchor receives is its blinded form c * gamma2, and the Anchor cannot recover c from it because gamma2 is uniform and secret.

HashToScalar can return zero, and the construction requires a nonzero challenge. Challenge aborts in that case, which keeps the challenge a deterministic function of the transcript; this happens with probability about 1/p (Section 4.2).

The Client sends challenge to the Anchor encoded as a ChallengeMessage (Section 5.6) and retains state for the next step.

5.3. Anchor Response

The Anchor answers the challenge and closes the session.

def Respond(skA: Scalar, state: AnchorState, challenge: Scalar) -> Response:
    (a, y, t) = state

    if challenge.isZero():
        raise VerifyError

    s = a + challenge * y * skA
    response = Response(s, y, t)

    return response

The resulting Response is encoded as a ResponseMessage (Section 5.6) and sent to the Client.

An Anchor MUST call Respond at most once per session state produced by Commit, and MUST destroy that state immediately afterwards. Answering two distinct challenges on the same state discloses the signing key: from s1 = a + c1 * y * skA and s2 = a + c2 * y * skA with c1 != c2, and y revealed in the response, an attacker recovers skA = (s1 - s2) * ScalarInverse((c1 - c2) * y). An Anchor that receives a second ChallengeMessage for a session it has already answered MUST raise a SessionError and MUST NOT compute a response.

5.4. Client Finalization

The Client checks the Anchor's response and unblinds it into an Endorsement.

def Finalize(pkA: Element, state: ClientState, response: Response) -> Endorsement:
    if pkA.isIdentity():
        raise VerifyError

    (nf, ctx_iss, (A, C), r1, r2, gamma1, gamma2, challenge, c) = state
    (s, y, t) = response

    Z = CreateContextBase(ctx_iss)

    if y.isZero():
        raise VerifyError
    if C != G.ScalarMultGen(t) + y * Z:
        raise VerifyError
    if G.ScalarMultGen(s) != A + (challenge * y) * pkA:
        raise VerifyError

    gamma = gamma1 * G.ScalarInverse(gamma2)

    s_final = gamma * s + r1
    y_final = gamma1 * y
    t_final = gamma1 * t + r2

    return Endorsement(c, s_final, y_final, t_final, nf)

The three checks verify that the Anchor opened its commitment honestly and answered the challenge under its published key. A Client whose Finalize raises an error MUST discard the session state and MUST NOT retry the exchange with the same state.

An Endorsement consists of the signature (c, s, y, t) together with the nullifier; its encoding is given in Section 5.6.2.

5.5. Endorsement Verification

An Endorsement is publicly verifiable under the issuing Anchor's public key.

The two contexts are inputs to Verify in addition to the Endorsement. A verifier therefore states the pair it is willing to accept and learns whether the Endorsement was issued under it.

def Verify(
    pkA: Element,
    endorsement: Endorsement,
    ctx_iss: bytes,
    ctx_red: bytes,
) -> bool:
    if pkA.isIdentity():
        return False

    (c, s, y, t, nf) = endorsement

    if len(nf) != Nn or c.isZero() or y.isZero():
        return False

    Z = CreateContextBase(ctx_iss)
    m = Message(nf, ctx_red)

    C = G.ScalarMultGen(t) + y * Z
    A = G.ScalarMultGen(s) - (c * y) * pkA
    if A.isIdentity() or C.isIdentity():
        return False
    commitment = Commitment(A, C)

    return c == ComputeChallenge(ctx_iss, commitment, m)

A Moderator never runs Verify under an Anchor's public key: a redemption does not reveal which Anchor issued the Endorsement, so the Moderator runs it under a rerandomized key and checks an issuer-hiding proof alongside (Section 6). The Client MUST NOT reveal the Anchor's public key to the Moderator.

Verify rejects a reconstructed A or C equal to the identity element, which SerializeElement cannot encode. Under an Anchor's key this happens with negligible probability, but the rerandomized key is the Client's choice, and a Client that knows its discrete logarithm x reaches the identity by setting s = c * y * x.

An honestly produced Endorsement always verifies. Writing gamma for gamma1 * ScalarInverse(gamma2), and A_anchor and C_anchor for the two elements of the Anchor's commitment, the commitment reconstructed by Verify is exactly the blinded commitment the Client hashed in Challenge:

  C = t_final * B + y_final * Z
    = gamma1 * (t * B + y * Z) + r2 * B
    = gamma1 * C_anchor + r2 * B
    = blinded_C

  A = s_final * B - (c * y_final) * pkA
    = (gamma * s + r1) * B - (c * gamma1 * y) * pkA
    = r1 * B + gamma * A_anchor
    = blinded_A

where the last step uses s = a + challenge * y * skA and challenge = c * gamma2, so that the two terms in skA cancel.

5.6. Encodings

This section gives the encoding of the three messages exchanged during issuance, of the session identifier that correlates them, and of the Endorsement they produce.

Element and Scalar are the fixed-length encodings produced by SerializeElement and SerializeScalar, of Ne and Ns bytes respectively. A recipient MUST deserialize every received Element and Scalar, and MUST abort the session with a DeserializeError if deserialization fails. In particular, deserializing an Element rejects the group identity element. Deserialization of a structure also fails if its input is truncated, if bytes remain after it, or if a vector's length is not a multiple of the size of its elements.

5.6.1. Issuance Messages

The three messages exchanged during issuance are carried in the EndorsementRequest and EndorsementResponse bodies defined in [PROTOCOLS], whose transport is HTTP. The first request is empty, and the corresponding response contains the CommitMessage; the second request contains the ChallengeMessage, and the corresponding response contains the ResponseMessage.

The messages include a session identifier that allows the Anchor to correlate the Client's challenge with the state corresponding to the Anchor's commitment. The Anchor opens the session with its commitment:

struct {
  opaque session_id<V>;
  Element A;
  Element C;
} CommitMessage;

The Client replies with the blinded challenge, echoing the session identifier:

struct {
  opaque session_id<V>;
  Scalar challenge;
} ChallengeMessage;

The Anchor replies with its response, which closes the session:

struct {
  Scalar s;
  Scalar y;
  Scalar t;
} ResponseMessage;

An Anchor MUST NOT have two open sessions with the same session_id, and SHOULD generate it with a cryptographically secure random number generator so that a Client cannot guess, and so collide with, another Client's session. An Anchor that receives a ChallengeMessage whose session_id does not correspond to one of its open sessions MUST raise a SessionError.

5.6.2. Endorsement

The output of Finalize is encoded as follows.

struct {
  Scalar c;
  Scalar s;
  Scalar y;
  Scalar t;
  opaque nf[Nn];
} Endorsement;

This structure is never sent to an Anchor. The Client holds it until it is redeemed, and Section 6 defines what is sent to a Moderator then. The issuance and redemption contexts are not carried in it: they are inputs to Verify (Section 5.5) and to redemption, held by the verifier.

5.7. Session Handling

An Anchor is stateful: it holds the secret state produced by Commit from the moment it sends CommitMessage until it answers or discards the session. That state is single-use; see Section 5.3 and Section 8.

An Anchor SHOULD bound both the number of concurrent open sessions per Client and the lifetime of an open session, and SHOULD discard state for sessions that are not completed within that lifetime. Discarding state early is always safe: it causes the Client's Finalize to be unreachable, but cannot produce an invalid Endorsement.

6. Endorsement Redemption

A Client redeems an Endorsement at a Moderator.

An Endorsement is single-use (Section 4.5), so redemption does not have to hide the signature: the Client reveals it, and the Moderator deduplicates on the nullifier. What redemption must hide is which Anchor issued it. The signature of Section 5 verifies under one Anchor's public key, so presenting it against that key would name the Anchor. Instead the Client rerandomizes the key (Section 6.1) and proves in zero knowledge that the rerandomized key belongs to some Anchor in the Moderator's Anchor Set (Section 6.2).

Redemption is one message from the Client, answering a challenge from the Moderator. The Moderator holds the ordered Anchor Set and carries it in that challenge; the Client learns it there and locates its own Anchor in it. Both parties input the two contexts.

   Client(endorsement,                   Moderator(anchor_set,
          ctx_iss, ctx_red)                        ctx_iss, ctx_red)
 ---------------------------------------------------------------------
                    challenge (carries anchor_set)
                              <--------

   redemption = Redeem(anchor_set, index, endorsement,
                       ctx_iss, ctx_red, challenge_digest)

                             redemption
                              -------->

                    nf = VerifyRedemption(anchor_set, redemption,
                                          ctx_iss, ctx_red,
                                          challenge_digest)
Figure 2: Endorsement redemption overview

anchor_set is the list of Anchor public keys the Moderator accepts. Both parties MUST use the same list in the same order. A proof computed over a different list or order will not verify.

challenge_digest binds the redemption to the challenge that triggered it. It is computed from the Moderator's challenge as specified in [PROTOCOLS], and this document treats it as an opaque byte string. Note that this challenge is the Moderator's, and has nothing to do with the issuance challenge of Section 5.2.

index is the position in anchor_set of the public key of the Anchor that issued the Endorsement. A Client whose Anchor does not appear in anchor_set cannot redeem at this Moderator and MUST NOT try: the Endorsement will not verify under any key it can prove membership for.

6.1. Key Rerandomization

The Client shifts the Anchor's public key by a secret scalar delta of its own choosing and adapts the signature to the shifted key. Writing pkA for anchor_set[index] and (c, s, y, t, nf) for the Endorsement:

  X_hat = pkA + delta * B
  s_hat = s + (c * y) * delta

Here B denotes the group's base point, i.e., B = G.Generator().

The result is an Endorsement (c, s_hat, y, t, nf) that verifies under X_hat exactly as the original verifies under pkA, because the shift cancels in the reconstruction of A:

  s_hat * B - (c * y) * X_hat
    = (s + c * y * delta) * B - (c * y) * (pkA + delta * B)
    = s * B - (c * y) * pkA

Every other value Verify recomputes is untouched, so a Moderator can check the signature by running Verify (Section 5.5) with X_hat in place of pkA.

Because this shift is additive, issuance is left unmodified and the unforgeability of Section 5 carries over to the modified Endorsement (Section 8). But X_hat by itself provides no evidence that it was derived from an Anchor key. The Client therefore also proves knowledge of delta relating X_hat to a key in anchor_set, without revealing which key.

6.2. The Issuer-Hiding Proof

The Client proves knowledge of delta such that X_hat - anchor_set[i] = delta * B for at least one i, without revealing i. This is a one-out-of-n disjunction of proofs of knowledge of a discrete logarithm, all over the base B = G.Generator().

Such a disjunction is classically composed with the technique of Cramer, Damgard and Schoenmakers [CDS94], in which the Client answers the branch it can and simulates the others, sending one challenge and one response per branch. That proof is linear in n, and the Anchor Set is also the anonymity set (Section 8). This document instead composes the branches with the stacking technique of Goel, Green, Hall-Andersen and Kaptchuk [STACKSIG], whose proof is logarithmic in n: the Client sends a single response, which every branch reuses, together with a commitment whose opening forces one branch to have been answered honestly. [FFKLLS26] applies the same two compositions, for the same purpose, to a pairing-based credential.

The proof takes the rerandomized key of Section 6.1 as its statement and leaves issuance and Section 5.5 unchanged.

The construction below first defines the branch proof, then builds a partially binding tree commitment over its branches. It finally specifies the Fiat-Shamir challenge and the proving and verification algorithms.

Each branch is the discrete logarithm proof of [SIGMA]; this document specifies the composition, its transcript, and its encodings.

6.2.1. The Branch Proof

The disjunction is proved over the statements

  Y[i] = X_hat - anchor_set[i]   for i in range(n)

of which the Client can answer branch index, where Y[index] = delta * B.

A single branch is proved by the usual three moves: the Client draws a nonce r and commits T = r * B; the verifier sends a challenge c; the Client answers z = r - c * delta; and the verifier checks that T = c * Y[index] + z * B.

Read the other way round, that check determines the commitment from the challenge and the response:

def BranchCommitment(
    proof_challenge: Scalar,
    response: Scalar,
    Y: Element,
) -> Element:
    return proof_challenge * Y + G.ScalarMultGen(response)


def Statements(
    anchor_set: Sequence[Element],
    X_hat: Element,
) -> list[Element]:
    return [X_hat - pkA for pkA in anchor_set]

Two properties make this branch proof stackable in the sense of [STACKSIG]. First, a verifying commitment can be computed for any statement from a challenge and a response, by the function above, without knowing a witness; this is the extended honest-verifier zero-knowledge property. Second, the response is a fixed shift of the derived nonce r, and so is statistically close to uniform independently of the statement and witness; see Section 8. One response can therefore be reused across branches without revealing which branch produced it.

A verifier given proof_challenge and one response can therefore compute an accepting commitment for every branch. Soundness comes from the commitment of Section 6.2.2: the Client fixes the commitment of one branch before the challenge exists and cannot change it afterwards.

6.2.2. Partially Binding Commitments

A partially binding vector commitment [STACKSIG] commits to a vector of values such that one position, chosen when the commitment key is generated and hidden from the verifier, is binding, while every other position can afterwards be opened to any value. The Client commits the branch commitment of index at the binding position, and after the challenge is known it opens every other position to the branch commitment that the shared response determines. The branch at the binding position uses the actual witness and a commitment fixed before the challenge; the index of the branch remains hidden by the commitment key.

This document uses the commitment of the appendix of [STACKSIG] over pairs, and then assembles them into a tree. The inputs to the commitments are arbitrary strings of bytes, and the outputs are compressed group elements.

The commitment to a pair uses a permutation P on group elements, with inverse Pinv (Section 6.2.3). Its key is a point Q, and an opening is a single scalar, the randomness argument of CommitStep. The two values are hashed to scalars and committed under the bases Q and P(Q):

def CommitStep(
    Q: Element,
    left: bytes,
    right: bytes,
    randomness: Scalar,
) -> bytes:
    C = (
        randomness * B
        + G.HashToScalar(left) * Q
        + G.HashToScalar(right) * G.P(Q)
    )
    return G.SerializeElement(C)

The committer knows the discrete logarithm of one base, and that is the position it can later open to another value. A key that binds the left position is one whose P(Q) has the known logarithm, and vice versa:

def GenerateStep(bind_left: bool, secret: Scalar) -> Element:
    T = secret * B
    (Q, _) = G.PermutationPair(T, bind_left)
    return Q

bind_left is a secret boolean: true binds the left position and false binds the right. G.PermutationPair (Section 6.2.3.1) returns (Q, P(Q)), with T = P(Q) when binding left and T = Q when binding right. It walks the same permutation edge in either case, hiding which endpoint has the known logarithm. Its second output can be cached for use in CommitStep.

The secret is the trapdoor: replacing the equivocal value shifts the opening by the difference of the hashes times the trapdoor, and the commitment is unchanged:

def EquivocateStep(
    oldb: bytes, newb: bytes, randomness: Scalar, secret: Scalar
) -> Scalar:
    old = G.HashToScalar(oldb)
    new = G.HashToScalar(newb)
    return randomness + (old - new) * secret

A vector V[0], ..., V[n-1] of byte strings is committed by pairing neighbours level by level. Each level has its own key and its own opening; a vector of odd length carries its last element up unchanged.

def VecCommit(
    V: Sequence[bytes],
    Qi: Sequence[Element],
    rands: Sequence[Scalar],
) -> bytes:
    if len(V) == 1:
        return V[0]

    V_prime = []
    for i in range(len(V) // 2):
        V_prime.append(CommitStep(Qi[0], V[2 * i], V[2 * i + 1], rands[0]))

    if len(V) % 2 == 1:
        V_prime.append(V[-1])

    return VecCommit(V_prime, Qi[1:], rands[1:])

A vector of n values has Depth(n) levels, each with one key and one opening:

def Depth(n: int) -> int:
    q = 0
    while 2**q < n:
        q += 1
    return q

Bit j of index is the side of its pair that the binding value is on at level j, which fixes the direction of that level's key; each key has its own trapdoor:

def GenerateVecBind(
    index: int, trapdoors: Sequence[Scalar]
) -> list[Element]:
    commitment_keys = []
    for j in range(len(trapdoors)):
        bind_left = ((index >> j) & 1) == 0
        commitment_keys.append(GenerateStep(bind_left, trapdoors[j]))
    return commitment_keys

The first move commits the value at index with every other leaf empty, under one opening per level:

def CommitValAtPlace(
    commitment_keys: Sequence[Element],
    n: int,
    index: int,
    value: bytes,
    openings: Sequence[Scalar],
) -> bytes:
    V = [b"" for _ in range(n)]
    V[index] = value
    return VecCommit(V, commitment_keys, openings)

Once the other leaves are known, each level's opening is shifted so that the sibling of the binding path takes its new value and the node above is unchanged. An odd last element on the binding path has no sibling and keeps its opening.

def VecEquivocate(
    commitment_keys: Sequence[Element],
    trapdoors: Sequence[Scalar],
    openings: Sequence[Scalar],
    old: Sequence[bytes],
    new: Sequence[bytes],
    index: int,
) -> list[Scalar]:
    if len(old) != len(new):
        raise ValueError("vectors must have the same length")
    if old[index] != new[index]:
        raise ValueError("the binding position cannot change")
    if len(old) == 1:
        return []

    if index == len(old) - 1 and len(old) % 2 == 1:
        opening = openings[0]
    else:
        partner = index - 1 if index % 2 == 1 else index + 1
        opening = EquivocateStep(
            old[partner], new[partner], openings[0], trapdoors[0]
        )

    old_prime = []
    new_prime = []
    for i in range(len(old) // 2):
        Q = commitment_keys[0]
        old_prime.append(
            CommitStep(Q, old[2 * i], old[2 * i + 1], openings[0])
        )
        new_prime.append(
            CommitStep(Q, new[2 * i], new[2 * i + 1], opening)
        )
    if len(old) % 2 == 1:
        old_prime.append(old[-1])
        new_prime.append(new[-1])

    rest = VecEquivocate(
        commitment_keys[1:],
        trapdoors[1:],
        openings[1:],
        old_prime,
        new_prime,
        index // 2,
    )
    return [opening] + rest


def VecEquivocateFromZero(
    commitment_keys: Sequence[Element],
    trapdoors: Sequence[Scalar],
    openings: Sequence[Scalar],
    new: Sequence[bytes],
    index: int,
) -> list[Scalar]:
    V = [b"" for _ in new]
    V[index] = new[index]
    return VecEquivocate(
        commitment_keys, trapdoors, openings, V, new, index
    )

6.2.3. The Permutation

P is a permutation on the elements of G, computed on their compressed encodings by cycle walking: a permutation of the encoding space is applied until the result decodes to an element, and Pinv walks the same cycle backwards. Since a permutation partitions its domain into cycles, the two are inverse to each other.

The following direct implementations define the permutation and its inverse. They MAY be used on public points, including the commitment keys in CommitStep. Key generation instead uses G.PermutationPair (Section 6.2.3.1), whose execution does not distinguish the two binding directions when its outputs are fixed.

def P(self, element: Element) -> Element:
    buf = bytearray(self.SerializeElement(element))
    buf[0] = buf[0] - 0x02
    while True:
        buf = PermuteBytes(buf)
        try:
            return self.DeserializeElement(
                bytes([buf[0] + 0x02]) + bytes(buf[1:])
            )
        except DeserializeError:
            continue

def Pinv(self, element: Element) -> Element:
    buf = bytearray(self.SerializeElement(element))
    buf[0] = buf[0] - 0x02
    while True:
        buf = UnpermuteBytes(buf)
        try:
            return self.DeserializeElement(
                bytes([buf[0] + 0x02]) + bytes(buf[1:])
            )
        except DeserializeError:
            continue

The permutation of the encoding space is an eight-round Feistel network over 33-byte strings whose first byte is 0x02 or 0x03, carried as its low bit. Each iteration of the loop below computes two rounds, one per half:

def PermuteBytes(buf: bytearray) -> bytearray:
    left, right = bytearray(buf[0:17]), bytearray(buf[17:33])
    for i in range(4):
        label = f"left round {i}".encode()
        left = bytearray(_xor(left, _sha256(right + label)[0:17]))
        left[0] = left[0] & 0x01
        label = f"right round {i}".encode()
        right = bytearray(_xor(right, _sha256(left + label)[0:16]))
    return left + right

def UnpermuteBytes(buf: bytearray) -> bytearray:
    left, right = bytearray(buf[0:17]), bytearray(buf[17:33])
    for i in reversed(range(4)):
        label = f"right round {i}".encode()
        right = bytearray(_xor(right, _sha256(left + label)[0:16]))
        label = f"left round {i}".encode()
        left = bytearray(_xor(left, _sha256(right + label)[0:17]))
        left[0] = left[0] & 0x01
    return left + right

The binding argument of Section 8 models P as a random permutation. An eight-round balanced Feistel network with independent random round functions is indifferentiable from a random permutation [DS15]. Here the round functions are SHA-256 separated by label, over halves of 129 and 128 bits, an imbalance [DS15] does not cover.

6.2.3.1. Walking a Permutation Edge

G.PermutationPair(T, bind_left) returns (Q, P(Q)). If bind_left is true, it walks backwards from T = P(Q) to Q. Otherwise it walks forwards from T = Q to P(Q). At each iteration it computes both one-step byte permutations, selects the next encoding using the secret bit, and tests whether that encoding is valid. It selects the order of the endpoints before decoding them, so both final decodings operate on the same public values Q and P(Q), in that order.

def PermutationPair(
    self, element: Element, bind_left: bool
) -> tuple[Element, Element]:
    if not isinstance(bind_left, bool):
        raise ValueError("bind_left must be a boolean")
    start = bytearray(self.SerializeElement(element))
    start[0] -= 0x02
    buf = start
    while True:
        forward = PermuteBytes(buf)
        backward = UnpermuteBytes(buf)
        buf = SelectBytes(forward, backward, bind_left)
        if IsValidPermutationEncoding(buf):
            break

    left = SelectBytes(start, buf, bind_left)
    right = SelectBytes(buf, start, bind_left)
    Q = self.DeserializeElement(
        bytes([left[0] + 0x02]) + bytes(left[1:])
    )
    PQ = self.DeserializeElement(
        bytes([right[0] + 0x02]) + bytes(right[1:])
    )
    return (Q, PQ)


def SelectBytes(
    left: bytearray, right: bytearray, choose_right: bool
) -> bytearray:
    mask = -int(choose_right)
    return bytearray(
        (a & ~mask) | (b & mask)
        for a, b in zip(left, right, strict=True)
    )

SelectBytes takes equal-length inputs and selects right if its boolean argument is true, or left otherwise. Implementations MUST perform this selection without secret-dependent branches or memory accesses.

For P-256, encoding validity can be tested without attempting a point decoding or raising exceptions. Here FIELD_MODULUS, CURVE_A, and CURVE_B are the P-256 field modulus and Weierstrass coefficients from [NISTCurves]. Since the field modulus is 3 modulo 4, the fixed exponent below computes a square-root candidate; squaring it tests whether a root exists. P-256 has prime, odd order, so an affine point with y = 0 cannot occur. Each valid x therefore supports both sign bits. The identity has no affine encoding.

def IsValidPermutationEncoding(buf: bytearray) -> bool:
    if len(buf) != 33:
        return False
    x = int.from_bytes(buf[1:], "big")
    rhs = (
        pow(x, 3, FIELD_MODULUS) + CURVE_A * x + CURVE_B
    ) % FIELD_MODULUS
    y = pow(rhs, (FIELD_MODULUS + 1) // 4, FIELD_MODULUS)
    return (
        ((buf[0] == 0) | (buf[0] == 1))
        & (x < FIELD_MODULUS)
        & (y * y % FIELD_MODULUS == rhs)
    )

The validity test MUST execute in constant time for all 33-byte candidates, including those with an out-of-range coordinate or no square root. Every condition is evaluated, so neither an exception-based decoder nor short-circuit evaluation may be used. The byte permutations, selections, initial serialization of T, and computation of T = secret * B MUST likewise have no secret-dependent timing or memory access patterns. The Python snippets specify the computation and operation schedule; Python integer arithmetic and the reference implementation's group operations do not provide these constant-time guarantees.

To see why the loop may stop at the first valid encoding, fix the public output Q. Let k be the number of applications of PermuteBytes needed to reach P(Q) from Q. None of the k - 1 intermediate encodings is valid. Walking backwards from P(Q) therefore reaches Q after exactly k applications of UnpermuteBytes, with the same sequence of validity results: k - 1 failures followed by one success. Each orientation executes one forward permutation, one inverse permutation, one selection, and one validity test per iteration. Thus the observable operation schedule depends only on Q, which is included in the proof, and can be reproduced from it. This argument also covers fixed points and does not assume that P is random.

Implementations MUST NOT optimize away the unused permutation direction. Calling Pinv(T) only for left binding leaks the direction directly. Computing Pinv(T) for both directions and selecting an endpoint afterwards also leaks: for a fixed public Q, its walk length describes the edge leaving Q in one case and the edge entering Q in the other. Both one-step permutations MUST be computed on each iteration of the selected walk.

6.2.4. Challenge Computation

ProofStatement encodes the statement being proven: the Anchor Set, the rerandomized key, the signature being presented, the two contexts, and the Moderator's challenge digest. The Fiat-Shamir challenge covers it together with the first move of the proof, which is the commitment keys and the root of the tree.

def ProofStatement(
    anchor_set: Sequence[Element],
    X_hat: Element,
    endorsement: Endorsement,
    ctx_iss: bytes,
    ctx_red: bytes,
    challenge_digest: bytes,
) -> bytes:
    (c, s_hat, y, t, nf) = endorsement
    n = len(anchor_set)

    anchor_set_enc = b""
    for i in range(n):
        anchor_set_enc += G.SerializeElement(anchor_set[i])

    return (
        I2OSP(n, 2)
        + anchor_set_enc
        + G.SerializeElement(X_hat)
        + G.SerializeScalar(c)
        + G.SerializeScalar(s_hat)
        + G.SerializeScalar(y)
        + G.SerializeScalar(t)
        + U16Prefixed(nf)
        + U16Prefixed(ctx_iss)
        + U16Prefixed(ctx_red)
        + U16Prefixed(challenge_digest)
    )


def ComputeProofChallenge(
    anchor_set: Sequence[Element],
    X_hat: Element,
    endorsement: Endorsement,
    ctx_iss: bytes,
    ctx_red: bytes,
    challenge_digest: bytes,
    commitment_keys: Sequence[Element],
    root: bytes,
) -> Scalar:
    ck_enc = b""
    for j in range(len(commitment_keys)):
        ck_enc += G.SerializeElement(commitment_keys[j])

    proof_transcript = (
        ProofStatement(
            anchor_set,
            X_hat,
            endorsement,
            ctx_iss,
            ctx_red,
            challenge_digest,
        )
        + ck_enc
        + root
        + b"IssuerProof"
    )

    return G.HashToScalar(proof_transcript)

The length of the Anchor Set n is prefixed and Element encodings are fixed-length, so anchor_set_enc and ck_enc are unambiguous without length prefixes of their own; q, and with it the number of commitment keys, is determined by n. The label "IssuerProof" separates this transcript from the issuance transcript of Section 5.2, which is hashed with the same function.

A challenge of zero is valid here: the proof verifies and favors no branch.

6.2.5. Proving

def ProveIssuer(
    anchor_set: Sequence[Element],
    index: int,
    delta: Scalar,
    X_hat: Element,
    endorsement: Endorsement,
    ctx_iss: bytes,
    ctx_red: bytes,
    challenge_digest: bytes,
    rand: bytes,
) -> tuple[Scalar, Scalar, Sequence[Element], Sequence[Scalar]]:
    Y = Statements(anchor_set, X_hat)
    q = Depth(len(anchor_set))
    if not 0 <= index < len(anchor_set):
        raise ValueError("index is outside the Anchor Set")
    if len(rand) != Nseed:
        raise ValueError("invalid issuer proof randomness length")

    instance = ProofStatement(
        anchor_set,
        X_hat,
        endorsement,
        ctx_iss,
        ctx_red,
        challenge_digest,
    )
    derived = G.DeriveNonces(
        G.SerializeScalar(delta) + I2OSP(index, 2),
        b"ProveIssuer",
        instance,
        rand,
        2 * q + 1,
    )
    r = derived[0]
    trapdoors = derived[1 : q + 1]
    first_openings = derived[q + 1 :]
    A = B * r

    commitment_keys = GenerateVecBind(index, trapdoors)
    # First move: commit along the binding path; other leaves empty.
    root = CommitValAtPlace(
        commitment_keys,
        len(Y),
        index,
        G.SerializeElement(A),
        first_openings,
    )

    proof_challenge = ComputeProofChallenge(
        anchor_set,
        X_hat,
        endorsement,
        ctx_iss,
        ctx_red,
        challenge_digest,
        commitment_keys,
        root,
    )

    response = r - proof_challenge * delta

    V = []
    for i in range(len(Y)):
        commitment = BranchCommitment(proof_challenge, response, Y[i])
        V.append(G.SerializeElement(commitment))
    openings = VecEquivocateFromZero(
        commitment_keys, trapdoors, first_openings, V, index
    )

    return (proof_challenge, response, commitment_keys, openings)

ProveIssuer derives the nonce r, the trapdoors of the q commitment keys, and the q openings of its first move with one call to G.DeriveNonces (Section 4.3), with delta and index as the secret and the proof statement as the instance.

The first move commits only the path from leaf index to the root: at each level the Client commits the value it holds on one side and an empty value on the other, having generated that level's key so that the other side is the equivocal one. The third move then computes the branch commitment of every statement from the single response, equivocates each level to the value its sibling subtree now has, and rebuilds the tree with the equivocated openings. The root is unchanged, so the verifier recomputes the same root.

6.2.6. Verifying

def VerifyIssuer(
    anchor_set: Sequence[Element],
    X_hat: Element,
    endorsement: Endorsement,
    ctx_iss: bytes,
    ctx_red: bytes,
    challenge_digest: bytes,
    proof_challenge: Scalar,
    response: Scalar,
    commitment_keys: Sequence[Element],
    openings: Sequence[Scalar],
) -> bool:
    n = len(anchor_set)
    if n == 0:
        return False

    Y = Statements(anchor_set, X_hat)
    q = Depth(n)
    if len(commitment_keys) != q:
        return False
    if len(openings) != q:
        return False

    T = []
    for i in range(n):
        commitment = BranchCommitment(proof_challenge, response, Y[i])
        if commitment.isIdentity():
            return False
        T.append(G.SerializeElement(commitment))

    root = VecCommit(T, commitment_keys, openings)

    return proof_challenge == ComputeProofChallenge(
        anchor_set,
        X_hat,
        endorsement,
        ctx_iss,
        ctx_red,
        challenge_digest,
        commitment_keys,
        root,
    )

The verifier computes a commitment for every branch from the single response, rebuilds the whole tree from those commitments and the openings it was given, and checks that the root it arrives at is the one the challenge was computed over. Neither the branch commitments nor the interior nodes are transmitted.

VerifyIssuer returns false rather than raising an error, as Verify (Section 5.5) does. Its checks on the lengths of commitment_keys and openings repeat what deserialization enforces (Section 6.5), and an empty Anchor Set means the Moderator is misconfigured. It also rejects a branch commitment equal to the identity element, which SerializeElement cannot encode and which a Client can produce on its own branch by answering response = -proof_challenge * delta. An interior node is the identity only for a Client that knows a discrete logarithm relation among B, Q, and P(Q) for the key Q of its level, which the binding property of Section 6.2.2 rules out, so VecCommit does not check for it.

A proof produced by ProveIssuer is accepted by VerifyIssuer. On branch index,

  proof_challenge * Y[index] + response * B
    = proof_challenge * delta * B
      + (r - proof_challenge * delta) * B
    = r * B

This is the commitment the Client committed at the binding leaf, and every other leaf holds by definition the commitment the verifier recomputes. Each level then reproduces the node the Client committed: the binding side is unchanged, and the equivocal side was shifted to match.

Conversely, the binding leaf is fixed before proof_challenge exists and cannot be moved afterwards, so a Client that could produce an accepting proof for two different challenges would yield delta for that leaf's statement. The soundness of the proof rests on that alone; see Section 8.

6.3. Redemption

The Client produces a redemption from an Endorsement it holds.

def Redeem(
    anchor_set: Sequence[Element],
    index: int,
    endorsement: Endorsement,
    ctx_iss: bytes,
    ctx_red: bytes,
    challenge_digest: bytes,
) -> Redemption:
    (c, s, y, t, nf) = endorsement
    n = len(anchor_set)

    if not 0 <= index < n:
        raise ValueError("index is outside the Anchor Set")

    rand = random(2 * Nseed)
    (delta,) = G.DeriveScalars(Seed(rand, 0), b"delta", 1)

    X_hat = anchor_set[index] + delta * B
    s_hat = s + (c * y) * delta
    shown = Endorsement(c, s_hat, y, t, nf)

    (proof_challenge, response, commitment_keys, openings) = ProveIssuer(
        anchor_set,
        index,
        delta,
        X_hat,
        shown,
        ctx_iss,
        ctx_red,
        challenge_digest,
        Seed(rand, 1),
    )

    return Redemption(
        X_hat,
        shown,
        proof_challenge,
        response,
        commitment_keys,
        openings,
    )

delta MUST be freshly derived for every redemption, and MUST NOT be derived from the Endorsement or from any other value a Client reuses. It hides the Anchor, and reusing it would link two redemptions to each other.

6.4. Redemption Verification

The Moderator checks the signature under the rerandomized key and the proof against its Anchor Set.

def VerifyRedemption(
    anchor_set: Sequence[Element],
    redemption: Redemption,
    ctx_iss: bytes,
    ctx_red: bytes,
    challenge_digest: bytes,
) -> bytes:
    (
        X_hat,
        shown,
        proof_challenge,
        response,
        commitment_keys,
        openings,
    ) = redemption

    if not Verify(X_hat, shown, ctx_iss, ctx_red):
        raise VerifyError

    if not VerifyIssuer(
        anchor_set,
        X_hat,
        shown,
        ctx_iss,
        ctx_red,
        challenge_digest,
        proof_challenge,
        response,
        commitment_keys,
        openings,
    ):
        raise VerifyError

    return shown.nf

The first check is the endorsement verification of Section 5.5, run against the rerandomized key. Together the two checks establish that the Client holds an Endorsement issued under ctx_iss and ctx_red by one of the Anchors in anchor_set, and reveal nothing further about which one. On success, VerifyRedemption returns the nullifier; either failed check raises a VerifyError.

VerifyRedemption does not enforce single use. The nullifier nf is in the clear in the redemption, and a Moderator that accepts a redemption MUST reject it if it has already recorded that nf, and MUST record nf before granting anything on the strength of it. A Moderator SHOULD scope its nullifier store to the issuance context, since an Endorsement issued under a different ctx_iss does not verify anyway. [PROTOCOLS] places these checks, and the check that ctx_iss is current, at the Moderator.

An Anchor Set of one key gives a tree of depth zero: the redemption carries no commitment keys or openings, and the issuer-hiding proof is a proof of knowledge of delta for that key. The redemption then names its Anchor, as the Anchor Set itself does; see Section 8.

6.5. Encodings

A redemption is carried in the bytes field of the Presentation structure of [PROTOCOLS], which a Client sends in the endorsement_presentation field of a CredentialRequest.

struct {
  Element rerandomized_key;
  Endorsement shown_endorsement;
  Scalar proof_challenge;
  Scalar response;
  Element commitment_keys<V>;
  Scalar openings<V>;
} Redemption;

shown_endorsement uses the Endorsement structure of Section 5.6.2, with s carrying s_hat. It is a valid Endorsement under rerandomized_key (Section 6.1), not the Endorsement the Client stored, which the Client MUST NOT send.

A Moderator deserializes a Redemption against its Anchor Set of n keys, as in Section 5.6. It MUST raise a DeserializeError unless commitment_keys is Depth(n) * Ne bytes long and openings is Depth(n) * Ns bytes long (Section 6.2.2).

7. Ciphersuites

A ciphersuite fixes the group, the hash functions, and the associated encodings and domain separation tags. Both parties are assumed to agree on the ciphersuite in use (Section 4.1).

For each ciphersuite, ctx_proto is as computed in Section 4.1. The nullifier length is Nn = 32 bytes and the seed length, that of the random input to every derivation, is Nseed = 48 bytes for the ciphersuite below.

7.1. Rollatini(P-256, SHA-256)

This ciphersuite uses P-256 (secp256r1) [NISTCurves] for the group and SHA-256 for the hash function, with Nh = 32 and s_in_bytes = 64. The value of the ciphersuite identifier is "P256-SHA256".

The interface of Section 3.1 is instantiated as follows.

Order():

Return 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551.

Identity(), Generator(), ScalarMultGen(r):

As defined in [NISTCurves].

HashToGroup(x, DST):

Use hash_to_curve with suite P256_XMD:SHA-256_SSWU_RO_ [HASH2CURVE] and input x, using the given DST.

HashToScalar(x, DST):

Use hash_to_field from [HASH2CURVE] with count = 1, m = 1, L = 48, expand_message_xmd with SHA-256, input x, a prime modulus equal to Order(), and the given DST. Return the single resulting field element as a Scalar.

ScalarInverse(s):

The multiplicative inverse of s modulo Order().

SerializeElement(A):

The compressed Elliptic-Curve-Point-to-Octet-String method of [SEC1]; Ne = 33.

DeserializeElement(buf):

Deserialize a 33-byte input using the compressed Octet-String-to-Elliptic-Curve-Point method of [SEC1], then perform partial public key validation as in Section 4.3 of [OPRF]. This includes checking that the coordinates are in range, that the point is on the curve, and that the point is not the identity element. Raise a DeserializeError if any check fails.

SerializeScalar(s):

The Field-Element-to-Octet-String conversion of [SEC1]; Ns = 32.

DeserializeScalar(buf):

Deserialize a 32-byte input using Octet-String-to-Field-Element from [SEC1]. Raise a DeserializeError if the result is not in [0, Order()-1].

P:

The SHA-256 Feistel permutation and cycle walk in Section 6.2.3. Compressed SEC1 prefixes are 0x02 and 0x03; subtracting 0x02 gives the one-bit prefix used by the byte permutation. Pinv reverses the walk. PermutationPair (Section 6.2.3.1) is used during commitment key generation. For a random encoding permutation, the walk takes about two iterations on average.

7.2. Randomness

Every random value in this document is drawn with random. Apart from the nullifier, it is consumed in inputs of Nseed bytes, by G.DeriveScalars (Section 4.2), by G.DeriveNonces (Section 4.3) for the values of the issuer-hiding proof, or by G.DeriveKeyPair (Section 4.4) for a key; no scalar is sampled directly. Implementations MUST draw this randomness with a cryptographically secure random number generator and MUST NOT reuse it across derivations. They SHOULD treat it as being as sensitive as the values derived from it, and SHOULD handle both in constant time: the randomness drawn in Commit determines the Anchor's session state, that drawn in Challenge the Client's blinding factors, and that drawn in Redeem every value of the issuer-hiding proof, so recovering any of it undoes the property that algorithm provides.

8. Security Considerations

The security of Rollatini is studied in [FFKLLS26]. The underlying blind signature is that of Tessaro and Zhu [TESSZHU], instantiated with the public input set to the issuance context. Security is analysed in the random oracle model, and one-more unforgeability additionally in the algebraic group model under the discrete logarithm assumption.

Blindness:

All the Anchor receives in a session is the blinded challenge c * gamma2. With uniform blinding factors, the blinded challenge is uniformly distributed and independent of the message and of the resulting signature, and the underlying scheme is perfectly blind [FFKLLS26]. The blinding factors of this document are derived by hashing ("Derived blinding factors" below), so in the random oracle model the scheme is statistically blind: an Anchor can link an Endorsement to the session that produced it only by evaluating the hash function on one of the secret inputs from which the Client derives its blinding factors (Section 4.2). Finding one by search takes about 2^256 evaluations, or 2^128 on a quantum computer. Endorsement grants and redemptions are therefore unlinkable, as [ARCH] requires, even to an attacker who records transcripts for a future quantum computer.

Derived blinding factors:

Challenge derives its four blinding factors with G.DeriveScalars, and Redeem derives delta the same way and the 2 * q + 1 scalars of its proof with G.DeriveNonces (Section 4.3). Short of an evaluation of the hash function on one of their secret inputs, each is within about 2^-128 of uniform (Section 4.2), so the four blinding factors of a session are jointly within about 2^-126 of uniform, and blindness holds up to that distance.

Derived first move:

In ProveIssuer, the nonce r reused under a different challenge reveals delta, which names the Anchor, and a commitment key reused with its first opening under a different challenge can reveal the key's trapdoor and with it a bit of index. ProveIssuer therefore derives all of these together with G.DeriveNonces, from delta, index, and the proof statement as well as fresh randomness (Section 6.2.5), so that a failed random source does not cause any of them to be reused under a different challenge (Section 4.3). The Anchor's signing nonce a in Commit cannot be protected this way, since it is fixed before the Client's challenge exists and no input distinguishes two sessions at that point. Commit derives a together with t and y (Section 4.2), so a random source that repeats part of its output still changes a, but only fresh randomness or persistent per-session state prevents a full repetition. An Anchor that reuses a across two challenges reveals y * skA, and hence skA, since y is public in the Endorsement.

One-more unforgeability:

A Client that completes k issuance sessions under a given issuance context cannot produce k+1 distinct valid Endorsements under that context, regardless of how many sessions it has completed under other issuance contexts [TESSZHU]. This is what allows a Moderator to conclude that an accepted Endorsement corresponds to exactly one grant by a trusted Anchor. This security guarantee holds even with concurrent sessions.

Unforgeability under rerandomization:

Lemma 1 of [FFKLLS26] establishes strong key randomizability of [TESSZHU]. Thus, Theorem 1 of [FFKLLS26] reduces issuer-hiding one-more unforgeability to [TESSZHU], provided the issuer-hiding proof is straight-line extractable.

  • TODO Appendix C claims straight-line extractability for [SIGMA], but not [STACKSIG]. Decide how to justify this gap. Perhaps we expect a direct proof (rather than composition via Theorem 1) to be possible.

Issuer hiding:

Theorem 2 of [FFKLLS26] establishes issuer-hiding blindness from perfect blindness of issuance, strong key randomizability, and perfect zero knowledge of the issuer-hiding proof, assuming uniform randomness. Up to the statistical distance given below, a redemption reveals nothing about which Anchor in anchor_set issued the Endorsement, so a Moderator, an Anchor, and the two colluding learn only that some key in anchor_set was used. X_hat and the response of the single branch proof are statistically close to uniform independently of the branch (Section 6.2.1), and the commitment of Section 6.2.2 hides which position it binds: a commitment key is distributed independently of that position, and an opening independently of whether the value it opens to was committed or equivocated. The stacked composition is therefore witness indistinguishable (appendix and Section 7 of [STACKSIG]). These results assume uniform randomness; in the random oracle model, each of the 2 * q + 2 scalars a redemption derives is within about 2^-128 of uniform ("Derived blinding factors" above), a statistical distance of at most (2 * q + 2) * 2^-128. Like blindness, issuer hiding therefore holds in that model against an adversary that does not find those inputs, including a quantum computer that records transcripts today. The binding property of the commitment, not its hiding, rests on the discrete logarithm, which is the direction [ARCH] requires.

Partially binding commitments:

The soundness of the issuer-hiding proof rests on two things: the commitment of Section 6.2.2 binding one position of each node, and the collision resistance of HashToScalar on the leaf and node encodings, without which a position could be opened to a colliding value and no discrete logarithm would be extractable. Binding is computational. A Client that opens the value under base Q two ways knows the discrete logarithm of Q, and one that opens the value under P(Q) two ways knows that of P(Q); the key generation gives it one of the two. Opening both positions would require both logarithms. That a Q with both known is as hard to find as a discrete logarithm when P is modelled as a random permutation (Section 6.2.3) is the assumption under which the pair commitment of the appendix of [STACKSIG] binds. P MUST therefore be the permutation specified in Section 6.2.3 and MUST NOT be chosen or negotiated by any party: a Client that could choose P could take P(Q) = 2 * Q, equivocate every position of every node, and forge redemptions without an Endorsement.

Anchor Set size:

Issuer hiding hides the Anchor within the Anchor Set, so the set is the anonymity set, and a redemption against a set of one key names its Anchor (Section 6.4); blindness still keeps it unlinkable to the session that produced the Endorsement. A Moderator that offers different Anchor Sets to different Clients partitions them, and one that reorders the set between Clients does the same; the set and its order MUST be the same for every Client offered a given ctx_iss. See [ARCH] for how set size interacts with Anchor diversity.

Proof size and cost:

The proof is logarithmic in the size of the Anchor Set: two scalars, plus one element and one scalar for each of the q levels of the tree (Section 6.5), but computation is linear in n. The prover and the verifier each evaluate all n branch commitments and build a tree of n - 1 node commitments over them. The tree of the prover's first move, which is also the old tree of VecEquivocate, has empty leaves off the binding path, so at most three of its node commitments at each level are distinct. The node commitments of a level share randomness * B and P(Q), and the branch commitments share proof_challenge * X_hat + response * B, terms an implementation computes once. Each party performs a number of scalar multiplications linear in n, so a large Anchor Set is cheap in bandwidth and not in CPU, which reverses the tradeoff of the linear disjunction [CDS94] for bandwidth but not for work; [FFKLLS26] notes the same for its own instantiations. For deployments, the linear disjunction is smaller for Anchor Sets of three keys or fewer, and since the depth q = Depth(n) is ceil(log2 n), an Anchor Set of 2^q + 1 keys costs a whole extra level while adding one Anchor to the anonymity set.

Constant-time proving:

ProveIssuer treats one leaf, and one side of each node on the path to it, differently from the others, and that leaf is the secret the proof hides. Implementations MUST NOT allow the binding path to be distinguished by timing, memory access patterns, or the amount of randomness consumed; the randomness a redemption consumes is 2 * Nseed bytes (Section 6.3). The first move places the real branch commitment at index and empty values at every other leaf; both commitment passes visit every node of the tree. That placement and the later equivocation MUST avoid observable secret-dependent branches and memory accesses. The number of distinct node commitments at a level of the first move depends on index, so an implementation that computes each only once MUST compute the same number at every level for every index.

Commitment key generation uses the balanced walk of Section 6.2.3.1. Its iteration count is determined by the published commitment key, and both binding directions execute the same operation schedule for that key. This addresses the permutation walk only: secret-index selection, scalar arithmetic, and the rest of the proving algorithm still require constant-time implementations. In particular, source-level masking in the Python reference implementation is not a constant-time guarantee.

Single-use sessions:

Implementations MUST ensure that the session state produced by Commit is never used more than once. If an Anchor answers two distinct challenges c1 != c2 on one Commit state, then from the two responses s1 = a + c1*y*skA and s2 = a + c2*y*skA, with y revealed in both, anyone recovers the signing key as skA = (s1 - s2) * ScalarInverse((c1 - c2) * y). The requirement extends to process restarts, to replicas sharing a signing key, and to any retry or replay of a ChallengeMessage: an Anchor MUST treat a session as closed the moment it emits a response, and MUST answer a repeated session_id with a SessionError rather than recomputing. Anchors are stateful for this reason. Sealing the state into a cookie handed to the Client keeps (a, y, t) secret but does not make it single-use, so it does not remove the need for this state.

Challenge binding:

challenge_digest enters the proof transcript (Section 6.2.4), so a proof produced for one challenge does not verify under any other. Whether that amounts to replay protection depends on the challenge being fresh, which this document does not control: [PROTOCOLS] fixes what the Moderator's challenge contains, and a challenge that carries only the Moderator's configuration takes the same value for every Client and every session. Under such a challenge, challenge_digest binds a proof to the Moderator rather than to a session, and it is the nullifier check of Section 6.4 that prevents a redemption from being replayed. A deployment that wants challenge binding to carry session freshness needs the challenge to include a value that varies per session. The signature itself does not depend on the challenge, so single use rests on the nullifier check.

Context binding:

The issuance context enters both the commitment base and the challenge transcript, and the redemption context enters the signed message, so an Endorsement does not verify under any other pair of contexts. Neither context is carried in the Endorsement; both are supplied by the verifier (Section 5.5), so a Client cannot assert the pair its Endorsement is checked against. A Client also cannot select the issuance context unilaterally: it is never sent from the Client to the Anchor, and using a value other than the one the Anchor committed under fails the opening check in Finalize.

Nullifier reuse:

A Client that reuses a nullifier across Endorsements links those Endorsements to each other at redemption and, depending on the Moderator's nullifier store, causes all but the first redemption to be rejected. Nullifiers MUST be freshly generated.

Aborts on zero:

Challenge aborts when the challenge hashes to zero and Respond aborts on a zero challenge. Both events occur with probability approximately 1/p for honest parties, where p is the order of the group. A Client that observes such an abort learns nothing and SHOULD start a fresh session.

Context granularity:

The unlinkability arguments above are cryptographic; the anonymity set they operate over is set by the contexts. Both contexts are visible at redemption, the issuance context directly and the redemption context through the fact that the Endorsement verifies under it, so each partitions Clients into the set that shares its value. Both values, which [PROTOCOLS] specifies (Section 4.5), must therefore be coarse. Every Client holding an Endorsement issued under a given issuance context MUST derive the byte-identical ctx_iss, and every Client redeeming under a given redemption context MUST derive the byte-identical ctx_red. A deployment that refines either value, for instance by using a per-request timestamp rather than a shared epoch, or a per-Client identifier rather than a value shared by every Client redeeming in the same place, reduces the anonymity set accordingly, down to a single Client, without violating any cryptographic property of the construction. Implementations MUST NOT do so.

Session identifiers:

The session_id of Section 5.6 is chosen by the Anchor and so is a value the Anchor recognises. It is confined to the transport: it is not an input to any algorithm of Section 5, nor is it included in the challenge transcript. Were it bound into the Endorsement, the Anchor could recognise its own identifier at redemption and link the redemption to the issuance session.

Anonymity sets:

The effective privacy a Client obtains also depends on deployment properties beyond this document, in particular the number of Clients an Anchor serves per epoch and the size of a Moderator's Anchor Set; see [ARCH].

9. IANA Considerations

This document has no IANA actions. The endorsement type for the scheme specified here is registered by [PROTOCOLS].

10. References

10.1. Normative References

[ARCH]
Schlesinger, S., Jackson, D., and T. Meunier, "Moderation of unLinkable Endorsements (MoLE) Architecture", Work in Progress, Internet-Draft, draft-jms-mole-architecture-00, , <https://datatracker.ietf.org/doc/html/draft-jms-mole-architecture-00>.
[HASH2CURVE]
Faz-Hernandez, A., Scott, S., Sullivan, N., Wahby, R. S., and C. A. Wood, "Hashing to Elliptic Curves", RFC 9380, DOI 10.17487/RFC9380, , <https://www.rfc-editor.org/rfc/rfc9380>.
[HTTP-TRANSPORT]
Schlesinger, S., Jackson, D., and T. Meunier, "MoLE HTTP Transport", Work in Progress, Internet-Draft, draft-jms-mole-http-transport-00, , <https://datatracker.ietf.org/doc/html/draft-jms-mole-http-transport-00>.
[I2OSP]
Moriarty, K., Ed., Kaliski, B., Jonsson, J., and A. Rusch, "PKCS #1: RSA Cryptography Specifications Version 2.2", RFC 8017, DOI 10.17487/RFC8017, , <https://www.rfc-editor.org/rfc/rfc8017>.
[NISTCurves]
National Institute of Standards and Technology (NIST), "Digital Signature Standard (DSS)", FIPS PUB 186-5, , <https://doi.org/10.6028/NIST.FIPS.186-5>.
[OPRF]
Davidson, A., Faz-Hernandez, A., Sullivan, N., and C. A. Wood, "Oblivious Pseudorandom Functions (OPRFs) Using Prime-Order Groups", RFC 9497, DOI 10.17487/RFC9497, , <https://www.rfc-editor.org/rfc/rfc9497>.
[PROTOCOLS]
Schlesinger, S., Jackson, D., and T. Meunier, "MoLE Protocols", Work in Progress, Internet-Draft, draft-jms-mole-protocols-00, , <https://datatracker.ietf.org/doc/html/draft-jms-mole-protocols-00>.
[RFC2119]
Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, DOI 10.17487/RFC2119, , <https://www.rfc-editor.org/rfc/rfc2119>.
[RFC8174]
Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174, , <https://www.rfc-editor.org/rfc/rfc8174>.
[SEC1]
Standards for Efficient Cryptography Group (SECG), "SEC 1: Elliptic Curve Cryptography", , <https://www.secg.org/sec1-v2.pdf>.
[TLS13]
Rescorla, E., "The Transport Layer Security (TLS) Protocol Version 1.3", RFC 8446, DOI 10.17487/RFC8446, , <https://www.rfc-editor.org/rfc/rfc8446>.

10.2. Informative References

[CDS94]
Cramer, R., Damgard, I., and B. Schoenmakers, "Proofs of Partial Knowledge and Simplified Design of Witness Hiding Protocols", CRYPTO 1994, , <https://doi.org/10.1007/3-540-48658-5_19>.
[DETSIGS]
Mattsson, J. P., Thormarker, E., and S. Ruohomaa, "Hedged ECDSA and EdDSA Signatures", Work in Progress, Internet-Draft, draft-irtf-cfrg-det-sigs-with-noise-05, , <https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-det-sigs-with-noise-05>.
[DS15]
Dai, Y. and J. Steinberger, "Indifferentiability of 8-Round Feistel Networks", , <https://eprint.iacr.org/2015/1069>.
[FFKLLS26]
Flamini, A., Friedrichs, K., Katz, J., Ladd, W., Lehmann, A., and M. Sefranek, "Issuer-Hiding Anonymous Tokens and Credentials from Key-Randomizable Signatures", , <https://eprint.iacr.org/2026/870>.
[SIGMA]
Orrù, M. and C. Yun, "Sigma Proofs for Linear Relations", Work in Progress, Internet-Draft, draft-irtf-cfrg-sigma-protocols-03, , <https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-sigma-protocols-03>.
[STACKSIG]
Goel, A., Green, M., Hall-Andersen, M., and G. Kaptchuk, "Stacking Sigmas: A Framework to Compose Sigma-Protocols for Disjunctions", EUROCRYPT 2022, , <https://eprint.iacr.org/2021/422>.
[TESSZHU]
Tessaro, S. and C. Zhu, "Short Pairing-Free Blind Signatures with Exponential Security", EUROCRYPT 2022, , <https://eprint.iacr.org/2022/047>.

Appendix A. Test Vectors

The vectors below cover the ciphersuite of Section 7: G.DeriveScalars, the permutation P, a key pair, one issuance, and redemptions of the resulting Endorsement against Anchor Sets of two keys, of five, and of one. Byte strings are in hexadecimal, wrapped at 64 digits with the continuation lines indented; integers are decimal.

Every algorithm is a deterministic function of the bytes it draws from random. derive.rand is the rand argument of G.DeriveScalars; every other rand entry is the concatenation of every random call the algorithm makes, in the order made, and an implementation replays it by serving those bytes in place of random.

For G.GenerateKeyPair the rand entry is the key seed; for Commit, the Nseed bytes of (a, t, y); for Challenge, the Nn bytes of the nullifier and then the Nseed bytes of the blinding factors; and for Redeem, the Nseed bytes of delta and then the Nseed bytes of the issuer-hiding proof. The commit.state entry is the AnchorState (a, y, t) as SerializeScalar(a) || SerializeScalar(y) || SerializeScalar(t), and the challenge.state entry is nf || SerializeScalar(r1) || SerializeScalar(r2) followed by SerializeScalar(gamma1) || SerializeScalar(gamma2) || SerializeScalar(c). issue.Z is CreateContextBase(ctx_iss), and key.P_pkA is P(pkA). Every message and endorsement entry is an encoding of Section 5.6 or Section 6.5, and the commit and challenge messages carry issue.session_id. The keys of each Anchor Set other than key.pkA were generated for the vectors. Every redemption presents the same Endorsement, which a Moderator would accept only once; each nf entry is the output of VerifyRedemption.

A.1. Ciphersuite

suite.identifier = 503235362d534841323536
suite.ctx_proto = 526f6c6c6174696e6976312d503235362d534841323536

A.2. Scalar Derivation

derive.info = 526f6c6c6174696e69207465737420766563746f7273
derive.rand =
    2901ba7aa4385020806b9dfda274116e8e7b40dc4b7ea5ecf802f450f385d95a
    23b24f8989262dd0098c67f1b18c7049
derive.scalars =
    d3a1cf0eb8ad06b36ccbec270304fe9d1e463aebe0074628e973372cc060dde1
    236ca33273718791d45cf1bd10bc8d27ae93514c23829beccfc7d4d810e9d2fa
    15bf15da1582d7290ef5c5ff6a3d56751903eff0d3c53a45f200654078bcca87

A.3. Key Pair

key.rand =
    1895f96ba9ffd425f438e32d27c0f30789192aaee2fb81ddab56a33ba979e1dc
    d662a1d0030115f54fc5f3ba9460f5cc
key.skA =
    cf0f461405dbbe310331b868ef937601c99fc006b07cc17e557a2bb379a03d10
key.pkA =
    02d3b5a1a36d47a82b4c11018555f385356ff5dea2a5450e856f305323ccf017
    6e
key.P_pkA =
    03e07a7634377aae0b8d88ecf0639ff8a9483532fdda7f128863aba3d94fa491
    0e

A.4. Issuance

issue.ctx_iss =
    526f6c6c6174696e69207465737420766563746f72732069737375616e636520
    636f6e74657874
issue.ctx_red =
    526f6c6c6174696e69207465737420766563746f727320726564656d7074696f
    6e20636f6e74657874
issue.Z =
    0371e34b3816dbda52ab5249e1a5192ae9a097d8475e66aea5c56748c7a4ae59
    d1
issue.session_id =
    526f6c6c6174696e69207465737420766563746f72732073657373696f6e
issue.commit.rand =
    34f8d290513e7ee4b333809f8468084d625e481f88467d66441fc84823863262
    2425cece0fcf97086b853bd5ab9450a8
issue.commit.state =
    1ff634c3b291a37a1bba99abc3cf59647d0ae9e5102f20940bfaf0dd8e2d8fbe
    0a094ed6538587c629620311408b997ca9ff15fed8d11adf42349fee1764f7ff
    aa5af1f5d078574376eee716ce00d72a7714ecae1c9b716454ce475490297e1d
issue.commit.message =
    1e526f6c6c6174696e69207465737420766563746f72732073657373696f6e03
    1112cfa1bda1deb5baf2c974f6c44ec75ee78e4a656888bbddcfbacfe2da694f
    032b993d8ae330e870050b44cd90bc073f4908f3dffb60d8dff49adba47524b2
    89
issue.challenge.rand =
    814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c647d
    fcd7b4dab3af98a5efd69ba6733ea379906dbfcbc9d3337a4fbf3021a6d1cc15
    eb712fa952f188ce9e95525268c95edf
issue.challenge.state =
    814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c647d
    09e017c74e57c56748f9a216c5aeee53a8bd370ea34f5b0a14a1022b92d768aa
    cabc4eb33008cab5717d53fc710a4056d38931e5611cab7693a4b722bdfbfe1b
    1c60cd093a4c1d04c9fa6dc94a76dda2e6058238e4d6808247b4e14f52f06f25
    2ea7756101a348c56b54b240d0df324d808a477e612dd530276896e85d832b6d
    e62a6114a8272e86fc36d7876f3955f2939d800140c0614d9db1450579d55827
issue.challenge.message =
    1e526f6c6c6174696e69207465737420766563746f72732073657373696f6ec4
    c94c26a9fdcffb178e27a01f6ccf020ac3f6b1c6b8a51453b8d703ef44086d
issue.response.message =
    c13f29a8276124c478e0becdbc59a1533e908bbee01b5cff12fb4e698ab91de6
    0a094ed6538587c629620311408b997ca9ff15fed8d11adf42349fee1764f7ff
    aa5af1f5d078574376eee716ce00d72a7714ecae1c9b716454ce475490297e1d
issue.endorsement =
    e62a6114a8272e86fc36d7876f3955f2939d800140c0614d9db1450579d55827
    381070d16151da97f15dbff860b81d9e5b7de45fc0ed44deaa13a85431f5283b
    65ea4ecce7f4d8ef2732bcf633021a09c2205fa0bd4d577fb196dbef5556fd11
    6062a703326d0aac0e8b45263b6fb1e8e36648c91116d09eb9bfc61a6b2b557b
    814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c647d

A.5. Redemption Against 2 Anchors

redeem2.index = 1
redeem2.anchor_set =
    02f3c80b6a4766e60b0f436bb60565ba7273eb72fad58dada88b73b5df7f35e0
    9d02d3b5a1a36d47a82b4c11018555f385356ff5dea2a5450e856f305323ccf0
    176e
redeem2.challenge_digest =
    526f6c6c6174696e69207465737420766563746f7273206368616c6c656e6765
    20646967657374
redeem2.rand =
    25b7018513eec1ec6963f83619f5a4b65b188a4bd1f8372893cafe1d4e9c6e14
    dff470ec235490aacaa96e215c080538de752d72e39e49c42372d24c6109946e
    6663efa6d497f2831fa2762be606e36b8aca1a01358537e25c53fb55cebfc875
redeem2.delta =
    33c221d1588baa3ac3589f2e8972fbc1b6b18a32ddf6a54bc7d55be6e8763f18
redeem2.message =
    020e4737feb353d8f713342c0eb52ed2a1a2c569d658daaf16f907a872380105
    0ce62a6114a8272e86fc36d7876f3955f2939d800140c0614d9db1450579d558
    279958a11e2506950f8d014b0329e8fc5ab36eca2c1a4ba5113e9d852cb2555a
    5a65ea4ecce7f4d8ef2732bcf633021a09c2205fa0bd4d577fb196dbef5556fd
    116062a703326d0aac0e8b45263b6fb1e8e36648c91116d09eb9bfc61a6b2b55
    7b814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c64
    7d1ff5ecfc12e85838877af8f3870b39c8184ff220228da2f8afd963d8d6a1e0
    2aaea3abcb638dfb3561d518d6eb67c58e8f1a8c4958792eb5a6544f5f0cd164
    f52102e33b5e85210390c67a1cae58e947a8f888a6d5c4d7c2b24f36aabb64b5
    0401fa20110d052c26abab640ce09b4bc4b9646316837dbc8dd5a23bee4b47fe
    3aaec247
redeem2.nf =
    814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c647d

A.6. Redemption Against 5 Anchors

redeem5.index = 3
redeem5.anchor_set =
    03c9df5f77dc47fb0892bb500c9623b3d793f05075dd2454139185fffbe0e856
    3102a56ce949a989b3cdf73657719889a56d54e5cb0bf4b7a6409440280a98bd
    266402fe80fa33232f36549fc3cb619468d1c18585aa28aaaa31de5549f1e4a6
    bfbc6d02d3b5a1a36d47a82b4c11018555f385356ff5dea2a5450e856f305323
    ccf0176e021d67b295ebd6c17d1651e9df194a94e1d7dde3955e7bcb5158c2c6
    e13c10fcb3
redeem5.challenge_digest =
    526f6c6c6174696e69207465737420766563746f7273206368616c6c656e6765
    20646967657374
redeem5.rand =
    278228bc418f00cb9dd39e5020e580092272b6370cf14c6c6ea4ff5444b68fcf
    c0e0b548a7ff0d9248e21220577501ee644ab5c7098accd602b255a968372a44
    56033a95bec633bf0ecea472b0175acee9073e60074887fce64c537fde6fdd88
redeem5.delta =
    094ac33f15105dfa8b102e3c707e8f1ce90cb48ad544b5b6fbe9b17c44e634b1
redeem5.message =
    02e4a72beabef3e6037b33aff1e56c5c1e19192e9194c0780e048f5da21f4400
    6ae62a6114a8272e86fc36d7876f3955f2939d800140c0614d9db1450579d558
    2756b4ae805c0054aa48f3304ce0a646361ac6b3bf00a7f4aa2ac828f167ec11
    1465ea4ecce7f4d8ef2732bcf633021a09c2205fa0bd4d577fb196dbef5556fd
    116062a703326d0aac0e8b45263b6fb1e8e36648c91116d09eb9bfc61a6b2b55
    7b814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c64
    7d8e74e453c9a9a13fc605a1d443bd8cdc8b0a5f9ff7a988d8535d66be881028
    bcc6499b6935a542579dcb86e5fe58240d02a468d9a01f29b7563260b5843663
    7d40630299ac22177836651a8495be06aa2807899da3f2962eae96b33cf33734
    41751913033d68319ee3b6ac6f9cb378b68c6bfab174b950d1808839a5990a67
    b2b9fea1ca03dadded9dfc1c00d619e8713b4ea7e897b2dff6c747a1a02bf4ef
    747a42b14e3b40607ee586efb34851f192bfa32e07cbee98c1065f2bacdebaa4
    764256b45b7fa612133e0bf8506994e159d1e0c18cf02ffbef51041e493913c4
    1a8358c21ad1b614d5effcb4a1c63483eccb92688cc2e31a12b8b731d2869513
    8f5be4b3829c2549
redeem5.nf =
    814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c647d

A.7. Redemption Against 1 Anchor

redeem1.index = 0
redeem1.anchor_set =
    02d3b5a1a36d47a82b4c11018555f385356ff5dea2a5450e856f305323ccf017
    6e
redeem1.challenge_digest =
    526f6c6c6174696e69207465737420766563746f7273206368616c6c656e6765
    20646967657374
redeem1.rand =
    225410fcb6e40d00fb652141e6d19f9a418c18e4b0177981a85b09323ccb6325
    bf949b541dce86e244978bea6238f4b5ec1497dac66c2803f15b06cc530a5961
    ca99a7db68fd8816691248473c40c53203c5e1687d4628a176c8e2759133c57e
redeem1.delta =
    a20875aba695e447305ba2833a23d141dcd105d6703b149489272c5bb6850bc2
redeem1.message =
    036c7114e474fe664a90bf71604dab46a02905f3fa421d9d8952e514a4001cb8
    9ae62a6114a8272e86fc36d7876f3955f2939d800140c0614d9db1450579d558
    271ca069fa7fa55962a7d78cfb969861786aea5250ac35d0b1a83d2939fc8bf4
    0865ea4ecce7f4d8ef2732bcf633021a09c2205fa0bd4d577fb196dbef5556fd
    116062a703326d0aac0e8b45263b6fb1e8e36648c91116d09eb9bfc61a6b2b55
    7b814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c64
    7d1203f01e897e951be4842a32a5cf062acf7a80953dc75cfccc5532fa396ac1
    37948e7fa247b645a950cb4700d122fd457dd7ffe89af2267b172f3f57830d73
    560000
redeem1.nf =
    814547b784ed0a83055b08aff5bb13f32833e312f619564a78c91d3ed46c647d

Acknowledgments

TODO acknowledge.

Authors' Addresses

Samuel Schlesinger
Google LLC
Watson Ladd
Akamai Technologies
Deep Inder Mohan
Georgia Institute of Technology