luxfi/threshold v1.12.7 added FROST(Ed25519, SHA-512), so Solana and TON finally
have a signable curve. The sessions named "EdDSA" were calling KeygenTaproot —
BIP-340 over secp256k1 — whose key is also 32 bytes, sails through the
len(EDDSAPubKey)==32 gate, and base58-encodes into a real-looking Solana address
the ring can never sign for.
⚠ A LIVE WRONG-ADDRESS BUG went with it: handlers_wallets served
addresses["ton"] = *wallet.SolAddress. A TON address is not a base58 pubkey — it
is workchain plus a hash of the wallet contract's StateInit, base64url with a
CRC16 — so the API was handing out an address that decodes to nothing. Deleted
rather than guessed; TON returns when its encoding is real.
The keygen tests used to call frost.KeygenEd25519 directly, which validated the
LIBRARY and not the daemon: reintroducing KeygenTaproot left every package
green. They now go through the production session, and so does a test that
constructs the real signing session and proves the caller's bytes reach the
protocol unhashed. Both original bugs reintroduced together fail 11 tests.
secp256k1 keygen changes from fail-open to fail-closed, and that is the one
behavioural change to the existing EVM/BTC path. An empty pubkey previously
meant any of nil config, marshal failure, kvstore.Put failure or keyinfo-save
failure — and was returned as SUCCESS, announcing addresses while fewer than
t+1 nodes hold a share. Applying fail-closed to Solana while leaving the
higher-value leg fail-open is incoherent.
Also: the KMS share classifier still keyed off the retired "frost:" prefix; the
CGGMP21 session published its own local failure onto the same first-wins subject
as the ring's verdict, so one node's failure could beat the ring's success; and
one 120s deadline shared across two sequential ceremonies starved the cheap leg.
NOT YET SPENDABLE. ConsensusKeyInfoStore.Save is the only RegisterKey caller and
hardcodes "secp256k1", and TriggerSign reads the curve from that field, so no
product entry point can reach Ed25519 signing. The address is correct; signing
it needs a wire-contract change shared with lux/wallet.
The image lane failed at the private-module fetch, and the module it could not
reach is one the binary does not contain:
fatal: Authentication failed for 'https://github.com/hanzoai/replicate/'
$ go mod why -m github.com/hanzoai/replicate
(main module does not need module github.com/hanzoai/replicate)
It is in go.sum and in no require. luxfi/hsm is the same shape — imported only
by cmd/mpcd/airgap_command_test.go, so it never appears in
`go list -deps ./cmd/mpcd`. Both were being fetched because the step said
`go mod download all`, and `all` means the entire module graph rather than the
build.
So the release was gated on reaching private repositories that contribute
nothing to the artifact, and the three failures before this one — an empty
UNIVERSE_PAT, a token inside a URL, a base64 header — were all attempts to make
that unnecessary fetch succeed.
Bare `download` takes the main module's own requirements, which is what the two
builds compile; GOFLAGS=-mod=mod records any sum still missing. The credential
stays for the private modules that ARE linked.
Two shapes failed here in turn, and each failed by a different mechanism:
in a URL, any byte the grammar ends on truncates it — git then read the
credential prefix as a hostname ('Could not resolve host: x-access-token');
in an Authorization header, the value is base64, and when that produces
nothing git offers NO credential at all and asks for a username
('could not read Username for https://github.com: terminal prompts disabled').
A credential helper hands over username and password as themselves. Nothing is
parsed, nothing is encoded, so neither failure has a way in.
The private-module fetch put the token in the URL, so any byte the URL grammar
ends on truncated it and git read the credential prefix as a hostname:
fatal: unable to access 'https://x-access-token:***@github.com/…':
Could not resolve host: x-access-token
Both existing defences were already in place — the workflow strips \r\n from the
secret, and command substitution eats a trailing newline — and it broke anyway.
That is the argument against the shape rather than against the value: a URL is
the wrong container for bytes that are not ours to control. luxfi/node hit this
same failure.
http.extraheader carries the credential as a header, so nothing the token
contains can change how the address parses. The value is base64 of
'x-access-token:<token>', which is what actions/checkout itself sends.
Keygen reached round 2 and every node refused every message, including from
legitimate peers, so each attempt sat until the 300s timeout and every crypto
deposit answered 503 "the custody service is not accepting new addresses".
The handler admits a message only from an id in its OWN party set, and the
fleet had built three sets that disagreed:
partyIDs=["mpc-node-0:keygen:1","mpc-node-1:keygen:1","peer-2:keygen:1"]
while node-2 called itself mpc-node-2:keygen:1. Nobody answers to "peer-2", so
its share never arrived and the ceremony could not complete.
The synthetic name is what an unprefixed --peer produces: the deployment passes
addresses only, self is dropped by address, and everything left took a
POSITIONAL id. The comment above that line already said such an id "cannot
match a real node id by construction" — it was describing this bug without
following it through.
So a peer is now named the same way self is recognised: the first DNS label,
which IS the node id under StatefulSet naming
(mpc-node-2.mpc-node-headless.ns.svc.cluster.local -> mpc-node-2). Deriving both
sides by one rule is what makes the sets agree.
A bare IP has no label to read and keeps the positional id — the caller must
then use nodeID@host:port, which states the name outright. The test asserts the
invariant that was violated rather than the helper's return value: the id a node
derives for a peer must equal the id that peer uses for itself.
The .network host was retired 2026-07-27 and no longer resolves at all, so every
reference to it named an address that cannot answer. The brand host is .cloud,
matching kms.zoo.cloud and the rest of the white-label convention.
Left alone deliberately, because rewriting them would invert what they say: the
LLM.md line that RECORDS the retirement, the e2e spec that ASSERTS
kms.lux.network must not resolve, and the recorded applies + cluster backups,
which are faithful accounts of what was applied and are not config.
go.work names ../threshold, a sibling checkout. Correct for a developer,
impossible in a container — the build context is this repo alone, so the
path resolves to /threshold and `go mod download all` dies before anything
compiles. Every containerized build has failed that way since the entry
landed: v1.17.16 and v1.17.17 are tags with NO image, and the fleet still
runs v1.17.15, so two releases' worth of fixes never shipped and nothing
said so.
GOWORK=off rather than deleting the entry — the workspace is a local
convenience; the build resolves the published graph, which go.mod already
pins at github.com/luxfi/threshold v1.12.5.
Co-authored-by: Hanzo Dev <[email protected]>
KEYGEN has been dead on the hanzo-mpc fleet — every request answered
503 "peers not ready" while /healthz reported `signing_quorum: true`
beside `ready: false`, and the counters said `ready=5 expected=4` in a
THREE node fleet. Both halves of that arithmetic were wrong.
SELF WAS COUNTED TWICE. A StatefulSet renders one command for every
replica, so the natural spelling hands each node the whole ensemble —
including itself. `peerMap[nodeID] = listenAddr` adds self by id, and the
matching `--peer` then adds it AGAIN under a synthetic `peer-N` id (an
unprefixed address gets a positional id by construction, so it can never
match a real node id, and filterSelf — which compares ids — could never
see it). Three nodes therefore reported four expected members. mpcd now
drops a --peer that names this node, matched by address: equal to
--listen, or a host whose FIRST DNS LABEL equals the node id (compared
whole, so mpc-node-10 is never mpc-node-1).
AND THE GATE WAS AN EQUALITY. `readyCount == len(peers)+1` reads an
OVERSHOOT as not-ready, so once the count passed the expected total
nothing could bring it back — permanently, across restarts. Signing
survived only because it already used >=. A readiness gate must fail
closed on too few, never on too many; both sites are >= now, so the
double-count could not have wedged keygen even before the fix above.
Verified by exercising isSelfAddr over the fleet's real addresses: the
live 3-node template on mpc-node-0 keeps 2 peers, expected total 3.
Co-authored-by: Hanzo Dev <[email protected]>
The build job dies at `go mod vendor`:
cloud.google.com/go/kms/apiv1: missing go.sum entry for module providing
package cloud.google.com/go/kms/apiv1 (imported by github.com/luxfi/mpc/pkg/hsm)
pkg/hsm has aws/azure/gcp files behind build tags. `go build ./...` skips them,
so the tree looks fine, but `go mod vendor` resolves EVERY tag and needs hashes
for all of them — kms, secretmanager, pkcs11, kmip-go and their transitives.
`go mod download all` adds them. go.mod is UNCHANGED and the diff is 514
additions, 0 removals: no version moved, only hashes that were always required
got recorded.
⚠️ This does not turn the job green on its own. One blocker remains and it is the
one the repo already documents in cc/attest/alias.go — "mpc cannot bump [threshold]
yet because mpc/pkg/mpc still imports threshold/protocols/tfhe, which that same
threshold branch removed":
github.com/luxfi/threshold/protocols/tfhe: no required module provides package
Measured: [email protected].5, the version go.mod PINS, does not contain it either.
So `go build -tags experimental_tfhe ./pkg/mpc/` already fails — the experimental
TFHE path cannot compile under any configuration, not merely under vendor. Three
files import it, all behind //go:build experimental_tfhe:
pkg/mpc/tfhe_session.go
pkg/policy/fhe_threshold_decryptor.go
pkg/policy/fhe_threshold_decryptor_test.go
Removing an experimental feature is an owner decision, so they are left alone.
go build ./... OK
go test ./pkg/wallet/ ok
Co-authored-by: Hanzo Dev <[email protected]>
The previous pass ran plain goimports and left three files that golangci still
rejects:
pkg/api/zap_kms_server_test.go:16:1: File is not properly formatted (goimports)
.golangci.yml configures
goimports:
local-prefixes: github.com/luxfi/mpc
which puts github.com/luxfi/mpc imports in their own group after third-party.
Plain goimports does not do that and reported the tree clean, so the lint kept
failing on files a local run called fine. Re-run with -local.
goimports -l 0
goimports -local github.com/luxfi/mpc -l 3 -> 0
go build ./... OK
go test ./pkg/api/ ok
Co-authored-by: Hanzo Dev <[email protected]>
golangci-lint failed the lint job on
pkg/wallet/usage.go:53:1: File is not properly formatted (goimports)
goimports over pkg/, cmd/ and internal/ finds SIXTEEN such files. The linter
reports the first and stops, so fixing only the named one buys exactly one more
run before it names pkg/api/server.go, and so on for sixteen round trips.
All of them are alignment-only: gofmt aligns a var block's `=` to its longest
name, and these blocks had been edited without re-aligning. No import was added,
removed or reordered.
The dashboard/ work in this tree is untouched; all sixteen were clean.
goimports -l ./pkg ./cmd ./internal 16 -> 0
go build ./... OK
go vet ./pkg/... ./cmd/... OK
go test wallet/policy/registry/transport/zap all ok
Co-authored-by: Hanzo Dev <[email protected]>
With the lockfile resynced the docs job got past install and failed at build:
Module not found: Can't resolve './.source' (from lib/source.ts)
.source is GENERATED and gitignored, and this package had no step that produced
it — `build` was a bare `next build` and there was no postinstall. It only ever
worked where a stale .source happened to be lying around, which is why a laptop
built it and a fresh checkout never could.
Three things, each found by making the previous one fail differently:
1. Nothing generated .source. Now `hanzo-mdx source.config.ts .source` runs in
both build and postinstall, the shape luxfi/consensus already uses.
2. `import { docs } from "@/.source"` is a DIRECTORY import and the generator
emits no index — it writes browser.ts / dynamic.ts / server.ts. consensus
imports "@/.source/server"; so does this now.
3. `docs.toFumadocsSource()` did not exist, and neither did `toSource()` — the
generated code targets `@hanzo/docs/mdx/runtime/server`, while lib/source.ts
pulled its loader from `fumadocs-core`, mixing two stacks. It resolved
@hanzo/docs 16.2.7 where consensus runs 16.4.x and works. Loader now comes
from `@hanzo/docs/core/source` with `toSource()`, and the range is pinned
^16.4.1 to match consensus rather than left to drift.
Verified as a fresh checkout would run it — node_modules and .source deleted
first:
pnpm install --frozen-lockfile OK
.source after install generated by postinstall
pnpm build OK
Only docs/ is touched; the dashboard/ work in this tree is left alone.
Co-authored-by: Hanzo Dev <[email protected]>
The docs workflow died at install:
ERR_PNPM_OUTDATED_LOCKFILE … specifiers in the lockfile don't match
specifiers in package.json:
* 1 dependencies were added: geist@^1.3.0
geist went into docs/package.json without the lockfile being regenerated, and
`pnpm install --frozen-lockfile` — the default in CI — refuses that.
Only docs/pnpm-lock.yaml is touched; the dashboard/ work in this tree is left
alone.
pnpm install --frozen-lockfile exit 0 (was ERR_PNPM_OUTDATED_LOCKFILE)
pnpm build exit 0
Co-authored-by: Hanzo Dev <[email protected]>
Currency bump: golang builder images -> 1.26.5 (latest stable).
Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.
No go.mod go directive was changed. Builder images move; directives stay.
Co-authored-by: Hanzo Dev <[email protected]>
Every job in all seven workflows named `runs-on: lux-build-amd64`, except
build.yml's arm64 leg, which named `["self-hosted","linux","arm64"]`. No runner
on this forge carries any of those labels — the pools are
`lux-build-linux-<arch>` — and a job naming a label nothing carries does not
fail: it waits. ci.yml additionally fired on `branches: ["*"]`, so every push to
every branch queued the whole file, CodeQL and SBOM included.
What survives: test, lint, govulncheck and build move to
.hanzo/workflows/ci.yml, scoped to main and pull requests.
docker.yml and release.yml merge into .hanzo/workflows/release.yml. Both fired
on the same `v*` tag with no shared concurrency group, which is how a tag ends
up half-published; they are now one workflow — per-arch image, manifest, Go
binaries, GitHub release, notify — under one group that does not cancel. The
manifest step also proves the composed tag is pullable before the job goes
green, so a pin can never point at a tag that resolves to nothing.
Two auth paths had to change or the release could not work off GitHub:
* ghcr login used `github.actor` plus the instance GITHUB_TOKEN, which here is
a Hanzo Git token with no rights at ghcr.io. It now uses GHCR_USER and
GHCR_TOKEN, the org Actions secrets sourced from KMS.
* the luxfi/hsm module token is written to RUNNER_TEMP with its trailing
whitespace stripped before BuildKit mounts it. Dockerfile:50 interpolates it
into a URL, so one trailing newline truncates the URL there and git resolves
the credential prefix as the host ("Could not resolve host: x-access-token").
luxfi/node hit exactly this.
* the universe notification used peter-evans/repository-dispatch, which
resolves GITHUB_API_URL — on this forge that is the forge's own API, where a
github.com PAT does not authenticate and luxfi/universe's dispatch endpoint
does not exist. It now calls api.github.com explicitly.
Deleted rather than migrated:
* e2e-tests.yml and benchmarks.yml. Both trigger on `push: branches: [master]`
while the default branch is main, so neither has fired. Both also build
`./cmd/lux-mpc`, which is not in this repo (cmd/ holds mpc and mpcd), and
drive `e2e/docker-compose.test.yaml`, which does not exist either (the file
is e2e/compose.test.yml). Three independent reasons each; they have never
run and could not have passed.
The e2e SUITE is real and now has no CI. It needs repair at the source
before it can have any: e2e/*.go shells out to `lux-mpc` and `lux-mpc-cli`,
which no longer exist, and to `docker-compose.test.yaml`, which is named
compose.test.yml; e2e/Makefile expects `mpc` and `mpc-cli` on PATH and
mpc-cli is not built either. That is a repair, not a migration, so it is not
smuggled in here.
* build.yml. It pushed a floating `:latest` and branch-named tags on every
push to main/test/dev, against the semver-only policy the release workflow
holds to, and its arm64 half named a pool that does not exist.
* the codeql-analysis, sbom and security-scan jobs. All three exist to upload
SARIF to the GitHub Security tab via github/codeql-action/upload-sarif.
There is no Security tab on this forge, so they compute results with nowhere
to put them. govulncheck stays in CI, reporting into the job log.
docs.yml's build survives as .hanzo/workflows/docs.yml; its publish does not.
configure-pages, upload-pages-artifact and deploy-pages speak to the GitHub
Pages API, which this forge does not have. The docs still fail the build that
breaks them; they are no longer published from CI until the site is served from
the PaaS.
All seven files are accounted for, which is the constraint: the forge stops at
the first workflow directory that exists, so a repo's entire .github/workflows
suite goes dark the moment .hanzo/workflows appears.
Co-authored-by: Hanzo Dev <[email protected]>
The GOPRIVATE list carried twelve named luxfi modules because they were private
and 404 on proxy.golang.org: bridge, broker, compliance, erc20-go, fasthttp,
graph, hsm, indexer, mpc, ordering, password, pool. All twelve are open source
now, all twelve serve from the proxy, so the exceptions have nothing left to do.
GOPRIVATE disables the module proxy AND checksum verification together, so every
name on that list was a module Go fetched from git and trusted unverified. The
list is now exactly the orgs that are actually private, and every luxfi module —
all ~150 of them — verifies against sum.golang.org.
Co-authored-by: Hanzo Dev <[email protected]>
These hashes were recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the module proxy and checksum
verification together. Tags moved afterwards, so what is written down here no
longer matches what sum.golang.org holds and the build refuses to verify.
proxy.golang.org still serves the originally-published bytes and those verify
against the log, so dropping only the disagreeing lines and re-tidying restores
a verified module graph with no version change. Lines that agree are untouched;
this is not a regenerated go.sum.
The failure followed the machine rather than the repository — the same commit
built wherever the module cache was clean. Any machine or CI runner that
fetched luxfi modules under the old GOPRIVATE holds the same poisoned entries.
Co-authored-by: Hanzo Dev <[email protected]>
Measured on the rendered pixels, not the source: the MPC dashboard's home page
carried 1.05% of every frame in hue at mean chroma 1.279, against explore's
0.004 next door. It came from four places, all literals no hostname could
retheme:
app/page.tsx violet→blue gradient wordmark (from-violet-500 to-blue-500)
feature headings text-violet-400 / text-blue-400 /
text-emerald-400, and ten chain chips on border-violet-500/20
bg-violet-500/10 text-violet-300 — the #ece7f7 wash that was
10,776 pixels of one screenshot
app/docs/page.tsx the same violet, plus border-violet/blue/emerald/purple-500/30
and 15 more files reaching for Tailwind's chromatic ramps directly. Those now
resolve through the tokens @luxfi/ui owns: green/emerald → --color-status-good,
red/rose → --color-status-bad, yellow/amber → --color-status-warn (achromatic —
attention is the loudest neutral in Lux, not a hue), everything else onto
foreground / muted / border.
After: warm 0%, tinted-neutral 0%, mean chroma 0.000 at 1440x900 and 390x844,
in light and dark. Distinction is carried by the LABEL, which already read
CGGMP21 / FROST / Bridge.
@luxfi/ui 7.4.5 -> 7.4.9.
Co-authored-by: Hanzo Dev <[email protected]>
Three files here restated the same OIDC wiring that lux/market restated too, and
that lux/explore restated a fourth, PKCE-less way. `@luxfi/ui/[email protected]` is
that flow, once — `AuthProvider` (identity read + PKCE login, bound to the host)
and `AuthCallback` (state check, verifier, code→token).
What is left here is the only part that is MPC's: the middleware session flag,
and the MPC API's own JWT minted from the IAM token. That exchange stays
DOWNSTREAM — the user is signed in whether or not the MPC API answers.
Behaviour is unchanged: same `/auth/callback` path, same `lux-mpc` client
resolved from the host, same landing on /dashboard. `tsc --noEmit` reports only
the pre-existing `globals.css` side-effect-import error.
Co-authored-by: Hanzo Dev <[email protected]>
The custody dashboard drew its own tick twice: `M5 13l4 4L19 7` inline in the
wallet-created panel and again in the Safe deploy wizard's step rail. That path
is Heroicons' outline check, a third set's geometry sitting on a screen whose
every other glyph comes from @luxfi/ui. Same 24x24 box, different drawing —
different corner radius on the vertex, different stroke terminals, no round cap.
Both are now `Check` from `@luxfi/ui/icons`, which is Lucide, the set @hanzo/gui
standardises on. The size classes are unchanged (h-3.5 and h-4), the glyph
inherits `currentColor` from the emerald-tinted disc it sits in exactly as the
inline SVG did, and `next build` is clean.
Chain badges in components/common/chain-icon.tsx keep their hue: they are
third-party identity, not Lux chrome, per lux/brand DESIGN.md 1.2.
Co-authored-by: Hanzo Dev <[email protected]>
The dashboard had its own header, its own user menu (components/layout/user-menu.tsx)
and its own branding table (lib/branding.ts) that mapped a hostname to a brand a
second time — a copy of what @luxfi/ui/white-label already answers, and one that
drifted (it had no bootno.de row and pointed zoo at the wrong issuer).
Both are deleted. The nav is now `AppNav` from @luxfi/ui: brand, org switcher,
links, settings and the user menu, with the links collapsing into the same gui
Sheet the switchers use instead of the old `hidden md:flex` that simply vanished
on a phone with nothing to replace it.
Identity comes from IdentityProvider (IAM: get-account / get-organizations /
get-organization-projects). Credentials stay with @hanzo/iam — one PKCE flow and
one token store per surface; the chrome borrows only signIn and signOut.
`next build` passes: 60 routes.
Two defects found by capturing the console against production.
1. Every custody write path was dark because a VALID IAM token was
rejected. fetchUserInfo built <issuer>/oauth/userinfo, which the SPA
shell answers 200 text/html; the JSON decode then failed on '<' and
surfaced as 401 "failed to decode userinfo: invalid character '<'".
The issuer serves userinfo at /v1/iam/oauth/userinfo (401 JSON when
unauthenticated, as it should). The provider allowlist keys on the
issuer ORIGIN, so the /v1/iam prefix cannot be supplied by the caller
and belongs in this function.
Verified by hand against lux.id: authorization_code + S256 completes,
token endpoint 200, and userinfo at the /v1/iam path returns
{"email":"[email protected]","isAdmin":true,"owner":"lux"}.
2. /wallets/[id]/smart-wallets rendered two invented wallets: a "2-of-3"
Safe at 0x1234...abcd on Ethereum and an ERC-4337 account at
0x5678...efgh on 96369, both green and marked deployed, hardcoded
under a "TODO: fetch from API". They rendered for ANY key id.
A custody screen claiming a 2-of-3 multisig that exists on no chain is
not a placeholder, it is a false statement about where funds are — and
it contradicted lux.cloud's own honest "1 of 1 today, no ring
deployed". The list is now empty until the API is read.
The login page needs no change: it already uses @hanzo/iam startLogin
with PKCE. Production is serving an older build than main, which is why
it still requested the implicit response_type=token that this repo
removed.
Still required and NOT done here: register
https://mpc.lux.cloud/auth/callback on the lux-mpc IAM client, and
deploy. Neither is a code change.
TestEmbeddedDispatcher_BLSRoundTrip and _AuthGate drove "bls". thresholdd
stopped serving that scheme — trusted-dealer threshold-BLS custody was ripped
for security (H-2), and luxfi/threshold v1.12.5's dispatcher exposes exactly
cggmp21, frost, pulsar, corona, magnetar and doerner (pkg/thresholdd/zap_schema.go;
there is no bls.go). So both tests had been failing on every run:
bls.keygen: zapclient: unknown procedure "bls.keygen"
wrong-id: error zapclient: unknown procedure "bls.keygen" does not name unauthorized
The second line is the damaging one. _AuthGate asserts a wrong NodeID is refused
with "unauthorized"; it was instead getting "unknown procedure" back from a
scheme lookup that failed before auth was ever consulted. The auth gate has not
actually been exercised for as long as the scheme has been gone.
FROST is a live production scheme on the same ZAP wire and keygens as fast, so
the calls move to it and every assertion is kept verbatim — pubkey non-empty,
3 shares, non-empty signature, verify true, forgery rejected, wrong NodeID
refused with "unauthorized". Nothing is skipped, softened or dropped; the
round-trip now lands on a surface that exists. _UnknownScheme still drives
"bogus" and still proves the unknown-procedure contract.
Renamed to TestEmbeddedDispatcher_FROSTRoundTrip so the name states what it runs.
go test ./... is green across the module.
Co-authored-by: Hanzo Dev <[email protected]>
pkg/infra blank-imported modernc.org/sqlite, so any binary linking this store
next to another Hanzo store (base/core, orm/db, tasks' shard store — all of
which reach SQLite through github.com/hanzoai/sqlite) registered the "sqlite"
database/sql driver name twice and panicked during init:
panic: sql: Register called twice for driver sqlite
modernc.org/sqlite.init.0() modernc.org/[email protected].0/sqlite.go:57
hanzoai/mpc's mpcd is exactly that binary; `go test ./...` there could not even
start. hanzoai/sqlite is the ecosystem's single registrar of that name (pure-Go
modernc under !cgo, hanzoai/csqlite+SQLCipher under cgo), so this store must go
through it — and so must base and orm, hence base v0.39.1 -> v1.5.8 and orm
v0.5.2 -> v0.6.16, the versions that route through it. mpcd's package graph now
has exactly one importer of a SQLite driver.
The hand-rolled DSN went with it. `?_journal_mode=WAL&_busy_timeout=5000&...`
is mattn syntax and the pure-Go backend this module actually linked dropped it
silently — measured journal_mode=delete, busy_timeout=0, foreign_keys=0, i.e.
no WAL, immediate SQLITE_BUSY under concurrent writers and no FK enforcement.
sqlite.PragmaDSN encodes the pragma set in the ACTIVE backend's syntax, so all
three apply under both backends.
Tests: sqlite_driver_test.go links base/core and orm/db alongside this store, so
a regression to a direct modernc import blows the test binary up in init;
TestSQLiteMeta_PragmasApplied reads the pragmas back off a file-backed WAL
store. Both pass under CGO_ENABLED=0 and CGO_ENABLED=1.
Co-authored-by: Hanzo Dev <[email protected]>
Panic-driven denial of service in the AWS SDK for Go v2 EventStream decoder:
eventstream -> v1.7.8, service/s3 -> v1.97.3 (minimum patched versions, so the
change carries the fix and nothing else).
Verified against a measured baseline: go build ./... exit 0, and go test ./...
is 36 ok / 3 fail BOTH with and without this change — identical, so any failures are
pre-existing and none is a regression from this commit.
Only go.mod/go.sum staged; other working-tree changes in this repo are left
untouched.
Every route in pkg/api is mounted under r.Route("/v1", ...) at server.go:272,
so /auth/oidc is /v1/auth/oidc and trade/submit is /v1/.../trade/submit. Nine
doc comments claimed an /api/v1 prefix that no route has ever carried:
handlers_trade_approval.go submit / approve / reject / pending
handlers_oidc.go auth/oidc
handlers_bridge.go generate_mpc_sig / complete
server.go the health-check note and the trade-endpoint note
handlers_approval.go already states the rule in-tree — "One canonical surface —
no `/api/` prefix, no v2" — so these were drift, not a second convention.
Comments that misdescribe a route are the same failure as a config that
misdescribes a threshold: they are what an audit reads. This is documentation
only; no route, handler or behaviour changes.
Remaining /api/ in this repo is all foreign and stays: pkg/hsm/zymbit.go (Zymbit
HSM device), pkg/hsm/providers.go (AWS IMDS), pkg/risk/chainalysis.go
(Chainalysis vendor). pkg/hsm/kms.go is a separate matter — it is an Infisical
client (/api/v3/secrets/raw with workspaceId/secretPath/type:shared), so moving
it to our native /v1/kms/orgs/{org}/secrets is a port, not a path swap.
Co-authored-by: Hanzo Dev <[email protected]>
api.KeygenResult carried only wallet_id, pubkeys and addresses — no threshold,
no participants. Keygen accepts no per-request threshold (the ring's own
--threshold governs), so a client had no way to learn what it actually got.
That is not theoretical. luxfi/kms pkg/keys/manager.go validates req.Threshold
(>=2, parties >= threshold), never sends it, then stores
`Threshold: blsResult.Threshold, Parties: len(blsResult.Participants)`. Its
client struct already declares both json fields, so with mpcd never emitting
them every validator key set on record reads Threshold:0 Parties:0 while the
caller had asked for 3-of-5 and passed validation. Ask for 3-of-5, silently get
the ring's config, and record zero.
Populate both from what the node is actually configured for: b.threshold, and a
new ConfiguredParticipants() (peers + self, sorted). Configured — not
GetReadyPeersIncludeSelf — because readiness answers "who is up right now",
while a key's party set is a property of the key and must not change because a
pod restarted; sorted so every node reports an identical list for the same key.
Additive and backward compatible: both fields are omitempty, and the KMS client
already decodes them. Clients can now compare against what they requested and
fail closed.
Tests: ConfiguredParticipants covers ring sizes, order normalization,
cross-node determinism, and a peerIDs aliasing guard.
Co-authored-by: Hanzo Dev <[email protected]>
pkg/threshold's init() pushed CGGMP21Scheme and FROSTScheme into the
process-global crypto/threshold registry as a side effect of import. Two
concrete hazards:
1. Shadowing. These adapters' signer keeps the secret nonce scalar k inside
NonceState (k||R). Anything resolving GetScheme(SchemeCMP) would silently
receive that signer; a NonceState crossing a party boundary yields the
private key as x = (s*k - H(m))/r. Opting into that must be deliberate.
2. Startup panic. RegisterScheme panics on a duplicate ID ([email protected].2
threshold/registry.go:24-26), so the day crypto/threshold ships its own
CMP/FROST scheme, merely importing this package kills the process at start.
Replace init() with an exported Register() documenting both hazards. Nothing
regresses: the daemon never reached this code (`go list -deps ./cmd/mpcd` does
not contain pkg/threshold) and the only importers, examples/, use
NewUnifiedThresholdAPI and never touch the registry.
register_test.go pins the invariant: TestImportDoesNotRegister fails if an
ambient init() comes back (verified red/green), TestRegisterIsExplicit proves
explicit opt-in still works.
Co-authored-by: Hanzo Dev <[email protected]>
Both protocol adapters shipped a Verify that returned false unconditionally —
cggmp21 with "For now, return false", frost with "doesn't support ECDSA". That
is not a safe default dressed up as one. A verifier that always fails is
indistinguishable from a verifier that works and was handed a bad signature, so
a caller using it as a signing-path health check sees a permanent, unexplained
failure, and a caller treating "unsupported" as skippable gets no verification
at all.
Both now verify. FROST is Schnorr rather than ECDSA, but its group key is still
a secp256k1 point and *ecdsa.PublicKey is only how this package's interface
carries one, so the point is recovered and handed to the FROST verifier.
pkg/protocol/curvekey.go holds the conversion both adapters need, replacing two
hand-rolled decompressions (a Tonelli-Shanks square root in cggmp21, a separate
helper in frost). CurvePoint goes the other way, and it validates rather than
assumes: an (X, Y) that is not on the curve is REJECTED, not silently corrected.
Compression keeps only the parity of Y, so a conversion that trusts X and
parity alone will happily turn an inconsistent key into a valid point and then
verify signatures against a key the caller never named. X=1 is a real X
coordinate on secp256k1, which is how the test that found this was written.
X is left-padded with FillBytes; big.Int drops leading zeros, so about one key
in 256 would otherwise encode short and fail to convert at all.
Bumps luxfi/threshold to v1.12.5 for pkg/quorum.
Co-authored-by: Hanzo Dev <[email protected]>
mpcd implements only CGGMP21 (secp256k1 ECDSA), FROST (ed25519/ristretto), and
LSS signing sessions — there is no bls12-381 threshold signing session. The KMS
validator "bls" slot is secp256k1/CGGMP21; the "corona" slot is ed25519/FROST.
Neither is bls12-381 aggregation nor post-quantum. A "post-quantum" claim over
classical ed25519 is materially misleading.
- deployments/customer-3of5/topology.yaml: the `bls` entry asserted
curve=bls12-381, protocol=BLS-Threshold for "Lux consensus (validator
signing)". Corrected to secp256k1/CGGMP21 (what is actually custodied) with a
note that mpcd cannot threshold-sign bls12-381 and that Lux consensus BLS is a
native per-validator key, never custodied here. (Customer manifest — highest
priority.)
- pkg/api/handlers_kms.go: the ValidatorKeySet doc called the custodied key a
"Corona PQ-threshold key" — it is ed25519/FROST, not PQ and not the Corona
R-LWE lattice protocol. Corrected.
- README.md: added a "Validator key slots vs. engine protocols" note so the
secp256k1/ed25519 custody slots are not read as the bls12-381/lattice engine
protocols of the same name.
Labeling only — no crypto change. The live 2-of-3 mpcd ECDSA is sound.
Co-authored-by: Hanzo Dev <[email protected]>
The signing sessions emitted a raw, possibly high-S S with a recovery byte that
was then dropped at every serialization boundary. luxfi/evm ecrecover rejects a
high-S signature (EIP-2 "invalid sender"), and without a propagated recovery id
a caller must guess v (wrong guess => wrong sender). This is what broke the KMS
/sign output.
- signing_session.go + signing_session_lss.go: produce the signature through
the shared threshold helper s.signature.SigEthereum() (EIP-2 low-S + recovery
id in ONE place), then self-verify the recovery id against the wallet's own
public key via CalculateRecoveryByte and FAIL CLOSED on mismatch — never emit
a signature that binds to the wrong address.
- pkg/api SignResult: carry V (recovery id "0"/"1").
- cmd/mpcd TriggerSign: propagate V from the signing session's signature_recovery
and assemble the canonical 65-byte r‖s‖v so KMS /sign never returns an empty
signature.
- handlers_kms REST: include v in the response.
Requires the non-mutating, tested SigEthereum in threshold v1.12.4; bumps
luxfi/threshold v1.12.0 -> v1.12.4 (MVS pulls its transitive updates). Builds
GOWORK=off; pkg/api + pkg/mpc tests green.
Co-authored-by: Hanzo Dev <[email protected]>
The luxfi/{age,mpc,pq} tags were force-updated upstream, leaving the
recorded go.sum hashes pointing at pre-retag content. Refresh the
checksums at the SAME pinned versions (no version bumps) so the root
module and the e2e module build. All are GOPRIVATE-covered first-party
modules, so no sumdb authority is bypassed.
Unblocks: e2e/live_http_sign_test.go builds mpcd for the live 3-node
consensus ceremony.
Co-authored-by: Hanzo Dev <[email protected]>
docker buildx build --push emits a per-arch OCI index; docker manifest
create rejects those ('ghcr.io/luxfi/mpc:vX-amd64 is a manifest list').
Both arch images for v1.17.12 built+pushed fine — only the merge step
failed. Switch to 'docker buildx imagetools create' (same as build.yml)
which composes indices correctly.
Co-authored-by: Hanzo Dev <[email protected]>
A post-v1.17.9 commit stripped the BuildKit gh_token secret + GOPRIVATE=hsm
+ GOSUMDB=off + in-build go.sum regen from the Dockerfile, and the
--secret id=gh_token,env=GH_TOKEN (UNIVERSE_PAT) from docker.yml, on the
false premise that all luxfi/* modules are public. luxfi/hsm is PRIVATE
and absent from proxy.golang.org (all versions 404), so the alpine
builder's plain 'go mod download' fell back to unauthenticated direct git
-> exit 128. Every tag since (v1.17.10/11) failed here, independent of the
threshold + dep fixes.
Restore the v1.17.9 Dockerfile verbatim (authed direct git for private
first-party modules, proxy for public, regenerate-not-bypass go.sum) and
re-add the secret to docker.yml, preserving the ARC lux-build-amd64
routing. Combined with the coherent dep graph (geth v1.17.12), this is
the full green-build set for the CGGMP21 threshold fix.
Co-authored-by: Hanzo Dev <[email protected]>
The luxfi module ecosystem was re-tagged: luxfi/geth wiped its entire
v1.16.x line (v1.16.77, v1.16.98 gone) leaving only v1.17.12, and
luxfi/pq v1.0.3 was re-published. This broke `go mod download` for every
dependent pinning the old graph — including this repo, so v1.17.10 (the
threshold fix) failed to build even though its go.mod was untouched.
Move onto the coherent post-re-tag set: crypto v1.19.27 -> precompile
v0.5.60 -> geth v1.17.12 (all existing tags), regenerating go.sum. mpc
imports 0 geth and 0 precompile packages directly, so this is a pure
transitive-graph repair; threshold stays v1.12.0 (default build needs it;
the experimental_tfhe path is build-tag gated and never compiled in the
Docker image). Verified: go mod download clean, readonly build of
./cmd/mpc + ./cmd/mpcd compiles with no missing go.sum, threshold unit
test green.
Co-authored-by: Hanzo Dev <[email protected]>
SECURITY: every wallet keyed by these rings was a 1-of-n key — any single
node's share could produce a valid signature, with no threshold protection
against single-node compromise. This affects real value: the bridge-mpc
`bridge-oracle` (LUX↔ZOO corridor signer), zoo-treasury-v1, and the
hanzo-dao wallet were all keyed at CGGMP21 polynomial degree 0.
Root cause: two disconnected "threshold" values. The --threshold flag
(c.Int("threshold")) drove only the HasSigningQuorum health gate and status
display. The keygen DEGREE was read separately from viper key
"mpc_threshold" (pkg/eventconsumer → CreateKeyGenSession → cmp.Keygen), which
nothing ever set — and mpcd never calls config.InitViperConfig, so
viper.AutomaticEnv was off and even an MPC_THRESHOLD env would not have been
read. viper.GetInt("mpc_threshold") was therefore always 0 → degree 0 →
1-of-n.
Fix: bridge the flag to the keygen degree at consensus start via
viper.Set("mpc_threshold", keygenDegreeForThreshold(threshold)). --threshold
is the number of signers required (matches HasSigningQuorum); cmp.Keygen
needs degree+1 signers, so degree = threshold-1. --threshold=3 → degree 2 →
3-of-5; --threshold=2 → degree 1 → 2-of-3. keygenDegreeForThreshold is a
named, unit-tested function so the security-critical off-by-one is explicit.
Takes effect for NEW keygens only; existing 1-of-n wallets must be re-keyed
to gain threshold security (the live bridge-oracle needs a coordinated
migration — do not re-key in place).
Co-authored-by: Hanzo Dev <[email protected]>
Follow-up to #19 (emit 33-byte compressed keygen pubkey). pubKeyToEVMAddress's
case-33 assembled the EVM preimage as append(x.Bytes(), y.Bytes()...). big.Int.
Bytes() drops leading zero bytes, so a coordinate < 2^248 (X or Y with a high
zero byte, ~1/256 of keys) produced xyBytes<64 → the len!=64 guard returned an
EMPTY address for an otherwise-valid key. Now FillBytes zero-pads each coordinate
into a fixed 32-byte slice (case 32 already did this; case 33 did not — it was
latent until #19 made keygen actually emit 33-byte keys).
Verified: pubkey 03·005056e0… (X has a leading 00 byte) → old path xyBytes=63 →
empty; fixed path xyBytes=64 → 0x7242EFb11FB1A669486F10649C9B73739731a5e6, byte-
identical to luxfi/crypto DecompressPubkey+PubkeyToAddress.
Co-authored-by: hanzo-dev <[email protected]>
The CGGMP21 keygen session serialized the group public key as the bare 32-byte
X-coordinate (PublicPoint().XScalar()), dropping the Y parity. pubKeyToEVMAddress
and pubKeyToBtcAddress then GUESS even-Y (0x02 prefix) when handed a 32-byte key,
so for every ODD-Y key (~50%) they derive the WRONG address — the sibling curve
point — which does NOT match the key the ring actually signs with. A threshold
signature over a digest then recovers to a different address than keygen reported,
making the wallet unusable (funds sent to the reported address are unspendable).
Fix: serialize PublicPoint().MarshalBinary() (33-byte compressed, 0x02/0x03 || X)
— exactly what signing_session_lss.go already uses for recovery — so keygen's
address == the signing key's address. pubKeyToEVMAddress (case 33),
pubKeyToBtcAddress (case 33), and CalculateRecoveryByte all already accept the
33-byte compressed form; the 32-byte path stays only as a legacy fallback.
Root-caused live: direct ring keygen returned evm_address 0x84e0…f894 for
ecdsa_pub_key X=5b0e9ad0…; a direct threshold sign recovered pubkey 03·5b0e9ad0…
(odd Y) → real address 0x10B8…15A9. The +1 parity bit is the whole bug.
Co-authored-by: hanzo-dev <[email protected]>
go.mod requires go 1.26.4 (and cc v0.3.0 requires >= 1.26.4). The alpine
golang image pins GOTOOLCHAIN=local, so 1.26.3-alpine refuses to build:
'go.mod requires go >= 1.26.4 (running go 1.26.3; GOTOOLCHAIN=local)'.
Match the base image to the go directive — hermetic, no toolchain download.
Two fixes that make the clean-proxy Docker image build of main succeed:
1. luxfi/cc is now a PUBLISHED public module (immutable proxy+sumdb).
Pin v0.3.0 (converged 6-vendor attestation verifier) and drop the local
'=> ../cc' replace so the Docker 'COPY . .' context (no ../cc) resolves
cc from the proxy. Also unblocks luxfi/dkg (dealerless threshold dep),
now public.
2. go.sum had two poisoned zip hashes (luxfi/age v1.4.0, luxfi/pq v1.0.3)
recorded from re-tagged 'direct' GitHub instead of the immutable
proxy+sumdb — the clean-proxy Dockerfile rejected them (SECURITY ERROR).
Restored the authentic sum.golang.org values.
Verified (fresh modcache, immutable proxy+sumdb, GOWORK=off, default tags):
go build ./... = 0, go test ./cmd/... ./pkg/... = 33 ok.
(2 BLS-dispatcher + 1 CAS-race failures pre-exist on v1.17.0 from the
threshold v1.12.0 dealerless bump; unrelated to this dep change —
confirmed identical failure with cc reverted to ../cc.)