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.
KEYGEN has been dead on the hanzo-mpc fleet — every request answered
503 "peers not ready" while /healthz reported `signing_quorum: true`
beside `ready: false`, and the counters said `ready=5 expected=4` in a
THREE node fleet. Both halves of that arithmetic were wrong.
SELF WAS COUNTED TWICE. A StatefulSet renders one command for every
replica, so the natural spelling hands each node the whole ensemble —
including itself. `peerMap[nodeID] = listenAddr` adds self by id, and the
matching `--peer` then adds it AGAIN under a synthetic `peer-N` id (an
unprefixed address gets a positional id by construction, so it can never
match a real node id, and filterSelf — which compares ids — could never
see it). Three nodes therefore reported four expected members. mpcd now
drops a --peer that names this node, matched by address: equal to
--listen, or a host whose FIRST DNS LABEL equals the node id (compared
whole, so mpc-node-10 is never mpc-node-1).
AND THE GATE WAS AN EQUALITY. `readyCount == len(peers)+1` reads an
OVERSHOOT as not-ready, so once the count passed the expected total
nothing could bring it back — permanently, across restarts. Signing
survived only because it already used >=. A readiness gate must fail
closed on too few, never on too many; both sites are >= now, so the
double-count could not have wedged keygen even before the fix above.
Verified by exercising isSelfAddr over the fleet's real addresses: the
live 3-node template on mpc-node-0 keeps 2 peers, expected total 3.
Co-authored-by: Hanzo Dev <[email protected]>
The previous pass ran plain goimports and left three files that golangci still
rejects:
pkg/api/zap_kms_server_test.go:16:1: File is not properly formatted (goimports)
.golangci.yml configures
goimports:
local-prefixes: github.com/luxfi/mpc
which puts github.com/luxfi/mpc imports in their own group after third-party.
Plain goimports does not do that and reported the tree clean, so the lint kept
failing on files a local run called fine. Re-run with -local.
goimports -l 0
goimports -local github.com/luxfi/mpc -l 3 -> 0
go build ./... OK
go test ./pkg/api/ ok
Co-authored-by: Hanzo Dev <[email protected]>
golangci-lint failed the lint job on
pkg/wallet/usage.go:53:1: File is not properly formatted (goimports)
goimports over pkg/, cmd/ and internal/ finds SIXTEEN such files. The linter
reports the first and stops, so fixing only the named one buys exactly one more
run before it names pkg/api/server.go, and so on for sixteen round trips.
All of them are alignment-only: gofmt aligns a var block's `=` to its longest
name, and these blocks had been edited without re-aligning. No import was added,
removed or reordered.
The dashboard/ work in this tree is untouched; all sixteen were clean.
goimports -l ./pkg ./cmd ./internal 16 -> 0
go build ./... OK
go vet ./pkg/... ./cmd/... OK
go test wallet/policy/registry/transport/zap all ok
Co-authored-by: Hanzo Dev <[email protected]>
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.
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]>
Every route in pkg/api is mounted under r.Route("/v1", ...) at server.go:272,
so /auth/oidc is /v1/auth/oidc and trade/submit is /v1/.../trade/submit. Nine
doc comments claimed an /api/v1 prefix that no route has ever carried:
handlers_trade_approval.go submit / approve / reject / pending
handlers_oidc.go auth/oidc
handlers_bridge.go generate_mpc_sig / complete
server.go the health-check note and the trade-endpoint note
handlers_approval.go already states the rule in-tree — "One canonical surface —
no `/api/` prefix, no v2" — so these were drift, not a second convention.
Comments that misdescribe a route are the same failure as a config that
misdescribes a threshold: they are what an audit reads. This is documentation
only; no route, handler or behaviour changes.
Remaining /api/ in this repo is all foreign and stays: pkg/hsm/zymbit.go (Zymbit
HSM device), pkg/hsm/providers.go (AWS IMDS), pkg/risk/chainalysis.go
(Chainalysis vendor). pkg/hsm/kms.go is a separate matter — it is an Infisical
client (/api/v3/secrets/raw with workspaceId/secretPath/type:shared), so moving
it to our native /v1/kms/orgs/{org}/secrets is a port, not a path swap.
Co-authored-by: Hanzo Dev <[email protected]>
api.KeygenResult carried only wallet_id, pubkeys and addresses — no threshold,
no participants. Keygen accepts no per-request threshold (the ring's own
--threshold governs), so a client had no way to learn what it actually got.
That is not theoretical. luxfi/kms pkg/keys/manager.go validates req.Threshold
(>=2, parties >= threshold), never sends it, then stores
`Threshold: blsResult.Threshold, Parties: len(blsResult.Participants)`. Its
client struct already declares both json fields, so with mpcd never emitting
them every validator key set on record reads Threshold:0 Parties:0 while the
caller had asked for 3-of-5 and passed validation. Ask for 3-of-5, silently get
the ring's config, and record zero.
Populate both from what the node is actually configured for: b.threshold, and a
new ConfiguredParticipants() (peers + self, sorted). Configured — not
GetReadyPeersIncludeSelf — because readiness answers "who is up right now",
while a key's party set is a property of the key and must not change because a
pod restarted; sorted so every node reports an identical list for the same key.
Additive and backward compatible: both fields are omitempty, and the KMS client
already decodes them. Clients can now compare against what they requested and
fail closed.
Tests: ConfiguredParticipants covers ring sizes, order normalization,
cross-node determinism, and a peerIDs aliasing guard.
Co-authored-by: Hanzo Dev <[email protected]>
pkg/threshold's init() pushed CGGMP21Scheme and FROSTScheme into the
process-global crypto/threshold registry as a side effect of import. Two
concrete hazards:
1. Shadowing. These adapters' signer keeps the secret nonce scalar k inside
NonceState (k||R). Anything resolving GetScheme(SchemeCMP) would silently
receive that signer; a NonceState crossing a party boundary yields the
private key as x = (s*k - H(m))/r. Opting into that must be deliberate.
2. Startup panic. RegisterScheme panics on a duplicate ID ([email protected].2
threshold/registry.go:24-26), so the day crypto/threshold ships its own
CMP/FROST scheme, merely importing this package kills the process at start.
Replace init() with an exported Register() documenting both hazards. Nothing
regresses: the daemon never reached this code (`go list -deps ./cmd/mpcd` does
not contain pkg/threshold) and the only importers, examples/, use
NewUnifiedThresholdAPI and never touch the registry.
register_test.go pins the invariant: TestImportDoesNotRegister fails if an
ambient init() comes back (verified red/green), TestRegisterIsExplicit proves
explicit opt-in still works.
Co-authored-by: Hanzo Dev <[email protected]>
Both protocol adapters shipped a Verify that returned false unconditionally —
cggmp21 with "For now, return false", frost with "doesn't support ECDSA". That
is not a safe default dressed up as one. A verifier that always fails is
indistinguishable from a verifier that works and was handed a bad signature, so
a caller using it as a signing-path health check sees a permanent, unexplained
failure, and a caller treating "unsupported" as skippable gets no verification
at all.
Both now verify. FROST is Schnorr rather than ECDSA, but its group key is still
a secp256k1 point and *ecdsa.PublicKey is only how this package's interface
carries one, so the point is recovered and handed to the FROST verifier.
pkg/protocol/curvekey.go holds the conversion both adapters need, replacing two
hand-rolled decompressions (a Tonelli-Shanks square root in cggmp21, a separate
helper in frost). CurvePoint goes the other way, and it validates rather than
assumes: an (X, Y) that is not on the curve is REJECTED, not silently corrected.
Compression keeps only the parity of Y, so a conversion that trusts X and
parity alone will happily turn an inconsistent key into a valid point and then
verify signatures against a key the caller never named. X=1 is a real X
coordinate on secp256k1, which is how the test that found this was written.
X is left-padded with FillBytes; big.Int drops leading zeros, so about one key
in 256 would otherwise encode short and fail to convert at all.
Bumps luxfi/threshold to v1.12.5 for pkg/quorum.
Co-authored-by: Hanzo Dev <[email protected]>
mpcd implements only CGGMP21 (secp256k1 ECDSA), FROST (ed25519/ristretto), and
LSS signing sessions — there is no bls12-381 threshold signing session. The KMS
validator "bls" slot is secp256k1/CGGMP21; the "corona" slot is ed25519/FROST.
Neither is bls12-381 aggregation nor post-quantum. A "post-quantum" claim over
classical ed25519 is materially misleading.
- deployments/customer-3of5/topology.yaml: the `bls` entry asserted
curve=bls12-381, protocol=BLS-Threshold for "Lux consensus (validator
signing)". Corrected to secp256k1/CGGMP21 (what is actually custodied) with a
note that mpcd cannot threshold-sign bls12-381 and that Lux consensus BLS is a
native per-validator key, never custodied here. (Customer manifest — highest
priority.)
- pkg/api/handlers_kms.go: the ValidatorKeySet doc called the custodied key a
"Corona PQ-threshold key" — it is ed25519/FROST, not PQ and not the Corona
R-LWE lattice protocol. Corrected.
- README.md: added a "Validator key slots vs. engine protocols" note so the
secp256k1/ed25519 custody slots are not read as the bls12-381/lattice engine
protocols of the same name.
Labeling only — no crypto change. The live 2-of-3 mpcd ECDSA is sound.
Co-authored-by: Hanzo Dev <[email protected]>
The signing sessions emitted a raw, possibly high-S S with a recovery byte that
was then dropped at every serialization boundary. luxfi/evm ecrecover rejects a
high-S signature (EIP-2 "invalid sender"), and without a propagated recovery id
a caller must guess v (wrong guess => wrong sender). This is what broke the KMS
/sign output.
- signing_session.go + signing_session_lss.go: produce the signature through
the shared threshold helper s.signature.SigEthereum() (EIP-2 low-S + recovery
id in ONE place), then self-verify the recovery id against the wallet's own
public key via CalculateRecoveryByte and FAIL CLOSED on mismatch — never emit
a signature that binds to the wrong address.
- pkg/api SignResult: carry V (recovery id "0"/"1").
- cmd/mpcd TriggerSign: propagate V from the signing session's signature_recovery
and assemble the canonical 65-byte r‖s‖v so KMS /sign never returns an empty
signature.
- handlers_kms REST: include v in the response.
Requires the non-mutating, tested SigEthereum in threshold v1.12.4; bumps
luxfi/threshold v1.12.0 -> v1.12.4 (MVS pulls its transitive updates). Builds
GOWORK=off; pkg/api + pkg/mpc tests green.
Co-authored-by: Hanzo Dev <[email protected]>
The 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]>
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.
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.
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.
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).
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.
* 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.
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.
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).
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.
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
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.
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.
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.
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.
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).
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.
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/
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.
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).