I want to sign data with a remote signer

We recommend the Prehash and SignPrehash primitives with an ML_DSA_65 key when the private key lives somewhere the message can't be sent.

Sometimes the party that holds the signing key can't -- or shouldn't -- receive the message itself: the key lives in an HSM or a KMS, or the message is larger than the signer's request size limit.

The Prehash and SignPrehash primitives solve this by splitting signing into two steps. You compute a short prehash value where the message is, using only the public key, and send it to the signer. The signer turns it into a signature using the private key, without ever seeing the message. With ML-DSA in External Mu mode -- the algorithm Tink supports here -- the prehash value is 69 bytes, whatever the size of the message.

The signature you get back is an ordinary signature over the original message: verifiers use the regular Digital Signature primitive and don't need to know that two steps were involved.

Before you begin

Create an ML-DSA keyset whose keys have an ID requirement -- either the TINK variant, or NO_PREFIX_WITH_PREHASH_ID if you don't want an output prefix on the resulting signature. Give the signer the private keyset and the prehashing side the corresponding public keyset. The signer should have an enabled key for every key ID the prehashing side can produce; see Keysets.

Step 1: Compute the prehash value

Run this wherever the message is. It needs only the public keyset.

C++

#include "tink/keyset_handle.h"
#include "tink/signature/config_2026.h"
#include "tink/signature/prehash.h"

absl::StatusOr<std::unique_ptr<crypto::tink::Prehash>> prehasher =
    public_handle.GetPrimitive<crypto::tink::Prehash>(
        crypto::tink::ConfigSignature2026());
if (!prehasher.ok()) return prehasher.status();

absl::StatusOr<std::string> prehash = (*prehasher)->Compute(message);
if (!prehash.ok()) return prehash.status();

Go

import "github.com/tink-crypto/tink-go/v2/signprehash"

prehasher, err := signprehash.NewPrehash(publicHandle)
if err != nil {
    return err
}
prehash, err := prehasher.ComputePrehash(message)
if err != nil {
    return err
}

Step 2: Send the prehash value to the signer

Send the prehash value to whatever holds the private key. It is not secret, but you must protect its integrity in transit: an attacker who can modify it in flight controls what gets signed.

Step 3: Sign the prehash value

Run this wherever the private key is.

C++

#include "tink/keyset_handle.h"
#include "tink/signature/config_2026.h"
#include "tink/signature/sign_prehash.h"

absl::StatusOr<std::unique_ptr<crypto::tink::SignPrehash>> signer =
    private_handle.GetPrimitive<crypto::tink::SignPrehash>(
        crypto::tink::ConfigSignature2026());
if (!signer.ok()) return signer.status();

absl::StatusOr<std::string> signature = (*signer)->Sign(prehash);
if (!signature.ok()) return signature.status();

Go

import "github.com/tink-crypto/tink-go/v2/signprehash"

signer, err := signprehash.NewPrehashSigner(privateHandle)
if err != nil {
    return err
}
sig, err := signer.SignPrehash(prehash)
if err != nil {
    return err
}

Step 4: Verify the signature

Verification is the ordinary Digital Signature flow over the original message, not over the prehash value.

C++

absl::StatusOr<std::unique_ptr<crypto::tink::PublicKeyVerify>> verifier =
    public_handle.GetPrimitive<crypto::tink::PublicKeyVerify>(
        crypto::tink::ConfigSignature2026());
if (!verifier.ok()) return verifier.status();

absl::Status verified = (*verifier)->Verify(signature, message);

Go

import "github.com/tink-crypto/tink-go/v2/signature"

verifier, err := signature.NewVerifier(publicHandle)
if err != nil {
    return err
}
if err := verifier.Verify(sig, message); err != nil {
    return err
}

Prehash and SignPrehash

The Prehash and SignPrehash primitives split the computation of a digital signature into two steps:

  1. Prehash needs only the public key. It turns a message of arbitrary length into a short, fixed-size prehash value.
  2. SignPrehash needs the private key. It turns a prehash value into a signature.

The signature that comes out is an ordinary signature over the original message. You verify it with the regular Digital Signature PublicKeyVerify primitive, and verifiers don't need to know -- or care -- that the signature was produced in two steps.

Treat the prehash value as opaque bytes. It has a fixed size and it carries the ID of the key it was computed for, but its layout is part of Tink's wire format, and you should not parse or construct one yourself. If you are porting Tink or need the byte-level details, see Tink wire format.

Use this pair of primitives when:

  • The signing key lives somewhere else, for example in an HSM, in a KMS, or behind an RPC boundary, and you don't want to ship the full message across that boundary.
  • The message is large, and the remote signer enforces a request size limit.

If neither of these applies, use the plain Digital Signature primitive instead: it is simpler, and it is harder to misuse.

Keysets

The two primitives select keys differently, in the same way that signing and verification do for the Digital Signature primitive:

  • Prehash.Compute always uses the primary key of the public keyset, and records that key's ID in the prehash value. It is the side that chooses which key the signature will be made with.
  • SignPrehash.Sign reads the key ID out of the prehash value and signs with the matching enabled key of the private keyset. It is the side that follows a choice someone else has already made. If no enabled key in the keyset has that ID, the call fails.

Every key in a SignPrehash keyset must have an ID requirement, otherwise creating the primitive fails.

Minimal security guarantees

  • The resulting signature has the same properties as a signature produced by the Digital Signature primitive with the same key type.
  • Tink prefixes the prehash value with 5 bytes containing a special reserved value and the ID of the key it was computed for. SignPrehash signs the value only with that key. Whether the value is cryptographically bound to that key depends on the algorithm; for External Mu ML-DSA it is, see Tink wire format.
  • Messages can have arbitrary length.

Things to watch out for

  • The signer cannot inspect what it signs. Anyone who can call SignPrehash can get an arbitrary message signed, and the signer has no way to apply a policy to the message contents. Protect access to SignPrehash exactly as you would protect access to PublicKeySign.
  • Protect the prehash value in transit. It is not secret, but an attacker who can modify it in flight controls what gets signed.

Choose a key type

ML-DSA in External Mu mode, as described in RFC 9881, is the only algorithm Tink supports for Prehash and SignPrehash. You should use a standard ML-DSA key with these primitives.

We recommend ML_DSA_65 for most use cases.

The key must have an ID requirement, because every prehash value starts with a prefix that contains the ID of the key it was computed for. The following variants are accepted:

  • TINK -- the resulting signature starts with Tink's usual 5-byte output prefix.
  • NO_PREFIX_WITH_PREHASH_ID -- the resulting signature carries no output prefix, while the key still has the ID that the prehash value needs.

Keys that use the NO_PREFIX (raw) variant are not supported, because they have no key ID.