The two MPC files that still carried Ringtail names:
cmd/mpcd/main.go — comment ref to luxfi/ringtail
pkg/api/handlers_kms.go — ValidatorKeySet wire schema
handlers_kms.go renames:
ValidatorKeySet.RingtailWalletID → CoronaWalletID
ValidatorKeySet.RingtailPublicKey → CoronaPublicKey
JSON tags ringtailWalletId/PubKey → coronaWalletId/PubKey
local `ringtailWalletID` and the per-validator suffix
"validator-<id>-ringtail" → "validator-<id>-corona"
router switch `case "ringtail":` → `case "corona":`
cmd/mpcd/main.go: comment "luxfi/corona and luxfi/ringtail" was
collapsed to just "luxfi/corona" — the dispatcher's not-yet-impl
stub now references only the live R-LWE library.
Wire break, no compat layer (forwards-perfection policy):
KMS callers must re-issue requests with the new field names.
Build: go build ./... PASS.
Tests: pkg/api PASS 1.374s.
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
a54e54d 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 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")
- 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 satschel.com, liquidity.io 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 + Ringtail 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
Liquidity exchange origins (exchange.{dev,test,main}.satschel.com,
liquidity.io) 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
- Login redirect uses /login instead of /oauth/authorize (matches actual
Casdoor login page URL that renders the form)
- E2E tests updated to use correct field placeholders (Email, Password)
and button text (Sign In) matching the Lux ID login component
- Exempt /healthz from rate limiting to prevent K8s probe failures
- Callback page now checks both hash fragment and query string for
access_token (Casdoor returns it in query string, not hash)
- Login redirect uses /oauth/authorize instead of /login/oauth/authorize
- Add hanzo.id to default OIDC issuers in MPC API
- Update E2E tests to match corrected authorize URL
- Add OIDC token exchange endpoint (POST /api/v1/auth/oidc) that
validates Lux ID access tokens and issues local MPC API JWTs
- Update dashboard auth callback to do 2-step exchange: get Lux ID
token, then exchange for MPC API JWT via OIDC endpoint
- Fix IngressRoute to use path-based routing (/api/v1 → API, / → dashboard)
- Fix deploy.yml to target lux-k8s cluster (not hanzo-k8s)
- Fix wallet creation page to use api client instead of raw fetch
- Add /api and /healthz to dashboard middleware public paths
- goimports -w on all pkg/api, pkg/db, cmd/mpcd files
- TestError_PanicsOnOddKeyValues → TestError_OddKeyValuesLogsWarning
(logger was intentionally changed to warn+sentinel instead of panic)
- int(threshold) → threshold (threshold is already int from c.Int())
- validateAPIKey returns *db.APIKey so middleware has access to permissions
- API key permissions propagated into request context via ctxPermissions
- requirePermission middleware checks actual key permissions (not just role)
- PasswordHash/MFASecret fields marked api:"-" to prevent serialization
- Remove stale generated files (CHANGELOG, CI_STATUS, DOCKER_BUILD etc.)
- Update LLM.md with production deployment state and architecture decisions