luxfi/mpc CI publishes ghcr.io/luxfi/mpc; the legacy mpc-api image
is gone. Pinning the SHA-tagged release ensures rollouts are
deterministic (matches operator pattern).
ZAP is the binary protocol — like HTTP=80 and HTTPS=443, ZAP=9999
across the Lux ecosystem. The DNS name (e.g. zap.kms.svc, zap.mpc.svc,
mpc-node-headless.lux-mpc.svc) disambiguates the service; the port is
always the same.
Changes:
- mpcd --listen default :9651 → :9999
- pkg/transport/{transport,factory}.go: comments and examples
- k8s/mpc-statefulset.yaml: ports + peer template
- k8s/network-policy.yaml: ingress/egress allow rules
- cloudbuild.yaml: EXPOSE
- deployments/customer-3of5/{compose.yml,k8s/*,topology.yaml}: full
template sweep — every peer hostname is distinct, port is uniform 9999
- README.md / LLM.md: example commands
luxd validator P2P (9631 mainnet, 9641 testnet, 9651 devnet) is a
DIFFERENT protocol on a DIFFERENT pod and is unchanged.
Consolidating to single canonical ARC fleet. Killing duplicate lux-build
scale set. Hanzo ARC provides identical capacity (max 30 on DO + max 30
ARM on GKE T2A) across hanzo/lux/zoo/liquidity repos — one and only one
way to run self-hosted CI.
No behavior change for this workflow.
Co-authored-by: Hanzo Dev <[email protected]>
v1.1.2 renames EnvProvider default env var from
LUX_MPC_PASSWORD to MPC_PASSWORD. The binary now reads the
canonical name used by the K8s operator injection.
Follow-up to 5215d42. The CLI/CRD `--threshold` flag surfaces the
OPERATOR-facing "minimum signers required" count (e.g. threshold=2 for
a 2-of-3 ensemble, threshold=3 for 3-of-5). The CGGMP21 signing API
internally stores the polynomial degree (one below signers-required)
in keyInfo.Threshold — so the sign code continues to compare
`len(signerPeerIDs) >= keyInfo.Threshold + 1`. Those two surfaces are
off-by-one by protocol library convention.
HasSigningQuorum is operator-facing (used by /healthz) so it uses the
CRD/CLI semantics directly: quorum holds when `readyCount >= threshold`.
Scientist's repro: 3-node 2-of-3 ensemble (CRD threshold=2) with one
peer down — readyCount=2, threshold=2 → quorum holds, /healthz returns
200. With the previous commit the arithmetic was readyCount>=threshold+1
which would have required all 3 nodes up.
Tests updated accordingly (Test_HasSigningQuorum_3Node_2of3 now uses
threshold=2 matching the deployed CRD, Test_HasSigningQuorum_5Node_3of5
uses threshold=3, boundary test covers the new arithmetic).
Survivor nodes in a t-of-n ensemble now report healthy so long as they
still have enough ready peers (including self) to form a signing quorum
for their configured threshold. Previously, ArePeersReady() was the gate
and any single peer loss collapsed availability on the remaining nodes.
Repro (Scientist, 2026-04-20): scale liquid-mpc-mpc-node StatefulSet
from 3 → 2 replicas in the liquidity devnet; surviving pods returned HTTP
503 with log "Peers not ready expected=3 ready=2" even though 2 nodes
are sufficient for the 2-of-3 (threshold=1) signing quorum.
Fix:
- pkg/transport/registry.go: add HasSigningQuorum(threshold) which returns
true when readyCount >= threshold+1 (the CGGMP21/FROST signers-required
count used everywhere in pkg/mpc/node.go).
- pkg/mpc/registry.go: matching method on the legacy Consul-backed
registry plus interface addition to PeerRegistry.
- cmd/mpcd/main.go: /healthz handler switched to HasSigningQuorum(threshold)
for the 200/503 gate. ArePeersReady() is still reported in the JSON
body (as `ready`) for observability. `status` becomes
"healthy" / "healthy-reduced" / "degraded" so operators can see the
in-between state.
- cmd/mpcd/main.go: ConsensusPeerRegistry wrapper delegates the new
method to the underlying transport registry.
Keygen sessions deliberately continue to require ArePeersReady() — a
fresh wallet still needs every participant present. Only the /healthz
gate is relaxed, which matches the availability story for signing.
Tests: pkg/transport/registry_test.go covers
- Test_HasSigningQuorum_3Node_2of3 (the Scientist repro — survivors
still quorum-ok after one peer loss)
- Test_HasSigningQuorum_5Node_3of5 (larger ensemble, f=2 tolerance)
- Test_HasSigningQuorum_ArePeersReady_Decoupled (the two APIs do not
gate each other)
- Test_HasSigningQuorum_BoundaryCheck (pins arithmetic: required =
threshold+1, not threshold)
All 11 test cases pass; transport + mpc package suites clean.
CI lint was failing because ui/dist/ doesn't exist in a clean checkout.
Default build compiles with no embedded assets. Production builds that
want the bundled dashboard must build with -tags embedui after
`pnpm --dir ui build`.
R6 — handlers_treasury_test.go covers the `requireRegulator=true` tier
invariant: when numeric quorum is reached WITHOUT the regulator on a tier
that requires them, handleTreasurySign must return 409 and revert the op
to pending_approval. Tests cover the reject path, the happy-path
finalization with regulator, the control case with requireRegulator=false,
and the create-time validator's refusal of regulator-required tiers on
wallets with no regulator shard.
T3 — ci.yml references `cmd/lux-mpc-cli` in three places (CodeQL build,
SBOM build, and post-CI build). That directory was renamed to `cmd/mpc` in
adaa38b but the workflow was never updated, so CodeQL/SBOM have been
failing on a missing path. Fix all three references to `./cmd/mpc`.
Run: CGO_ENABLED=1 go test -tags json1 -run TestR6 -v ./pkg/api/
Red round 4 flagged (F4, MEDIUM-HIGH) that the previous LAX binding
mode in VerifyLiveness permitted a cross-ceremony replay: an attacker
who observed a fresh WebAuthn challenge could trick SecureGate into
signing a liveness envelope binding to the challenge alone (no
credentialHash), then replay that envelope against any enrollment for
the same userId that happened to reuse the challengeId-only
expectation.
Two LAX-only tightenings in pkg/webauthn/liveness.go (STRICT already
enforced the missing-both rejection; this sharpens LAX):
1. credentialHash is a hard floor. If the server expects
credentialHash and the envelope omits it, reject with
"credentialHash required in LAX mode — envelope without
credentialHash is replay-vulnerable". challengeId alone cannot
satisfy the binding requirement in LAX — challengeId can be
observed and replayed; credentialHash commits to the specific
pubKey being enrolled.
2. When the envelope supplies BOTH credentialHash AND challengeId AND
the server expects both, require BOTH to match. No more "either-or"
loophole. The existing mismatch-rejects guards already caught
individual mismatches; this closes the pathological
both-supplied-one-blank-expected corner.
handleBiometricEnroll always commits ExpectedCredentialHash (derived
from the enrollment pubKey via sha256), so the LAX floor always
engages in practice for WebAuthn enrollment — this hardens what
is already the exercised path.
Test coverage (pkg/webauthn/liveness_test.go):
- TestLiveness_R38_LaxRejectsMissingCredentialHash — primary F4 gate:
envelope with only challengeId, missing credentialHash → reject
- TestLiveness_R38_LaxAcceptsBothMatch — happy path (extended envelope
after SecureGate rollout)
- TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresent
- TestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresent
- TestLiveness_R38_LaxAcceptsCredentialHashOnly — current SecureGate
envelope shape still accepted
- TestLiveness_R38_LaxRejectsMissingBindingWhenCredentialHashExpected —
replaces the old lax-warns-missing path
- TestLiveness_R38_LaxWarnsOnMissing_ChallengeIDOnly — warn path still
available when credentialHash is not expected
Full pkg/webauthn suite green (18/18). pkg/api regression tests pass.
pkg/custody pre-existing failures unrelated to this change (SQLite
json_extract missing, blame predates F4).
LLM.md entry R35 documents the full set. Cut v1.7.1 on merge.
pkg/api/cas_postgres_test.go and the audit-chain concurrency test in
hanzo/kms silently SKIP when no Postgres is reachable. Without a live
sidecar, regressions in the FOR UPDATE + advisory-lock paths would
sail through PR checks — exactly the R3-1 / R2-2 class of bugs the
tests exist to catch.
Add a postgres:15-alpine service, expose TEST_PG_DSN to the test job,
and psql-ping the sidecar before `go test` so the run fails fast if
the service is misconfigured instead of skipping.
One gate, always on, no special-case runbook.
consumeBiometricChallenge was the odd one out: it used SERIALIZABLE
while the two other row-local CAS sites (consumeSessionForSign and
tryApproveOperation) used READ COMMITTED + GetForUpdate. For row-
local CAS the FOR UPDATE row lock already gives us the atomicity we
need; SERIALIZABLE adds cost and forces waiters to abort with 40001
instead of blocking. One pattern, one way.
Aligns with LLM.md's R2-2 note (READ COMMITTED + FOR UPDATE is the
canonical row-local CAS pattern for this repo).
A stolen SecureGate attestation (within its 2-minute validity window)
could be replayed against a different WebAuthn enrollment because the
envelope only attested (userID, score, timestamp) — it said nothing
about WHICH public key the user was actually enrolling.
Fix: extend LivenessAttestation with two optional binding fields:
credentialHash = sha256(publicKey) [base64-std]
challengeId = the WebAuthn challenge being enrolled [base64url]
handleBiometricEnroll computes both values from the real enrollment
data (consumed challenge bytes + submitted public key) and passes them
as ExpectedCredentialHash / ExpectedChallengeID to VerifyLiveness.
Mismatches are always fatal. A missing binding is fatal in STRICT
mode (the default) and warns-and-accepts in LAX.
BindingMode is server-configured via MPC_LIVENESS_BINDING=strict|lax.
Production mainnet runs STRICT — SecureGate must ship the extended
envelope as a deployment blocker. LAX exists solely for the rollout
window; every permissive accept emits an `mpc.biometric.binding_warn`
log line so operators can see rollout progress.
Tests added in pkg/webauthn/liveness_test.go:
- R38_AcceptsMatchingCredentialHash
- R38_RejectsMismatchedCredentialHash
- R38_RejectsMissingBindingStrict
- R38_LaxWarnsOnMissing
- R38_AcceptsMatchingChallengeID
- R38_RejectsMismatchedChallengeID
Full ./pkg/api/ suite green; the existing pre-R3-8 LAX-style liveness
tests still pass (they supply no Expected* values → the binding path
is a no-op for them).
A misissued API key with permissions=["*"] was acting as a god-key,
passing every named permission gate including mpc:settlement:sign,
mpc:operations:approve, mpc:wallet:sweep, and mpc:policy:write.
Every API key must now enumerate its explicit permissions. This is the
one-way-to-express-authority rule: the list is the source of truth —
no wildcards, no implicit grants.
Tests:
- TestRequireRoleOrAPIPermission updated: `*` is rejected on every check
- New TestRequireRoleOrAPIPermission_WildcardRejectedOnSensitive: table
over every sensitive permission, asserts wildcard is forbidden
- New TestRequirePermission_WildcardRejected: same rule on the lower
gate used by trade approval
Breaking change: any operator who issued keys with permissions=["*"]
must reissue with explicit permissions. v1.7.0.
R2-1: biometric enrollment is a forged-enrollment oracle fixed.
- pkg/webauthn/verify.go: full WebAuthn verification — compare against the
random challenge bytes we issued (not the DB row ID), origin allowlist,
rpIDHash equality, UP+UV authenticator-flag enforcement, ceremony type.
- pkg/webauthn/liveness.go: SecureGate-signed liveness attestation. Ed25519
envelope over {providerId, userId, score, timestamp, nonce}. MPC verifies
against MPC_SECUREGATE_PUBKEY_ED25519 and enforces userId match, score
floor, max age. Body-supplied livenessScore is never trusted again.
- If SecureGate pubkey is unconfigured, handleBiometricEnroll returns 503.
- 14 new tests cover each rejection path.
R2-2: CAS-under-Postgres. The previous in-Go check-then-write +
unconditional UPSERT works on SQLite (writeMu) but loses updates under
Postgres READ COMMITTED. Switched to SELECT ... FOR UPDATE inside READ
COMMITTED tx via orm.RunInTransactionWith + orm.GetForUpdate[T]. Requires
hanzoai/[email protected] (tagged today).
- Session consume: operationLimit strictly respected under concurrency;
valueAccum computed under the lock.
- Operation approve: ApprovedBy append under the row lock, quorum
finalization transitions at most once even under racing approvers.
- pkg/db.New now supports postgres:// DSNs.
- New Postgres-backed regression tests in cas_postgres_test.go —
auto-skipped when no TEST_PG_DSN reachable; 10 concurrent consumers
against operationLimit=5 yields exactly 5 successes and 5 denials.
Bumps github.com/hanzoai/orm v0.3.2 → v0.4.0.
Test plan:
go test -tags sqlite_json1 -count=10 \
-run 'CASUnderRace|CASUnderRace_Postgres|RequireRoleOrAPIPermission' \
./pkg/api/ ./pkg/webauthn/
Every /v1 mutating route that previously accepted role=="api" without
permission checks now uses requireRoleOrAPIPermission — JWT human roles OR
API keys that carry the concrete permission. R2-3 regression surface:
- /v1/transactions → mpc:transactions:create
- /v1/subscriptions/* → mpc:subscriptions:write
- /v1/payment-requests/* → mpc:payment-requests:write
- /v1/smart-wallets/* → mpc:smart-wallets:write
- /v1/trade/* → mpc:trade:write
- /v1/intents/* (sign/co-sign) → mpc:sign (same perm as /v1/mpc/sign)
- /v1/mpc/operations/approve,reject → mpc:operations:approve
Bare role=="api" is no longer sufficient anywhere — an API key with empty
permissions hits 403. Human roles (owner/admin/signer) still pass through
on JWT auth. Also adds the canonical requireRoleOrAPIPermission helper to
middleware.go.
- F2: handleApproveOperation now runs tryApproveOperation inside
RunInTransaction with CAS guard on (ApprovedBy, Status). Two
concurrent final-approvers can no longer both satisfy quorum and
both trigger signing; exactly one wins, the other retries or sees
"operation not in pending_approval state". 5 retries with backoff,
then 409 conflict.
- F23: initiator can NEVER self-approve, regardless of how many
approvals already exist. Previous code only blocked self-approval
when len(ApprovedBy) == 0.
- F10: list handlers (operations + audit) now call Count() on the
filtered query before fetching the page; totalItems reflects the
real count, not len(page). Added page/perPage response fields.
- F20: operation status filter is validated against the spec enum
(pending_approval, approved, signed, rejected, failed, expired).
Unknown values return 400 instead of silently dropping the filter.
- F3: consumeSessionForSign now atomically CAS-guards OperationsUsed
and ValueAccum inside RunInTransaction with explicit prev-value
check; mismatch returns errSessionConflict and the outer loop
retries up to 5 times. Prevents over-consuming sessions under
concurrent /sign requests. Callers must present a sessionID (403
otherwise — aligns with F1 handler change).
- F21: session reaper now loops pagination until a page produces no
reaps; per-row Update errors are logged via an audit entry instead
of being silently swallowed. Added reapExpiredSessions for direct
test-drive.
- F24: handleCreateWalletSession now consults wallet policies. If the
requested valueLimit trips a require_approval policy the session is
created in pending_approval state; if a deny policy matches, the
request is rejected 403. operationLimit > 1000 also forces approval.
- F25: enforced scope strictly — sign scope is required on the session
for /sign and /settlement/sign. Sessions granted only 'authorize' or
'read' cannot sign.
Added concurrency test cas_concurrency_test.go:
TestConsumeSessionForSign_CASUnderRace (F3 reproducer)
TestApproveOperation_CASUnderRace (F2 reproducer)
TestInitiatorCannotApprove_Regardless (F23 regression)
Tests require -tags json1 for SQLite json_extract; Makefile already
sets this on the test target (F30 mitigation).
- F1: /v1/mpc/sign now requires sessionId (403 without). Same for
settlement/sign. No silent auto-session selection in handlers.
- F15: /v1/mpc/sign now gated by requireRole("owner","admin","signer","api")
to match /v1/mpc/settlement/sign.
- F6: decodeMessage is strict — returns error when the declared encoding
does not parse. Removed the raw-string fallback that made the endpoint
an arbitrary-bytes signing oracle.
- F4: /v1/mpc/balances/{address} now resolves the address against the
caller's org (ETH/BTC/SOL columns). Unknown or cross-tenant address
returns 404 — no more "empty balances" leak.
- F5: biometric enroll requires a server-issued challenge, signed
attestation (P-256 ECDSA verify), and a SecureGate liveness score
above 0.8 (PAD-2). Raw templates are no longer accepted. Added
consumeBiometricChallenge + verifyBiometricAttestation helpers.
- F14: added Wallet.DefaultForUserID column; handleMpcSetDefaultWallet
now writes to it instead of stuffing a "default:<userID>" sentinel
into the user-facing Name field. Previous default (if any) is cleared
atomically per (orgId, userId).
- F22: findDefaultWalletID reads the new is_default column (via
defaultForUserId) — no more "most recently created wallet" fallback
that produced non-deterministic behavior.
Implement the canonical Liquidity MPC surface frozen in
openapi/mpc.yaml. All endpoints mount under /v1/mpc/* — one and only
one path per operation, no aliases, no backwards compatibility.
New:
- handlers_mpc.go — wallets, balances, default wallet, sign,
settlement/sign, webauthn/biometric spec shapes
- handlers_sessions.go — time-bounded signing grants with operation
and value limits, enforced on every /sign via consumeSessionForSign
- handlers_operations.go — unified Operation view over db.Transaction
with kind ∈ {sign,send,mint,burn,transfer,contract_call}
Changed:
- server.go: mount r.Route("/mpc", …); remove top-level /v1/policies
and /v1/audit (moved to /v1/mpc/policies, /v1/mpc/audit); retain
/v1/transactions POST as internal wiring
- handlers_policy.go: accept both {id} (internal) and {policyId} (spec)
- pkg/db/models.go: add Session model (registered as "mpc-session")
No GITHUB_TOKEN needed. No git config override. No GOPRIVATE.
Proxy downloads everything. GONOSUMDB skips sum DB for our repos.
go.sum checksums match proxy (not direct).
- ws.go: EventBus pubsub + /v1/ws upgrade handler (stdlib, no deps)
- zap_server.go: StartZAP(port) with opcodes 0x0060-0x0064
keygen, sign, status, wallets, intents over ZAP binary protocol
- Events field on Server for cross-transport event broadcasting
- Each consensus node serves full API (not separate mpc-api sidecar)