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]>
BLS is consensus-only. The mpcd validator-key custody path generated a
threshold-BLS group key via a TRUSTED DEALER (blsThreshold.TrustedDealer:
one node, the lowest sorted party ID, generated the entire group secret
key and all shares, then distributed them) — a single point of full-key
compromise, with no DKG and no rogue-key PoP. That violates the
public/leaderless/permissionless invariant "no single node can sign or
extract a key".
Orphan-safe: this path was incomplete (no proof/sign-off doc unlike
cmp/frost; no deployment manifest provisions validator-BLS; BLS keygen
had no dispatch caller) and NOT live custody. A validator's real BLS
consensus key is its own NATIVE per-validator crypto/bls key (PoP +
aggregation, the consensus fast-path), held at the node — never
threshold-custodied here. Corona PQ-threshold custody (dealerless DKG)
stays. So a validator keyset = {native BLS (consensus, no custody) +
corona PQ-threshold (custodied)}.
Delete: bls_keygen_session.go, bls_signing_session.go, bls_config_marshal.go,
bls_session_test.go, signing_handler_bls.go. Remove KeyTypeBLS (mpc + types),
SessionTypeBLS, CreateBLS{KeyGen,Sign}Session, the BLS signing/keytype
dispatch cases, the keygen-result BLSPubKey field, and the bls: key-share
prefix. handlers_kms.go: drop the BLS-custody half of ValidatorKeySet and
the generate/sign/rotate BLS branches; KMS custodies corona only.
Verified: GOWORK=off go build ./... + go vet green; pkg/mpc, pkg/api,
pkg/types tests pass. Zero residual refs to deleted BLS symbols.
lux OSS names no PAD-2 / liveness vendor. Decomplect the verifier into a
pluggable extension point so a white-label deployment (e.g. liquidity)
registers its own provider without forking:
- pkg/webauthn: add Verifier interface + Binding (vendor-neutral per-call
input) + Ed25519EnvelopeVerifier (OSS default, delegates to the audited
VerifyLiveness) + RegisterVerifier/LookupVerifier registry. Tests cover
the default verifier, the registry, and a custom non-Ed25519 scheme.
- pkg/api: resolve the active Verifier at boot from MPC_LIVENESS_PROVIDER_ID
(registered custom wins; else Ed25519 from MPC_LIVENESS_PUBKEY_ED25519;
else nil → enroll fails closed 503). handleBiometricEnroll calls
s.livenessVerifier.Verify(...) — no vendor code in lux.
- Rename MPC__* env vars → MPC_LIVENESS_*; drop every
identifier/comment across code + LLM.md. Zero /onyxd in lux/mpc.
Also finish the half-done decomplect from a326cc6 'rip zapauth gate' that
left pkg/api/zap_kms_server.go calling a removed checkAuth and missing the
encoding/json import — restore the import, drop the orphaned guard.
(Pre-existing, unrelated: cmd/mpcd still references thresholdd.NewZapServer
from the same in-flight WIP — not touched here.)
Trust at the network boundary (NetworkPolicy + ZAP wire), not in the
application envelope. Companion to luxfi/keys v1.2.0 (LoadMnemonic
identity-free) and luxfi/kms v1.12.0 (iamclient gone).
Killed:
- pkg/zapauth/ (JWKS verifier + AuthHelloFrame parser)
- pkg/api/zap_kms_server.go: KMSZapConfig, auth+authRequired fields,
handleAuthHello, respondAuthHello, checkAuth, StartKMSZAPWith
- cmd/mpcd: ZAP_JWKS_URL, ZAP_EXPECTED_ISS, ZAP_EXPECTED_AUDIENCES,
ZAP_AUTH_REQUIRED — all gone.
Note: mpc still has a pre-existing build block from upstream luxfi/geth
v1.16.79 → precompile pseudo-version (unrelated to this change).
* build(mpc): GOEXPERIMENT=jsonv2 alongside runtimesecret per SCALE_STANDARD
Per SCALE_STANDARD.md §2 (~/work/hanzo/hips/docs/SCALE_STANDARD.md) —
every Go production Dockerfile that emits JSON to a client builds with
GOEXPERIMENT=jsonv2. Verified on hanzoai/zip's json_bench_test.go:
- Edge POST roundtrip: -12% time, -23% allocs
- Marshal-only: -22% time
- Unmarshal-only: -19% time, -25% allocs
GOEXPERIMENT is comma-separated, so jsonv2 composes cleanly with the
pre-existing runtimesecret experiment used by the MPC data plane.
* ci: switch workflows to lux-build (luxfi-scoped ARC pool)
Cross-org runs-on dispatch failure: hanzo-build-linux-amd64 scale set is
registered to github.com/hanzoai and silently rejects luxfi/* jobs.
Switch to lux-build (githubConfigUrl=github.com/luxfi, max 20).
* test/zapauth: deterministic invalid-signature test (was flaky)
TestZapAuthInvalidSignature flipped one base64 char of the signature
segment. ECDSA accepts a wrong sig with probability ≈ 1/2^6 per
single-char flip (alphabet is 64), making the test ~1.5% flake rate
in CI.
Fix: replace the entire signature segment with all-A's. Still
decodable as base64url but cryptographically guaranteed-invalid
bytes — Verify rejection is now deterministic.
Verified: ran -count=10 ./pkg/zapauth/ — all pass.
* deps: bump accel/crypto/corona/threshold/fhe/lens/math to current tags
* api/db: decomplect — KeccakAddress on Wallet / TreasuryWallet / KeygenResult
Wave 5 of the workspace-wide eth* naming purge.
pkg/db/models.go — Wallet + TreasuryWallet:
- New canonical KeccakAddress *string field with json `keccakAddress`
- EthAddress kept with json `ethAddress` as `// Deprecated:` alias
- NormalizeAddresses() method on both structs mirrors the two fields
via the package-private mirrorAddrPtrPair helper
pkg/api/server.go — KeygenResult (backend response):
- New canonical KeccakAddress string field with json `keccak_address`
- EthAddress kept with json `eth_address` as `// Deprecated:` alias
- NormalizeAddresses() method mirrors both fields
pkg/api/handlers_wallets.go, handlers_treasury.go, handlers_bridge.go,
handlers_mpc.go — all sites that constructed wallet rows from MPC
backend results now:
1. Call result.NormalizeAddresses() on the backend response so both
KeccakAddress / EthAddress carry the same value before copy
2. Copy both fields onto the persisted Wallet / TreasuryWallet row
3. Call wal.NormalizeAddresses() to ensure DB row has both
4. Read from the canonical KeccakAddress at response-construction
sites (toWalletResponse, treasury response, bridge response)
The "ethereum"/"lux"/"xrp" labels in addresses map (handleGetWalletAddresses)
are per-chain DISPLAY labels keyed by chain name a client knows;
the values are the Keccak-derived 20-byte address. Map keys NOT
decomplected here — they're per-chain UI labels, not internal naming.
Effect on the wire:
- API consumers reading wallet.ethAddress see the same value as
wallet.keccakAddress
- New consumers can switch to keccakAddress today
- ORM persistence stores both fields (eventually consistent via
NormalizeAddresses calls at handler boundaries)
Tests: pkg/api unit tests pass (1.8s); pkg/db has no test files.
Deprecation alias retention means downstream KMS / dashboards
keep working at every step of the migration.
Per CLAUDE.md x.x.x+1.
* strip KeccakAddress + EthAddress dual-field bridge (no aliases)
Forward-only completion of the workspace strip-aliases cascade. The
canonical JSON field is evmAddress, the Go field is EVMAddress. No
NormalizeAddresses helpers, no mirrorAddrPtrPair, no deprecated
KeccakAddress, no deprecated EthAddress.
Touched:
pkg/api/server.go KeygenResult drops KeccakAddress +
EthAddress + NormalizeAddresses
pkg/db/models.go Wallet drops KeccakAddress + EthAddress
TreasuryWallet drops KeccakAddress +
EthAddress
Drops NormalizeAddresses + mirrorAddrPtrPair
pkg/api/handlers_wallets.go rip mirror calls; only EVMAddress
populated on Wallet rows
pkg/api/handlers_treasury.go rip mirror calls on TreasuryWallet
pkg/api/handlers_mpc.go rip NormalizeAddresses calls; rename
filter("ethAddress=") → ("evmAddress=")
pkg/api/handlers_bridge.go local var keccakAddress → evmAddr
pkg/api/handlers_smart_wallets.go wallet.EthAddress → wallet.EVMAddress
pkg/api/treasury_test.go mock KeygenResult EthAddress → EVMAddress
pkg/api/zap_kms_server_test.go same
cmd/mpcd/main.go pubKeyToEthAddress → pubKeyToEVMAddress,
\"eth_address\" → \"evm_address\",
local var ethAddr → evmAddr, KeygenResult
EthAddress → EVMAddress field
Deps: bump luxfi/metric v1.5.1 → v1.5.7 to pick up ExponentialBuckets
(via transitive luxfi/threshold dep).
The value IS \"EVM-runtime account address\" — that's the data model.
The Keccak256(secp256k1 pubkey) derivation is HOW.
Adds threshold-signing transport so KMS can use mpcd as MPC backend.
Reuses ML-KEM-768 hybrid handshake from luxfi/kms/pkg/zap (#55) by
vendoring the same construction into mpc/pkg/zap — wire constants
(opcodes 0x00F0/0x00F1, HKDF info "kms/zap/v1/session", direction
tags) stay in lockstep with KMS.
New surface:
- pkg/zap: vendored handshake + session (forward-only convergent)
- pkg/api/zap_kms_server.go: KMS-facing ZAP node (opcodes
0x0001/0x0010/0x0011/0x0012/0x0020) wired onto MPCBackend
- cmd/mpcd: --kms-zap-listen flag (default :9970), enabled out of the
box; default-disable by passing empty string
Existing pkg/api/zap_server.go (dashboard event bus, opcodes 0x0060+)
is unchanged — different consumer, different port.
Closes the secrets-only mode gap on mainnet (#70).
The threshold-FHE wallet keygen path (pkg/mpc/tfhe_session.go:154,165
production caller via pkg/mpc/node.go:583) was wired to
luxfi/threshold/protocols/tfhe.NewKeyGenerator + GenerateKeys. Per #197,
that primitive is a fail-loud placeholder: every party stores the full
master key, PartialDecrypt returns an HMAC tag, and CombineShares ignores
partials and runs single-party decryption. Upstream guards every entry
point with guardUnsafe() that panics unless
LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1 is set — by design.
Default production builds must not be able to reach the placeholder
primitive even with the env var set. Real-threshold wiring (luxfi/lattice
Shamir/LWE) is tracked as a multi-week separate task per LP-137 §2.6.
Changes:
- pkg/mpc/tfhe_session.go: //go:build experimental_tfhe — entire
tfheKeygenSession + tfheComputeSession + TFHESession interface gated
behind the tag.
- pkg/mpc/tfhe_node.go (new, experimental_tfhe): Node.CreateTFHEKeyGenSession
+ CreateTFHEComputeSession constructing the experimental sessions.
- pkg/mpc/tfhe_node_stub.go (new, !experimental_tfhe): stub Node methods
that return ErrTFHENotImplemented from every entry point. This is what
the production binary ships with.
- pkg/mpc/tfhe_errors.go (new, unconditional): ErrTFHENotImplemented
symbol with a description pointing at LP-137 §2.6.
- pkg/mpc/node.go: removed the unconditional CreateTFHEKeyGenSession +
CreateTFHEComputeSession definitions and the unused luxfi/fhe import.
Test pass:
go test ./pkg/mpc/... ./pkg/policy/... -short -count=1 -race -timeout 60s
ok github.com/luxfi/mpc/pkg/mpc 2.016s
ok github.com/luxfi/mpc/pkg/policy 1.477s
91 PASS, 0 FAIL (default build, no env var set)
go build -o /tmp/mpcd_final ./cmd/mpcd/
/tmp/mpcd_final version # mpcd version 0.3.3 — no panic, no UNSAFE TFHE reach
go build -tags experimental_tfhe ./pkg/mpc/... # opt-in build OK
Note: pkg/policy/main_test.go sets LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1
because RealThresholdDecryptor's dispatcher tests exercise the placeholder
PartialDecrypter — same TestMain pattern threshold/protocols/tfhe uses.
These are orchestration-shape tests; they verify no threshold security
property because the underlying scheme has none.
Companion bumps required for the branch to compile:
- luxfi/threshold v1.6.0 -> v1.6.4 (FHECiphertext / FHEThresholdShare /
ShareAggregator exports landed in v1.6.x)
- google/go-sev-guest -> go.mod (cc/attest/sev.go direct import)
- pkg/api/server.go: missing approval + tieredWallets fields added to
satisfy in-tree handler references
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
2ce3dd8 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/
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 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 — 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: -signed liveness attestation. Ed25519
envelope over {providerId, userId, score, timestamp, nonce}. MPC verifies
against MPC__PUBKEY_ED25519 and enforces userId match, score
floor, max age. Body-supplied livenessScore is never trusted again.
- If 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 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 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")
- 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)
- Add bearer token auth to internal MPC API (port 9800): /keygen, /keys,
/backup now require Authorization header. Token from MPC_INTERNAL_API_KEY
env (KMS-synced) or derived from Ed25519 node identity.
- Replace DualModeListener with TLSOnlyListener: plaintext peer connections
are rejected. TLS 1.3 with PQ key exchange is mandatory.
- Add dedicated 20 RPM rate limiter on bridge signing and intent signing
endpoints (separate from global 100 RPM).
- Add body size limit (1MB), read/write timeouts, and 10 RPM rate limiter
to internal MPC API server.
- Restrict K8s NetworkPolicy egress for MPC nodes to peers, postgres,
valkey, DNS, and HTTPS only (was unrestricted).
- Wire cluster-api-key in api-deployment for authenticated proxy to
internal MPC API. Forward as Authorization: Bearer header.
- Remove hardcoded dev JWT_SECRET default from compose.yml (now required).
- Add internal-api-key to KMSSecret CRD and StatefulSet env.
MPC is generic infrastructure — no hardcoded tenant URLs.
- Remove pars.network, zoo.network from OIDC issuers, ingress, TLS
- Remove .com, from CORS, WebAuthn, tests
- CORS/WebAuthn origins now env-driven: MPC_CORS_ORIGINS, MPC_WEBAUTHN_ORIGINS
- Ingress: mpc.lux.network only — tenants route through their own ingress
- Tests updated to match infrastructure-only origin lists
KMS routes now live inside MPC API server — one binary for both:
- POST /api/v1/keys/generate — create validator BLS + Corona keys
- GET /api/v1/keys — list all validator key sets
- GET /api/v1/keys/{id} — get validator key set
- POST /api/v1/keys/{id}/sign — sign with validator key
- POST /api/v1/keys/{id}/rotate — reshare validator keys
Storage: ORM (same postgres/ZapDB as MPC wallets).
No separate KMS binary needed — mpcd serves both.
- Remove pkg/bridge/ (btc_watcher + orchestrator) — bridge/opnet-watcher
is the single source of truth for chain watching per CTO directive
- Add Handler() to API server for mounting on Hanzo Base
- MPC is signing-only; bridge is the control plane
Add MPC_WEBAUTHN_RP_ID and MPC_WEBAUTHN_ORIGINS env vars to make
WebAuthn relying party and origin allowlist configurable. Adds
exchange origins (exchange.{dev,test,main}..com,
) to WebAuthn origin checks, CORS, and security tests.
Backup shard (shard 3) is encrypted with user's WebAuthn passkey
public key (P-256 ECDH). HSM stores the ciphertext but cannot
decrypt — only user's biometric (via Secure Enclave private key) can.
ATS shard (2) + encrypted_backup (3) = company CANNOT sign.
User shard (1) + passkey_decrypt(3) = user CAN exit independently.
Added UserPasskeyFingerprint to WalletBackup model.
Backup handler looks up user's WebAuthn credential for encryption.
- Run goimports -w on all 17 Go files with formatting issues
- Set CGO_ENABLED=0 on all CI steps (test, lint, build, CodeQL, SBOM)
- Remove -race flag from tests (requires CGO which pulls in blst.h)
- All 17 test packages pass with CGO_ENABLED=0
Backend:
- Reorg detection in tx tracker (re-verify block hash, detect receipt disappearance)
- Post-finalization safety margin (6 extra blocks before stopping tracking)
- Server-side HSM co-signing (HSMProvider interface, server calls HSM directly)
- Background intent expiry reaper (5-min sweep for stuck intents)
- Domain-separated intent hashing (lux-mpc-intent:v1 prefix)
- Backup rate limiting (max 3 active per wallet)
- Default backup changed to 2-of-3 (iCloud + HSM + offline) for fault tolerance
- All status transitions now use RecordTransition with proper actors
Frontend:
- Intents list page with status filter
- Intent detail page with lifecycle progress bar, sign/co-sign actions
- Settlements list page with status filter
- Settlement detail page with lifecycle, HSM signatures, transfer agency
- Enhanced transaction detail with on-chain confirmation, revert reason, status history
- Wallet backup UI with Shamir shard visualization
- Updated StatusBadge with all new status colors
- Nav updated with Intents + Settlements links
- Co-sign UI updated for server-side HSM flow (key_id only, no client signature)
Add HTML landing page at / route for mpc.lux.network showing:
- CGGMP21 and FROST protocol info
- Supported chains (BTC, ETH, LUX, SOL, XRPL, TON, etc.)
- Links to health, bridge dashboard, and API docs
Also update go.work to Go 1.26.1.
- Add WebAuthn/FIDO2 registration and verification endpoints
POST /webauthn/register/begin, /register/complete, /verify
- Biometric approval: Face ID/Touch ID/YubiKey approve transactions
- Challenge bound to transaction ID (SHA256) preventing replay
- Enforce ApproverRoles from policy engine during transaction approval
- Add WebAuthnCredential DB model (pending_registration, active, revoked)
- Policy decisions now carry ApproverRoles for role-gated approvals