Skip to content

Audit hardening: Ed25519 key validation and input-validation fixes - #1

Open
mellowcroc wants to merge 7 commits into
mainfrom
audit/ed25519-key-validation
Open

Audit hardening: Ed25519 key validation and input-validation fixes#1
mellowcroc wants to merge 7 commits into
mainfrom
audit/ed25519-key-validation

Conversation

@mellowcroc

@mellowcroc mellowcroc commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Security-audit hardening series for the MPC ceremony and its input-validation
surface. Commits (oldest first):

  • Reject unusable Ed25519 identity public keys — validate identity keys as
    canonical, non-small-order curve points via filippo.io/edwards25519, closing
    a universal-forgery vector (a small-order key verifies signatures for any
    message) and a fingerprint-duplicate evasion via non-canonical encodings.
  • Accept more than two audits in a production decision — relax the decision
    quorum to permit additional independent audits.
  • Keep on-curve validation when skipping subgroup checks — re-add
    IsOnCurve() on the streaming proving-key decode path that opts out of
    subgroup checks.
  • Report chain replay progress on stderr — surface progress during the
    otherwise-silent multi-hour chain replay.
  • Raise the CLI error redaction candidate floor — tighten argument-value
    redaction so short values can't leak.
  • Add audit change list and a local ceremony runbook — documentation.
  • Reject untrimmed whitespace in attested string fields
    ContributionEnvironment.OS/.Architecture and audit findings used a plain
    == "" check, so a single space satisfied "must not be empty"; require the
    trimmed, non-empty form, matching Identity.DisplayName and artifact names.

Identity.Validate checked only that ed25519_public_key_hex decoded to 32
bytes and that public_key_fingerprint matched. It never checked that the
bytes are a usable curve point, so a roster could enrol a small-order
key.

Ed25519 verification computes [-k]A + [S]B and compares the result to R.
When A has small order that equation collapses: the signature R =
identity, S = 0 verifies against every message. Anyone can then forge
signatures for that identity without holding a private key, which voids
every signature-based control for whichever role holds it. A coordinator
who authors participants.json could plant such a key for a public
witness and manufacture the receipts that exist to detect coordinator
equivocation.

Validate now rejects three cases: bytes that are not a curve point,
non-canonical encodings, and points of small order. The canonical check
matters on its own because identity uniqueness across the definition is
enforced on public_key_fingerprint, a hash of these exact bytes, so two
encodings of one point would otherwise register as two distinct
identities.

The guard lives in Identity.Validate, so it also covers the roles
enrolled outside the ceremony definition: EnrollmentRecord,
PublicWitnessReceipt and ImmutableMirrorReceipt each validate their
embedded Identity.
Three layers disagreed on how many audits a ceremony may have. A
definition may enrol two or more auditors (definition.go). SignRelease
accepts two or more signed passing reports (audit.go). ProductionDecision
demanded exactly two.

A ceremony that enrolled three auditors, which is permitted and strictly
more conservative, could therefore produce a valid signed release that
could never be recorded in a valid decision. The failure surfaces at
final GO signing, after the ceremony is complete and nothing can be
redone.

Both audit lists now require at least two rather than exactly two.
Distinctness of auditor key ids and external signer fingerprints moves
from a hardcoded comparison of elements 0 and 1 to a set check across
every element, so the rule still holds for longer lists.
KeySource.open decodes the G1 singletons (alpha, beta, delta) and the G2
singletons (beta, delta) with NoSubgroupChecks. Skipping the subgroup
check is a deliberate throughput trade on a proving key that callers are
expected to digest-authenticate first, and internal/msmengine makes the
same trade. The difference is that msmengine still runs IsOnCurve on
every decoded point, and streampk ran no validation at all.

That gap matters because OpenKeyURL reaches this code over HTTP range
requests, and the URL caller in cmd/wasm-prover does not digest the
proving key before opening it. A point that parses but is not on the
curve therefore entered a multi-scalar multiplication unchallenged.

IsOnCurve is cheap relative to the decode and is now applied to all five
singletons. This does not close the missing digest verification on the
URL path, which needs a separate change.
A K=21 phase close replays every accepted contribution before it writes
anything, which runs for hours and produced no output. An operator could
not tell a running replay from a hung one, and could not measure how
long a close takes on their hardware.

That measurement is not a convenience. The closure commits to a future
drand round, and choosing a round far enough ahead requires knowing how
long the replay will take. Misjudging it is what caused the 2026-07-24
closure-timing incident. The current code fails loudly in that case
rather than publishing an invalid closure, but the operator still burns
the attempt with no better information for the retry.

internal/mpcceremony deliberately has no logger: it handles signing keys
and secret contribution state, and having no output path is stronger
than having a careful one. A callback preserves that. ReplayProgress
carries a phase, a one-based index and a total, never a path or key
material, and rendering is the caller's business.

PhaseTranscriptPaths carries the optional callback, which reaches every
replay site already threaded through that struct. The CLI writes to
stderr, never stdout, which is reserved for the result contract. Single
head loads pass nil because they read one record rather than replaying.
redactCLIError blanks argv-derived strings out of error messages before
printing, because arguments include signing-key paths. It did so by
substring replacement over the whole message, and accepted any candidate
except the empty string, "-" and "--".

A one or two character argument value, such as a participant index,
therefore blanked unrelated substrings and left the diagnostic useless
exactly when it was needed. Candidates shorter than four characters are
now skipped. The values this must not print, key paths, key ids and
identity names, are all comfortably longer.

Redaction remains defence in depth rather than the control. The control
is that internal/mpcceremony has no print path, so secret material is
never in a position to be written.
Records the findings behind the preceding commits, plus the items that
are not code changes, so a reviewer can see what was checked and what
was left open. Each entry cites the file and line that establishes it,
and separates verified findings from proposals and from items that were
named but not investigated.

The local runbook documents how to build the tool and stand up a
ceremony on one machine. It is orientation and rehearsal only. The
production procedure is docs/mpc-ceremony-runbook.md, which is absent
from main and survives only in refs/pull/34/head of the upstream
repository (item B1). It also records the two roots of trust, the
coordinator public key and the binary, which must arrive over channels
the reader already trusts.

scripts/mpc-demo-init.sh runs the documented init end to end. It builds
with go build rather than go run, because go run omits the VCS metadata
that software.go requires, and it reads the coordinator key id back from
participants.json rather than hardcoding it.
ContributionEnvironment.OS/.Architecture and audit findings used a plain
`== ""` presence check, so a single space satisfied "must not be empty" and
flowed into signed attestations and records. Require the trimmed, non-empty
form, matching the convention already used for Identity.DisplayName and (in
e9a789f) artifact names.
@mellowcroc
mellowcroc force-pushed the audit/ed25519-key-validation branch from 4164355 to dbbfb84 Compare August 13, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant