SignedNote (signed_note v1.0.0)

Copy Markdown View Source

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

Types

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

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

t()

@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

cosign(binary, signers)

@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"]

open(binary, verifiers)

@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.

parse_unverified(binary)

@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.

sign(text, signers)

@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"]