355 Commits
Author SHA1 Message Date
zeekay 1183d565d2 kms.lux.cloud is the brand host; kms.lux.network is retired
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.
2026-08-05 07:51:13 -07:00
zeekayandHanzo Dev 5eaf0304b0 mpcd: a node is not its own peer, and too many ready is not too few
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]>
2026-08-04 16:39:45 -07:00
zeekayandHanzo Dev 69d2d257fa style: goimports with -local, the setting golangci actually uses
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]>
2026-08-02 10:23:23 -07:00
zeekayandHanzo Dev e9d5915722 style: goimports all 16 unformatted files, not just the one CI named
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]>
2026-08-02 10:14:42 -07:00
zeekay fd8eb49dc7 fix(mpc): unreachable custody backend + invented smart wallets
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.
2026-07-27 13:51:48 -07:00
zeekayandHanzo Dev 7be332b97d fix(infra): route SQLiteMeta through hanzoai/sqlite — one "sqlite" registrar
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]>
2026-07-27 10:52:57 -07:00
zeekayandHanzo Dev 01db2a1edf api: doc comments said /api/v1 for routes that are /v1
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]>
2026-07-25 23:23:53 -07:00
zeekayandHanzo Dev 72a2ae1371 mpcd: report the threshold and party set keygen actually produced
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]>
2026-07-25 16:38:17 -07:00
zeekayandHanzo Dev 3f22f50c1b threshold: stop registering the nonce-leaking adapters at import
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]>
2026-07-25 16:33:00 -07:00
zeekayandHanzo Dev 3685ebea4c mpc: make signature verification real, and stop trusting a caller's Y
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]>
2026-07-25 11:45:23 -07:00
zeekayandHanzo Dev 3ff4b4e9d9 docs(mpc): correct BLS/Corona/PQ mislabels (truthful naming)
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]>
2026-07-21 08:39:25 -07:00
zeekayandHanzo Dev b19863787b fix(mpcd): canonical EVM-ready threshold-ECDSA signatures (low-S + recovery id)
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]>
2026-07-21 08:32:35 -07:00
d625ef533a fix(keygen): emit full compressed ECDSA pubkey (33B), not bare X — fixes ~50% wrong addresses (#19)
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]>
2026-07-07 21:35:20 -07:00
zeekayandHanzo Dev bfb347d69b fix: gate experimental_tfhe + fail-closed stub; bump threshold v1.12.0 (dealerless daemon) + pulsar v1.9.0
Co-authored-by: Hanzo Dev <[email protected]>
2026-06-30 16:41:50 -07:00
zeekay 5e04d411e2 refactor(attest): consume the github.com/luxfi/cc leaf; KindNRAS→KindNVTrust
The canonical attestation verifier (cc/attest + pkg/attestation/nvidia) was
extracted into the orthogonal leaf module github.com/luxfi/cc so mpc, the TEE
custody extension, and the confidential-AI system can share ONE verifier
coupled to none of their domains.

- pkg/attestation/composite.go, pkg/kms/release.go: import the leaf
  github.com/luxfi/cc/attest directly; map the NVIDIA issuer to the real
  KindNVTrust (was the KindNRAS stub).
- pkg/attestation/nvidia + cc/attest/{sev,tdx,verifier,...}.go: moved to the
  leaf (clean stdlib/go-sev-guest leaves, zero mpc-internal imports).
- cc/attest now a thin TRANSITION ADAPTER: a zero-logic re-export of the leaf
  (type aliases ⇒ identical types, so the kms.CompositeAttestation release
  gate is satisfied from either import path) plus the deprecated KindNRAS
  symbol that pinned github.com/luxfi/threshold v1.9.9 still references. To be
  removed once threshold republishes against the leaf (its main already has)
  and mpc's threshold/protocols/tfhe dep is resolved.

go.mod: require github.com/luxfi/cc (local replace ../cc until tagged);
threshold stays v1.9.9 (cannot bump: main drops protocols/tfhe that pkg/mpc
needs). go vet ./... clean; attestation/kms/scheduler tests green.
2026-06-27 12:33:40 -07:00
zeekay 567f950fa0 security(mpc): rip trusted-dealer threshold-BLS custody — H-2
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.
2026-06-26 21:18:02 -07:00
zeekay 255faab1cd fix(transport): propagate peer Ed25519 keys to consensus identity store
Consensus mode learned only its OWN Ed25519 public key: peer keys arrive in
ReadySignal.PublicKey during the transport identity handshake (sendIdentity) but
were discarded — never fed to the ConsensusIdentityStore that VerifyWireMessage
consults. Result: every inbound CGGMP21/FROST/signing wire message from a peer
was dropped ("public key not found for node …") and keygen/signing stalled to a
60s timeout. The HTTP /keygen and /sign endpoints could not work in any real
multi-node ensemble.

Fix (orthogonal, no import cycle): transport.Config.OnPeerIdentity observer is
invoked (notifyPeerIdentity, with self/empty/length guards + defensive copy)
whenever a peer key is learned — from the MsgMPCReady handshake in handleMessage
and handleIncoming. The acceptor now also replies with its own identity so the
key exchange is symmetric on the single tie-broken connection. main.go wires the
observer to ConsensusIdentityStore.AddPeerPublicKey. This is the consensus-mode
analog of the legacy NATS path's peers.json + *_identity.json seeding.

Proof: e2e/live_http_sign_test.go (//go:build integration) builds mpcd from the
repo root, starts a real 3-node 2-of-3 consensus ensemble, and over the live
HTTP API proves: distributed CGGMP21 keygen (real evm address), a real threshold
signature, idempotency cached-match, anti-oracle 409 on key reuse with new
content, 400 on empty key, and no plaintext key in any response or node log.
Unit coverage for the observer guards in pkg/transport/peer_identity_test.go.

Also fixes a pre-existing standalone_test.go bug: it set only ZAPDB_PASSWORD but
the env HSM provider reads MPC_PASSWORD — set both so the node starts.
2026-06-26 21:18:02 -07:00
zeekay 184193bae6 scrub branding + corona from docs/comments/tests
- Genericize all -brand references in comments, LLM.md, and
  FIREBLOCKS_PARITY.md: ' MPC API'→'MPC API', drop
  ~/work/liquidity/openapi/mpc.yaml paths, 'Liquid operator'→'the operator',
  'liquid-mpc'→'the mpc cluster'.
- Genericize tenant fixtures/examples: liquidity→acme, mlc→globex across
  treasury_test.go + db model comments (cross-tenant isolation tests keep
  their semantics with neutral org names).
- Rename corona→corona in docs (active Go code had none).
lux OSS names no white-label tenant. Build + api/webauthn tests green.
2026-06-10 22:55:37 -07:00
zeekay ec0fb82eaa webauthn: vendor-neutral pluggable liveness Verifier; drop
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.)
2026-06-10 21:42:27 -07:00
zeekay a326cc6418 mpc: rip zapauth bearer-token gate (decomplect)
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).
2026-06-06 21:06:17 -07:00
zeekay f16dec31f6 mpc: swap minio-go/v7 → hanzos3/go-sdk v1.0.2
Canonical S3 SDK across the stack is the Hanzo S3 fork at
github.com/hanzos3/go-sdk (package `s3`). Imports use an explicit
`s3 "github.com/hanzos3/go-sdk"` alias to avoid the `go-sdk` last-path-
segment mismatch with `package s3`.

`pkg/backup/s3.go` swept: `minio.Client` → `s3.Client`, `minio.New` →
`s3.New`, `minio.Options` → `s3.Options`, `minio.MakeBucketOptions` →
`s3.MakeBucketOptions`, `minio.PutObjectOptions` → `s3.PutObjectOptions`.

Field name `s3Client *s3.Client` is fine (no shadow with the package
qualifier).

Dropped stale `luxfi/zapdb v1.0.0` entries in go.work.sum that were
blocking go mod tidy (their hashes no longer match the proxy after
zapdb's own sweep).

No behavioral changes.
2026-06-03 11:07:39 -07:00
zeekay da277ae96a fix: gofmt -s across repo (CI format check) 2026-06-02 11:39:20 -07:00
zeekayandGitHub b25d9422d0 build(mpc): GOEXPERIMENT=jsonv2 alongside runtimesecret per SCALE_STANDARD (#13)
* 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.
2026-05-29 21:05:40 -07:00
zeekayandGitHub d4ce770f4e env: drop LUX_ prefix from env vars (noise) (#8)
Renames:
- LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY -> ALLOW_FAKE_TFHE_FOR_TESTING_ONLY
  (test opt-in for the placeholder UNSAFE TFHE primitive)
- LUX_MPC_CONFIG -> MPC_CONFIG (docs/compose env)
- LUX_MPC_MODE   -> MODE        (Viper config key 'mode' override)

The brand prefix was noise; the env var names speak for themselves.
README/LLM/config docs updated to reflect that Viper has no env prefix
configured (AutomaticEnv reads uppercased config keys directly), so the
prior 'LUX_MPC_*' documentation was inaccurate.

Note: file format magic in pkg/kvstore/zap_backup.go remains
"LUX_MPC_BACKUP" — that's an on-disk format identifier, not an env var.
Renaming it would invalidate every existing backup file.

BREAKING: external callers/operators must update their environment
variable names. Per CLAUDE.md no-backwards-compatibility rule, the old
forms are removed.
2026-05-15 16:01:13 -07:00
zeekay d40098c959 feat(zapauth): JWKS-bearer gate on KMS-facing ZAP server (LP-103)
New pkg/zapauth verifies a JWT presented in OpAuthHello (0x00EF)
BEFORE the X25519+ML-KEM-768 hybrid handshake. Per-peer claims are
attached by ZAP peer ID and consulted on every opcode dispatch.

Backwards compatible: defaults to auth=off so v1.13.x clients keep
working. ZAP_AUTH_REQUIRED=true rejects unauthenticated peers; the
flip happens after every KMS deploys with bearer minting (luxfi/kms
v1.9.0 pkg/iamclient).

- pkg/zapauth/{verifier,verifier_test}.go — Verifier, JWKS cache,
  AuthHello frame, stdlib-only signature verification (RS/ES/EdDSA).
- pkg/api/zap_kms_server.go — KMSZapConfig, StartKMSZAPWith, per-op
  checkAuth gate, OpAuthHello handler.
- pkg/api/zap_kms_server_auth_test.go — required+advisory+missing+bad
  token + nil-verifier integration tests against a self-contained
  ES256 JWKS endpoint.
- cmd/mpcd/main.go — wires ZAP_JWKS_URL / ZAP_EXPECTED_ISS /
  ZAP_EXPECTED_AUDIENCES / ZAP_AUTH_REQUIRED env vars.
- docs/zap-auth.md, LLM.md — protocol reference + LLM map entry.

Tests: pkg/zapauth (13/13), pkg/api KMSZAP (10/10 — 5 original +
5 new auth integration), pkg/zap (existing untouched).
2026-05-02 09:30:48 -07:00
zeekay aa697bfc10 feat(mpcd): expose ZAP server on :9970 for KMS handshake
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).
2026-05-01 01:35:13 -07:00
zeekay 9b9b26dccd chore: bump deps (lattice v7.1.0, hsm v1.1.3, sqlite3 v1.14.42)
- lattice v7.0.2 -> v7.1.0: types package (ComputeTrustMode,
  ConfidentialIOLevel, WorkloadPrivacyClass) used by pkg/registry +
  pkg/attestation. Was on feat/lp-137-types branch; merged + tagged.
- luxfi/hsm v1.1.2 -> v1.1.3: airgap factory cases (coldcard,
  foundation, keystone, ngrave, gridplus, ledger, trezor, yubihsm,
  nitrokey, pkcs11). Unblocks TestAirgapProvidersRoundTripFactory.
- mattn/go-sqlite3 v2.0.3+incompatible -> v1.14.42: json1 default-on,
  fixes pkg/custody json_extract failures (no build-tag dance needed).
- pkg/approval/factory_test.go: yubihsm now wantError=false (v1.1.3
  exposes it), removes the v1.1.2 workaround comment.
- cc/attest/verifier_test.go + pkg/hsm/hsm_test.go: pre-existing minor
  test cleanups bundled in (stub-panic and gcp/azure-stub assertions).

Build + 36/36 packages pass. Standalone integration smoke
(cmd/mpcd TestStandaloneSelfQuorum, -tags integration): PASS.
2026-04-29 00:19:15 -07:00
zeekay d6a7d5ae2e Merge release-policy-require-flags-2026-04-28 2026-04-28 09:30:21 -07:00
zeekay 8606872b9c Merge tfhe-real-threshold-wire-2026-04-28 2026-04-28 09:27:21 -07:00
zeekay be7fa85a9b kms/release: wire cc/attest chain verifier into Release path
Red Final N1 fix. Release() previously called only Attestation.Verify(nonce)
and ReleasePolicy.Permits() before sealing the session key. Neither step
performs the actual SEV-SNP / TDX / NRAS chain verification — AMD KDS
lookup, ARK→ASK→VCEK chain, NRAS JWT — leaving the #222 go-sev-guest
verifier as dead code on the release path. A worker that fabricates a
CompositeAttestation envelope whose Verify(nonce) returns true and whose
RIM/HW/issuer membership matches policy could harvest a session key
without ever presenting AMD-signed evidence.

Wire-in:
  - kms.CompositeAttestation gains VerifyEvidence(ctx, opts...) returning
    []*attest.VerifiedReport. *attestation.CompositeAttestation already
    implements this method (composite.go:320 → attest.Dispatch per blob).
  - ReleaseRequest gains optional Ctx + AttestOptions for context plumbing
    + per-call policy (e.g. attest.WithExpectedReportData(nonce[:])).
  - Release() calls VerifyEvidence between the cheap nonce check and the
    policy permit step. Any chain failure → ErrAttestationChain joined
    with ErrPolicyRefused via errors.Join. No silent acceptance.
  - New sentinel ErrAttestationChain.

Tests (go test -v -short -race -count=1 -timeout 60s ./pkg/kms/...):
  - all 14 existing TestRelease_/TestNonce_/TestAAD_ remain green
  - NEW TestRelease_RefusesOnChainFailure: Verify(nonce)=true but
    VerifyEvidence returns ErrChainInvalid → MUST surface
    ErrAttestationChain wrapped under ErrPolicyRefused
  - NEW TestRelease_AcceptsOnChainPass: paired happy-path so the
    chain-step is the only difference, not a wiring regression

Also pulled in cherry-pick of canonical go-sev-guest sums (c4f1e22) so
cc/attest builds reproducibly outside the dirty local cache.
2026-04-28 08:47:09 -07:00
zeekay 03b1da16b9 sec(mpc): gate TFHE wallet keygen behind experimental_tfhe build tag
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
2026-04-28 08:46:08 -07:00
zeekay 522359115c kms/release: add Require* flags mirroring AttestationBaseline (#203 O5)
Per LP-137 §2.7, ReleasePolicy now exposes RequireSEVSNP / RequireTDX /
RequireNVNRAS boolean flags matching the C++ AttestationBaseline.require_*
pattern. Closes the asymmetry where the C++ side enforced explicit issuer
presence but the Go side only checked RIM + AllowedHardware membership.

Permits() default-deny: if a flag is true and the corresponding canonical
issuer string ("amd.sev.snp", "intel.tdx", "nvidia.nras.v1") is absent
from att.EvidenceIssuers(), the policy refuses release.

NewReleasePolicyStrict bundles the production posture (all three flags
true) so deployments cannot accidentally ship a wide-open policy. Tests
opt out via NewReleasePolicy + leaving the flags false.

Interface change: CompositeAttestation gains EvidenceIssuers() []string.
Wire-stable strings exported as IssuerSEVSNP / IssuerTDX / IssuerNVNRAS
matching cc/attest dispatch keys.

Tests: 4 new cases - happy path (all 3 issuers present + valid),
missing SEV-SNP, missing TDX, missing NVNRAS (incl. nil-issuer edge).
All 19 pkg/kms tests pass with -race -count=1 -short -timeout 60s.
2026-04-28 08:37:19 -07:00
zeekay c6098b226d Merge kms-nonce-bind-2026-04-28
# Conflicts:
#	CHANGELOG.md
2026-04-28 00:58:33 -07:00
zeekay c7564de523 cc/attest: canonical TEE attestation verifier (#222 stage 1)
Adds the canonical TEE attestation verifier package at cc/attest. This
is the home for chain validation + trust anchor management; the
luxcpp/crypto/attestation parser primitives feed bytes here BEFORE
their measurement output is trusted.

Stage 1 — SEV-SNP — PRODUCTION:
  - cc/attest/sev.go: SEVSNP{} verifier built on
    github.com/google/go-sev-guest v0.14.1. Live VCEK fetch from AMD
    KDS, ARK→ASK→VCEK chain validation, signature verify against the
    leaf, MEASUREMENT/REPORT_DATA/HOST_DATA/CHIP_ID extraction,
    caller-supplied policy gates (WithExpectedReportData,
    WithExpectedMeasurement). 197 LOC.
  - testdata/: real-vendor Milan attestation report + VCEK leaf
    committed verbatim from the upstream go-sev-guest test corpus.
    Tests replay the AMD KDS responses via SimpleGetter so CI never
    hits the network.
  - 9 SEV-SNP tests passing (happy path, dispatch, length reject,
    tampered signature, wrong report_data, wrong measurement,
    deterministic composite hash, unknown kind, stub-panic guard).

Stage 2 — TDX — STUB (#222 stage 2):
  - cc/attest/tdx.go: TDX{}.Verify panics with ErrNotImplemented so
    any caller routing TDX evidence during stage 1 fails LOUD. Plan
    documented inline: go-tdx-guest + Intel PCS + QE/TDX module
    identity pinning.

Stage 3 — NRAS — STUB (#222 stage 3):
  - cc/attest/nras.go: NRAS{}.Verify panics with ErrNotImplemented.
    Plan: lift the existing pkg/attestation/nvidia NRAS client into
    the cc/attest contract with a JWKS cache.

Wire-in:
  - pkg/attestation/composite.go gains VerifyEvidence(ctx, opts...)
    which dispatches each Evidence blob to cc/attest by Issuer.
    Callers (kms release gate, scheduler) MUST invoke this before
    accepting parser output.

Doc:
  - cc/attest/doc.go states the production/stub matrix and the
    layering contract between the C-ABI parser and this package.
2026-04-28 00:47:37 -07:00
zeekay 8b4ed2edf8 kms/release: add F2/B4 nonce-binding regression suite
Five test cases asserting the audit-required nonce binding properties:

  1. Round-trip — Issue() → Release(returnedNonce) → success
  2. Wrong nonce — forged nonce ≠ issued → ErrNonceMismatch
  3. Cross-job replay — Issue(A,X) → Release(B,X) → ErrNonceMismatch
  4. Cross-epoch replay — Issue(N,A) → Rotate → Release(N+1,A) refused
  5. Double-Release — Issue → Release → Release → ErrAlreadyConsumed

All five PASS under -race. Pinned to the 23fccf2 binding fix.
2026-04-27 21:36:14 -07:00
zeekay 3bcf211706 kms/release: bind nonce to (epoch, jobID) and persist replay state
Closes Blue audit B4. Four nonce-binding gaps in the ReleaseGate are
closed in one pass:

1. Nonce derivation is now HMAC-SHA256(rootKey, "kms/release/v1" ||
   epoch || jobID || rand32). A nonce issued for jobID A cannot be
   replayed under jobID B even if the random component is identical.

2. Issue() persists (jobID, nonce, epoch, expires) in a NonceStore.
   Release() looks up the stored nonce and constant-time-compares it
   against req.Nonce. The forged-nonce path now refuses with
   ErrNonceMismatch instead of silently accepting any 32 bytes.

3. AAD now binds the issued nonce: epoch || jobID || teePub || nonce.
   Two Release() calls with the same (epoch, jobID) but different
   issuances produce distinct AADs; sealed keys are non-fungible
   across issuances even after a process restart.

4. State (epoch + pending nonces + consumed-set) is durable via the
   NonceStore interface. Two implementations:
   - MemoryNonceStore for tests / single-shot dev
   - DatabaseNonceStore for prod (luxfi/database; pair with encdb +
     ZapDB for at-rest encryption per Hanzo Base pattern)

Replay window default 1h; pending TTL 1h. GC reclaims both.

Tests cover all five required scenarios + nonce-binding + AAD-binding
asserts. -race clean.

Backwards-incompatible change to NewLocalReleaseGate / NewMPCReleaseGate
/ NewHSMReleaseGate signatures; no compat shim per audit guidance.
2026-04-27 21:33:04 -07:00
zeekay fdc21dc925 neutral: drop 'Lux'/'Hanzo' brand prefixes in user-facing strings
MPC and KMS are protocols, not products. The Go module path
github.com/luxfi/mpc remains canonical (location), but user-facing
strings (--help, README, comments, banners) are brand-neutral.
'Lux MPC' → 'MPC', 'Lux KMS' → 'KMS', 'Hanzo MPC' → 'MPC'.
2026-04-25 21:46:16 -07:00
zeekay 6b3567eeb4 feat(zap): adopt canonical port 9999 across MPC + transport
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.
2026-04-25 21:18:39 -07:00
zeekay 631cbbabc5 fix(mpc): HasSigningQuorum uses operator-facing threshold semantics
Follow-up to e08dba1. 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).
2026-04-20 18:52:49 -07:00
zeekay e08dba1faa fix(mpc): /healthz reports quorum — not all-peers-ready (P0-1)
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.
2026-04-20 18:48:31 -07:00
zeekay f142af8a90 feat(mpc): R6 requireRegulator invariant test + T3 CodeQL/SBOM workflow fix
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/
2026-04-18 22:41:45 -07:00
zeekay cd9bf93919 feat(mpc): treasury 3-of-5 + purge Postgres, SQLite-only
- Postgres purge: delete cas_postgres_test.go, strip PG sidecar from CI
- Treasury 3-of-5 governance: pkg/api/handlers_treasury.go + models
- Routes: POST /v1/mpc/treasury/wallets, POST /v1/mpc/treasury/sign,
  GET /v1/mpc/treasury/wallets/{id}
- Per-org HSM isolation via tenant-scoped cosigner key
- Threshold escalation: small=3-of-5, large=4-of-5
2026-04-18 21:07:26 -07:00
zeekay 56747fffd9 fix(webauthn): F4 LAX tightening — credentialHash floor + both-must-match
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  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  rollout)
- TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresent
- TestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresent
- TestLiveness_R38_LaxAcceptsCredentialHashOnly — current 
  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.
2026-04-18 18:05:11 -07:00
zeekay 181532593f R3-9: unify consumeBiometricChallenge on READ COMMITTED + GetForUpdate
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).
2026-04-18 17:32:56 -07:00
zeekay 0313b7333d R3-8: bind liveness envelope to WebAuthn credential + challenge
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).
2026-04-18 17:30:14 -07:00
zeekay 9713f68c9d R3-2: remove * wildcard bypass in API-key hasPermission
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.
2026-04-18 17:13:07 -07:00
zeekay 6698ed37ee fix(mpc): R2-1/R2-2 — webauthn verification + CAS under Postgres
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/
2026-04-18 17:02:33 -07:00
zeekay 6feb4d304b remove: "api" role pass-through from requireRole — one way only
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.
2026-04-18 16:46:41 -07:00
zeekay 6619ee765c fix(mpc): F2/F10/F20/F23 — CAS on approve + enum validation + pagination total
- 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.
2026-04-18 16:07:57 -07:00
zeekay 309aaed7bb fix(mpc): F3/F21/F24/F25 — CAS on session consume + reaper loop + policy gate
- 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).
2026-04-18 16:07:48 -07:00