A pure Elixir implementation of C2SP signed notes and transparency log checkpoints.
A note is text signed by one or more keys — the format transparency
logs, witnesses, and monitors exchange (Go module sumdb checkpoints,
Sigstore's Rekor, static-ct logs). This library parses, verifies, and
signs notes with Ed25519 keys, and reads and writes the checkpoint
profile via SignedNote.Checkpoint.
iex> vkey = "example.com/foo+530d903a+AekyeRrm56hApGFkyQR4ZCbV54Id2LKaANYcrnKv3U2k"
iex> {:ok, verifier} = SignedNote.Verifier.from_string(vkey)
iex> note = """
...> This is an example message.
...>
...> — example.com/foo Uw2QOkn8srV1yJGh2VYRlL1Tnagv1YEq6TfXppzi2ONncAlTgK7Ztg1ERYNZXsYjOBH3mFXmRKuwHjG1Yu72IneyaQM=
...> """
iex> {:ok, opened} = SignedNote.open(note, [verifier])
iex> opened.text
"This is an example message.\n"
iex> opened.verified_names
["example.com/foo"]Verification model
open/2 follows the spec's trust rules exactly: signatures from unknown
keys (no verifier matches both name and key ID) are ignored; a failing
signature from a known key rejects the whole note; a note with no
verifying known signature is rejected. Text obtained from open/2 has
been signed by every key named in verified_names.
parse_unverified/1 also returns note text, without checking any
signature. The spec requires that unverified text be ignored, so that
function is for debugging and inspection only. Anything that makes a
trust decision must use open/2.
Scope
Ed25519 (signature type 0x01) is the only implemented algorithm, per
the spec's guidance that implementations support only the types their
design requires. Verifier keys use the C2SP vkey encoding
(SignedNote.Verifier).
Requirements
Erlang/OTP 29 or later, checked at compile time, and Elixir 1.20.2 or later.
Summary
Functions
Adds signatures to an already-signed note, preserving its existing ones.
Parses binary as a signed note and verifies it against verifiers.
Parses the structure of a note without verifying any signature.
Signs text with each signer and renders the complete note.
Types
@type t() :: %SignedNote{ signatures: [SignedNote.Signature.t()], text: String.t(), verified_names: [String.t()] }
A parsed note: the signed text (including its final newline), every
well-formed signature line, and — after open/2 — the names of the
verifiers whose signatures verified.
Functions
@spec cosign(binary(), [SignedNote.Signer.t()]) :: {:ok, binary()} | {:error, SignedNote.Error.t()}
Adds signatures to an already-signed note, preserving its existing ones.
This is the witness workflow: a log publishes a checkpoint, and witnesses countersign the identical text so that clients can require several independent attestations of the same tree head. The text is not re-rendered, so a cosigned note keeps the log's original bytes.
Signatures naming a key already present in the note are dropped rather than duplicated, matching the reference implementation's behavior of skipping repeated signatures from one key.
iex> {:ok, log} =
...> SignedNote.Signer.from_ed25519_seed("log.example", String.duplicate(<<1>>, 32))
iex> {:ok, witness} =
...> SignedNote.Signer.from_ed25519_seed("witness.example", String.duplicate(<<2>>, 32))
iex> {:ok, note} = SignedNote.sign("checkpoint\n", [log])
iex> {:ok, cosigned} = SignedNote.cosign(note, [witness])
iex> {:ok, opened} =
...> SignedNote.open(cosigned, [
...> SignedNote.Signer.verifier(log),
...> SignedNote.Signer.verifier(witness)
...> ])
iex> Enum.sort(opened.verified_names)
["log.example", "witness.example"]
@spec open(binary(), [SignedNote.Verifier.t()]) :: {:ok, t()} | {:error, SignedNote.Error.t()}
Parses binary as a signed note and verifies it against verifiers.
Returns {:ok, note} with verified_names listing every verifier whose
signature verified, or an error:
Every failure is a SignedNote.Error whose :reason is one of
:malformed, :note_too_large, :too_many_signatures,
:ambiguous_verifier, :signature_invalid, or
:no_verifiable_signature.
Verification follows the reference implementation's semantics exactly: signatures are processed in order; after a key's first signature, further signatures naming the same key and ID are skipped without verification.
@spec parse_unverified(binary()) :: {:ok, t()} | {:error, SignedNote.Error.t()}
Parses the structure of a note without verifying any signature.
The spec requires ignoring a note's text until a trusted signature
verifies; use open/2 for anything except debugging and inspection.
@spec sign(String.t(), [SignedNote.Signer.t()]) :: {:ok, binary()} | {:error, SignedNote.Error.t()}
Signs text with each signer and renders the complete note.
The text must be non-empty, valid UTF-8, free of control characters other than newline, and must end with a newline. Signature lines are appended in the given signer order.
iex> {:ok, signer} =
...> SignedNote.Signer.from_ed25519_seed("example.com/k", String.duplicate(<<7>>, 32))
iex> {:ok, note} = SignedNote.sign("hello\n", [signer])
iex> {:ok, opened} = SignedNote.open(note, [SignedNote.Signer.verifier(signer)])
iex> opened.verified_names
["example.com/k"]