std/signature

Ed25519 of RFC 8032 and ECDSA verification over P-256 (ES256) in TorbScript - an Ed25519 private key from a 32-byte seed signs, public keys verify, and keys and signatures read and write themselves as bytes and hexadecimal.

The generated reference of std/signature has every public construct with its signature, its doc comment and its examples.

std/signature makes and checks digital signatures. Its main scheme is Ed25519 (RFC 8032 section 5.1): a private key made from 32 bytes of randomness signs a message, and anybody who has the public key can check that the signature was made by that key over exactly that message. The package registry signs every record of its index with it (RELEASE.md section 7.4), so that a mirror can hold back a release but cannot change one. The second is ECDSA over P-256 with SHA-256 (FIPS 186-5), which JOSE and WebAuthn call ES256 and most passkeys sign with: only verified here, so there is a public key and a signature and no private key. It is ordinary TorbScript with no native, over Int arithmetic and std/digest's Sha512 and Sha256, so both back ends compute the same bits.

Import#

use Ed25519PrivateKey, Ed25519PublicKey, Ed25519Signature, SignatureError from "std/signature"
use P256PublicKey, P256Signature from "std/signature"
use Ed25519PrivateKey from "std/signature"

const seed: Bytes = Buffer.from List.filled(32, 1)
const key = Ed25519PrivateKey.fromSeed(seed).expect("32 bytes")
const message = "a release record".bytes()
const signature = key.signature message
print key.publicKey().verifies(message, signature)

Declarations#

Ed25519PrivateKey#

type Ed25519PrivateKey with Show, Encode {
  static fn fromSeed(seed: Bytes): Result<Self, SignatureError>
  static fn fromHex(text: String): Result<Self, SignatureError>
  fn seed(): Bytes
  fn seedHex(): String
  fn publicKey(): Ed25519PublicKey
  fn signature(message: Bytes): Ed25519Signature
  fn show(): String
  fn encode<Target: Encoder>(var target: Target)
}

A private key is the 32-byte seed RFC 8032 calls the private key. fromSeed derives everything else from it once - the secret scalar, the prefix the nonce of a signature is hashed from, and the public key - which costs about as much as one signature; fromHex reads the seed as 64 hexadecimal digits, the way it comes out of an environment variable. signature signs a message. Signing is deterministic: the same key and message always give the same signature, and it needs no randomness of its own.

The package makes no randomness: where the seed comes from is the caller's, and it has to be 32 bytes of the system's source of randomness. Its show() and its Encode write Ed25519PrivateKey(public key <hex>) and never the seed, so a key that lands in a log line or an encoded configuration does not leak; seed and seedHex are the ways to the secret, for the one place that keeps it.

Ed25519PublicKey#

type Ed25519PublicKey with Show, Equals, Hash, TryFrom<String, SignatureError> {
  static fn fromBytes(bytes: Bytes): Result<Self, SignatureError>
  static fn tryFrom(value: String): Result<Self, SignatureError>
  fn bytes(): Bytes
  fn hex(): String
  fn show(): String
  fn equals(other: Self): Bool
  fn hash(): Int
  fn verifies(message: Bytes, signature: Ed25519Signature): Bool
}

A public key is 32 bytes that encode a point of the curve. Both factories check that they do, canonically - a y below p, an x that exists, no negative zero - so a key that exists is one verifies can use. As text it is 64 lowercase hexadecimal digits: hex(), show() and String.from(key) write them, tryFrom reads them in either case, and the same pair is how a key is encoded and decoded. Two keys are equal when their bytes are.

verifies answers whether a signature is this key's signature of a message: whether S B - k A encodes as R, the equation without the cofactor that ref10, libsodium and OpenSSL check.

use Ed25519PrivateKey, Ed25519PublicKey from "std/signature"

const seed: Bytes = Buffer.from List.filled(32, 2)
const key = Ed25519PrivateKey.fromSeed(seed).expect("32 bytes")
const published = String.from key.publicKey()
const reader = Ed25519PublicKey.tryFrom(published).expect("the key just written")
const message = "index/acme/http.trb".bytes()
print reader.verifies(message, key.signature(message))
print reader.verifies("another message".bytes(), key.signature(message))

Ed25519Signature#

type Ed25519Signature with Show, TryFrom<String, SignatureError> {
  static fn fromBytes(bytes: Bytes): Result<Self, SignatureError>
  static fn tryFrom(value: String): Result<Self, SignatureError>
  fn bytes(): Bytes
  fn hex(): String
  fn show(): String
}

A signature is 64 bytes: the encoded point R, then the scalar S. The factories refuse an S that is not below the group order L (RFC 8032 section 5.1.7), which is what keeps a signature from having a second form that verifies as well. As text it is 128 lowercase hexadecimal digits, read back by tryFrom and encoded and decoded the same way.

P256PublicKey#

type P256PublicKey with Show, Equals, Hash, TryFrom<String, SignatureError> {
  static fn fromBytes(bytes: Bytes): Result<Self, SignatureError>
  static fn fromCoordinates(x: Bytes, y: Bytes): Result<Self, SignatureError>
  static fn tryFrom(value: String): Result<Self, SignatureError>
  fn bytes(): Bytes
  fn hex(): String
  fn show(): String
  fn equals(other: Self): Bool
  fn hash(): Int
  fn verifies(message: Bytes, signature: P256Signature): Bool
}

A P-256 public key is a point of the curve y^2 = x^3 - 3 x + b modulo the prime p (SP 800-186 section 3.2.1.3). fromBytes reads the uncompressed encoding of SEC 1 section 2.3.3 - the byte 4, then x and y as 32 bytes each, big endian - and fromCoordinates the two coordinates on their own, the way a JSON Web Key and a COSE key write them. Both check that each coordinate is below p and that the point is on the curve: a coordinate written as itself plus p is refused, not reduced. That is the whole validation SP 800-56A section 5.6.2.3.3 asks for, since the cofactor of P-256 is 1 and the point at infinity has no encoding. As text a key is the 130 lowercase hexadecimal digits of its 65 bytes.

verifies answers whether a signature is this key's ECDSA signature of a message with SHA-256 (FIPS 186-5 section 6.4.2): with e the SHA-256 of the message and w the inverse of s modulo the group order n, whether the point (e w) G + (r w) Q is not the point at infinity and has an x that is r modulo n. Both r and s were checked to be from 1 to n - 1 when the signature was read.

use P256PublicKey, P256Signature from "std/signature"

const key = P256PublicKey.tryFrom(
  "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6"
    + "7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299",
).expect("the key of RFC 6979 section A.2.5")
const signature = P256Signature.tryFrom(
  "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716"
    + "f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8",
).expect("its signature of sample")
print key.verifies("sample".bytes(), signature)
print key.verifies("example".bytes(), signature)

P256Signature#

type P256Signature with Show, TryFrom<String, SignatureError> {
  static fn fromBytes(bytes: Bytes): Result<Self, SignatureError>
  static fn fromDer(bytes: Bytes): Result<Self, SignatureError>
  static fn tryFrom(value: String): Result<Self, SignatureError>
  fn bytes(): Bytes
  fn hex(): String
  fn show(): String
}

A P-256 signature is the two scalars r and s. fromBytes reads them as 64 bytes, r and then s, big endian - the form JOSE's ES256 uses (RFC 7518 section 3.4) - and fromDer reads the DER that WebAuthn, X.509 and OpenSSL use (RFC 3279 section 2.2.3): a SEQUENCE of two INTEGERs, in the one strict encoding - short lengths, positive integers in their fewest bytes, nothing after the sequence. Both refuse an r or an s that is 0 or not below n. bytes(), hex() and tryFrom always use the 64 bytes.

SignatureError#

type SignatureError with Show, Error {
  case WrongLength(what: String, expected: Int, found: Int)
  case NotHexadecimal(what: String, reason: String)
  case NotAPoint
  case ScalarOutOfRange
  case NotDer(reason: String)
  case NotAnRsaKey(reason: String)
  fn show(): String
}

Why bytes or a text are not a key or a signature: the wrong number of bytes, text that is not hexadecimal, bytes that encode no point of the curve, a scalar out of its range (an Ed25519 S that is not below L, a P-256 r or s that is 0 or not below n), or DER that is not the one strict encoding. A signature that does not verify is not an error: verifies answers false.

use Ed25519PublicKey from "std/signature"

print Ed25519PublicKey.tryFrom("0200000000000000000000000000000000000000000000000000000000000000")
print Ed25519PublicKey.tryFrom("d75a98")

Correctness and speed#

Tested against all five test vectors of RFC 8032 section 7.1 (the empty message, one byte, two bytes, 1023 bytes, and the SHA-512 of abc) and against four vectors Node's crypto made from random seeds and messages (std/signature/tests, natively and in the VM): each seed makes its public key, each message signs to the printed signature byte for byte, each signature verifies, and a changed message, R, S or key does not. The vectors of the RFC were checked against Node's crypto - OpenSSL's Ed25519 - before they were written into the test. Once more, by hand: 256 random seeds and messages of up to 300 bytes, signed by Node, gave the same public keys and the same signatures here, every signature verified, and each of them with one bit flipped was refused here exactly where Node refused it.

Measured with benchmarks/signature.trb, a message of the size of an index record, on the machine that built it (Windows, gcc, x86-64, 2026-09-27):

native, --releasethe VMNode's crypto (OpenSSL), for comparison
a key from a seedabout 700 per secondabout 23 per second-
a signatureabout 700 per secondabout 16 per secondabout 13 000 per second
a verificationabout 1 000 per secondabout 21 per secondabout 5 000 per second

A thousand index records verify in about a second natively, which is what the package manager does: torb is a native program. Most of the distance to OpenSSL is TorbScript's checked arithmetic and bounds; signing is further behind because it does not use ref10's precomputed table of multiples of the base point, and computes its nine multiples for every signature instead.

P-256 is tested against the two SHA-256 signatures of RFC 6979 section A.2.5 and two signatures Node's crypto made, in DER - each verifies, and a changed message, r, s or key does not - and against every case of Project Wycheproof's ecdsa_secp256r1_sha256_p1363_test.json (262) and ecdsa_secp256r1_sha256_test.json (484, in DER), each of which verifies or is refused as the file says: r and s of 0, n, p and just around them, an r whose x is above n (accepted through r + n where that is below p), the point at infinity as an intermediate sum, keys that share an x with the base point, and DER bent in every way the file knows. The arithmetic was first written as a Python model with the same limbs, the same steps and a check of every intermediate Int against 2^63, compared with affine arithmetic and run through both files with no difference. A verification is about 6,000 multiplications modulo p and n, nearly all of them in the 256 doublings and the additions of u1 G + u2 Q; in the VM that is about 8 verifications per second (Linux, x86-64, 2026-10-08). The native speed is not measured yet.

Constant time, and where it stops. Signing never branches on a secret and never indexes by one, in the source: the multiplication of the base point writes the scalar as signed digits and reads all nine multiples of the base point for every digit, keeping the one it wants with a mask; the field arithmetic, the reduction modulo L, S = r + k s and SHA-512 are fixed sequences of arithmetic. What that becomes in the machine is not verified: the C the compiler emits checks every + and * for overflow with a branch that is never taken, the C compiler is free to reorganize the masks, no measurement of the timing was made, and the VM is an interpreter nobody should call constant time. Not constant time at all, and not meant to be: verifying (everything it reads is public), decoding a public key, the hexadecimal of fromHex, and == on two private keys, which compares their seeds byte by byte. Nothing wipes a seed from memory; a copy lives as long as the value does. Everything of P-256 takes variable time, which is right for it: only verification is here, and a public key, a signature and a message are public.

Pitfalls#

A seed is not a password. A seed made from something a person chose, or from a hash of it, can be guessed. It has to be 32 bytes of the system's source of randomness, kept secret, and never written into a repository.

verifies without the cofactor. Implementations that check 8 S B = 8 R + 8 k A accept a few signatures made with points of small order that this one refuses. Both are allowed by RFC 8032; a system that needs every implementation to agree on every edge case has to pin one of the two, as ZIP 215 does for the other.

ECDSA signatures are malleable. Where (r, s) verifies, (r, n - s) verifies as well: FIPS 186-5, WebAuthn and OpenSSL accept both, and so does P256PublicKey.verifies. A signature is not an identifier of what was signed; a system that keeps a set of the signatures it has seen, to refuse a replay, keys it by the message instead.

No compressed P-256 keys. P256PublicKey reads the uncompressed encoding and the two coordinates; the 33 bytes of the compressed encoding fail with WrongLength. WebAuthn, COSE and JSON Web Keys carry both coordinates.

No other hash with P-256. verifies hashes with SHA-256, which is what ES256 is. A signature made over the SHA-384 of a message, or over a message hashed by the caller, does not verify. A passkey's assertion signs authenticatorData followed by the SHA-256 of clientDataJSON: that concatenation is the message, not its hash. A WebAuthn signature is DER and is read with P256Signature.fromDer; an ES256 signature of a JWS is r and s side by side, read with P256Signature.fromBytes.

  • std/digest - Sha512 and Sha256, which Ed25519 and P-256 hash with, and why a digest is not a signature.
  • RELEASE.md section 7.4 - the index of the registry, whose every record is signed.
  • The standard library - the other packages.
Esc