Skip to content

feat(covenantsigner): wallet-signed EIP-712 approvals + cooperative REDEEM/RENEW - #4169

Open
piotr-roslaniec wants to merge 8 commits into
fix/tob-tbtcacext-remediationfrom
feat/covenant-eip712-eth-leg
Open

feat(covenantsigner): wallet-signed EIP-712 approvals + cooperative REDEEM/RENEW#4169
piotr-roslaniec wants to merge 8 commits into
fix/tob-tbtcacext-remediationfrom
feat/covenant-eip712-eth-leg

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Enables wallet-signed (eth_signTypedData_v4) covenant artifact approvals and adds
the cooperative REDEEM / RENEW action paths to the keep-core covenant signer.

This is the keep-core prerequisite (section A of the VBA EIP-712 change-list) that must land
before this covenant work goes live: today the signer verifies a bare EIP-712 struct
hash
(no 0x1901, no domain separator) against the depositor's secp256k1 key, which
no connected ETH wallet can produce. This PR domain-wraps the digest, binds the depositor
approval to a pinned ETH identity via ecrecover, and builds out the redeem/renew
transaction path.

Stacked on top of #3882 (feat/psbt-covenant-final-project-pr) — the only branch carrying
pkg/covenantsigner/. Tracked by tlabs-xyz/vba-dashboard#172:
https://github.com/tlabs-xyz/vba-dashboard/issues/172

What changed

A1 - Domain-wrap the digest (EIP-712 v2, off-chain / salt-based).
The approval digest is now keccak256(0x1901 ‖ domainSeparator ‖ structHash) with domain
EIP712Domain(string name,string version,uint256 chainId,bytes32 salt) (no
verifyingContract, since verification is off-chain). name = "tBTC Covenant Artifact Approval", version = "2", chainId + salt from signer config (default salt =
keccak256("tBTC Covenant Artifact Approval Domain v2")). Domain params are threaded from
config → validation options → digest, including the exported ComputeArtifactApprovalDigest
used by pkg/tbtc.

A2 - ETH identity binding. Optional EthAddress on DepositorTrustRoot (keyed by
route/reserve/network). When pinned, the depositor approval is verified via
ecrecover-and-compare (65-byte r‖s‖v, v in {27,28} or {0,1}, low-S enforced);
otherwise it falls back to the secp256k1 script key. Custodian role stays secp256k1.

A3 - Hard cut to approvalVersion = 2 (v1 rejected). Nothing is deployed yet, so no
transition window is needed.

A4 - Vectors. Regenerated the v2 approval vectors and added a real-wallet vector
whose digest is cross-verified byte-identical against the independent eth_account
(0.13.7) EIP-712 encoder
— genuine eth_signTypedData_v4 compatibility, not a
self-referential check. A startup guard rejects a config that pins depositor ETH addresses
while eip712ChainId == 0.

A5 - Cooperative REDEEM / RENEW path. New CovenantAction discriminator
(MIGRATION / REDEEM / RENEW; empty defaults to MIGRATION), RedeemDestinationReservation /
RenewDestinationReservation, redeem/renew commitment schemas (SHA-256 over canonical JSON,
mirroring migration), action-dispatched validation, and an action branch in
buildCovenantTransactionBuilder. The built transaction is proven to pay exactly the
committed output — the transaction plan's destinationValueSats is bound to the committed
output value.

Commitment schemas (normative contract for the client repos to mirror):

  • redeem: {reserve, epoch, route, revealer, vault, network, outputScriptHash, outputValueSats}
  • renew: {reserve, epoch, route, revealer, vault, network, nextCovenantScriptHash, nextMaturityHeight, outputValueSats}

Client-side contract (must be mirrored byte-for-byte)

  • digest = keccak256(0x1901 ‖ domainSeparator ‖ structHash).
  • Struct: ArtifactApproval(uint8 approvalVersion,bytes32 route,bytes32 scriptTemplateId,bytes32 destinationCommitmentHash,bytes32 planCommitmentHash), approvalVersion = 2.
  • route / scriptTemplateId are bytes32 = keccak256(identifier string) — the dApp must
    pre-hash them; a wallet encoder given the raw bytes32 reproduces the digest.

Verification

  • go test ./pkg/covenantsigner/... — 156 passed
  • go test ./pkg/tbtc/... — 275 passed (full suite)
  • go build ./... — green; gofmt clean
  • Headline flow proven end-to-end: a wallet-signed (ETH-identity) cooperative REDEEM is
    accepted through Service.Submit, and the signed transaction pays exactly the committed
    redeem/renew output (scriptPubKey + value).

Out of scope

The section-B/C client sync (covenant-manager, dashboard engine.ts, verification kit,
co-sign service, dual-connection wallet UX) lives in other repos. The pre-existing migration
depositScript-as-output gap is flagged, not fixed here — the redeem/renew paths sidestep it
by carrying an explicit output scriptPubKey.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3bed71a9-83c3-4b06-9fac-307c3bf20492

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/covenant-eip712-eth-leg

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@piotr-roslaniec

Copy link
Copy Markdown
Collaborator Author

Security-review follow-ups (deferred from this PR)

The hardening findings addressed in d3afaa7f7 are self-contained. Three further findings were intentionally not included here because each reaches beyond the signer or conflicts with a recent decision. Documenting them so they aren't lost:

1. REDEEM/RENEW carry no freshness/authority bound — deferred (needs decision)

MIGRATION is gated by an ed25519 migrationPlanQuote with an expiresAt check (validation_quote.go), which is skipped for REDEEM/RENEW (validation.go:353). ArtifactApprovalPayload has no expiry field, so a REDEEM/RENEW depositor signature is long-lived and replayable within the epoch. Mitigation present: activeOutpointTxid/Vout is bound inside planCommitmentHash, pinning the input UTXO.

Why deferred: the "cryptographically bind and enforce signer-approval expiry" feature was reverted twice recently (af236a25a, 2faf6e764). Re-introducing a freshness field reverses that deliberate decision and is a protocol change requiring coordinated client + orchestrator updates. Needs a design call before implementation.

Options: add an expiry field to the approval payload; require a parallel REDEEM/RENEW plan-quote; or explicitly document that epoch-scoped UTXO binding satisfies the threat model and why.

2. MigrationTransactionPlan name leaks into REDEEM/RENEW — deferred (cross-repo)

REDEEM payout and RENEW re-lock values bind to MigrationTransactionPlan.DestinationValueSats; the struct, fields, and JSON key migrationTransactionPlan all say "migration" while serving three actions. Every mirroring client (covenant-manager / dashboard / verification-kit) must send a field literally named "migration" to redeem/renew.

Why deferred: a back-compat input alias alone doesn't fix the response side (the server still emits migrationTransactionPlan), so client-facing confusion persists until the three client repos are updated together. Belongs in a coordinated cross-repo rename (e.g. to covenantTransactionPlan) rather than a signer hardening commit.

3. Empty depositorTrustRoots falls back to request-supplied key — already addressed

When depositorTrustRoots is empty and structured signing is enabled, the depositor key is caller-supplied and only a warning is logged (server.go:110-118).

Status: this is already enforceable via the existing requireApprovalTrustRoots config flag — validateRequiredApprovalTrustRoots (server.go:190) hard-errors on missing trust roots when the flag is set. The warn-only default is the intentional opt-in posture. Recommend documenting the flag rather than changing the default to a hard error (which would break trustless dev/test deployments).


Suggested next steps: track (1) and (2) as separate issues; treat (3) as resolved via config documentation.

@lrsaturnino

lrsaturnino commented Jul 29, 2026

Copy link
Copy Markdown
Member

2 issues found, both fixed and pushed.

Fixed: REDEEM/RENEW requests unconditionally rejected when migrationPlanQuoteTrustRoots is configured (Major)
normalizeRouteSubmitRequest in pkg/covenantsigner/validation.go called normalizeMigrationPlanQuote unconditionally, while validateCommonRequest already gated that same call on request.ResolvedAction() == CovenantActionMigration. Any service configured with migration plan quote trust roots therefore rejected every REDEEM and RENEW submission with request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured, even though those actions don't use a migration plan quote. Fixed by applying the same action gate in normalizeRouteSubmitRequest, defaulting the normalized quote to nil for REDEEM/RENEW. Commit 91c99ba4f549c155158304c4347edfc145d1ddac.

Fixed: wallet-signed (ETH-identity) approval accepted at Submit is rejected at every subsequent Poll (Major)
Service.Poll always set policyIndependentDigest: true and never passed depositorTrustRoots, so validateCommonRequest's depositor-ETH-address resolution gate was unreachable on the poll path — wallet-signed (ETH ecrecover) approvals fell back to a secp256k1 check that could never verify them, failing every poll after a successful submit. Fixed by resolving and persisting the depositor's ETH address on the Job at Submit time and threading that pinned value into Poll's re-validation via a new validationOptions.pinnedDepositorEthAddress field, instead of relying on live trust-root resolution. Commit db4f6bff55c0916e004fdf16353c0e43bf85bbc5.

Both fixes are covered by new regression tests (added to redeem_renew_test.go and a submit-then-poll test respectively), which fail before the fix and pass after. Full package test suite (including -race), go vet, gofmt, staticcheck, and go build ./... all pass, along with builds of external consumers (pkg/tbtc, cmd, config).

No unresolved findings.

lrsaturnino
lrsaturnino previously approved these changes Jul 29, 2026

@lrsaturnino lrsaturnino left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 findings fixed and pushed; none require follow-up.

@lrsaturnino
lrsaturnino self-requested a review July 30, 2026 10:53
@lrsaturnino
lrsaturnino dismissed their stale review July 30, 2026 10:57

Silent regression: #4169 refactored the migration-destination output logic into a new covenantDestinationOutputScript helper whose MIGRATION case returns the raw deposit script directly—in lines 776-780, there's no P2WSH wrap. #4126's audited fix (TOB-TBTCACEXT-2) wraps that same destination in payToWitnessScriptHash at line 854. #4169 has zero calls to payToWitnessScriptHash anywhere in pkg/tbtc — so a naive merge that keeps #4169's helper as-is would silently undo the audited fix.

@lrsaturnino
lrsaturnino changed the base branch from feat/psbt-covenant-final-project-pr to fix/tob-tbtcacext-remediation July 30, 2026 20:39
@lrsaturnino
lrsaturnino force-pushed the feat/covenant-eip712-eth-leg branch from db4f6bf to 7a556f3 Compare July 30, 2026 20:39
@lrsaturnino
lrsaturnino requested review from lrsaturnino and removed request for lrsaturnino July 30, 2026 20:52
@lrsaturnino

Copy link
Copy Markdown
Member

Reconciled and tested against #4126 @ b5e1166 on 2026-07-30T20:53:12Z.

@lrsaturnino lrsaturnino left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@lrsaturnino
lrsaturnino force-pushed the fix/tob-tbtcacext-remediation branch from b5e1166 to d1a909a Compare July 30, 2026 21:24
piotr-roslaniec and others added 8 commits July 30, 2026 18:24
…-signed ETH leg (v2)

Enable wallet-signed (eth_signTypedData_v4) covenant artifact approvals by
domain-wrapping the approval digest and binding the depositor approval to a
pinned Ethereum identity.

- Wrap the digest: keccak256(0x1901 ‖ domainSeparator ‖ structHash) with an
  off-chain, salt-based EIP712Domain(name,version,chainId,salt). chainId+salt are
  configured on the signer (Config.EIP712ChainId/EIP712Salt) and threaded through
  validation and the tBTC engine so recomputed digests stay consistent.
- Verify the depositor approval against an optional pinned DepositorTrustRoot
  ethAddress via ecrecover-and-compare (65-byte sig, v in {27,28}/{0,1}, low-S
  enforced); fall back to the secp256k1 script key when unset. Custodian
  verification is unchanged.
- Hard cut approvalVersion to 2 (v1 rejected).
- Regenerate approval vectors to _v2; add a real-wallet signature vector whose
  digest is cross-verified byte-identical against an independent EIP-712 encoder,
  an ecrecover integration test (accept + reject), and a startup guard that fails
  loudly when an ethAddress is pinned without a configured chainId.

Refs: keep-core prerequisite for vba-dashboard#172.
keep-core previously implemented only the MIGRATION covenant action. Add the
cooperative REDEEM (pay out to a depositor-chosen address) and RENEW (re-lock
into the next-epoch covenant) actions end to end.

- Add a CovenantAction discriminator (MIGRATION/REDEEM/RENEW) to RouteSubmitRequest
  (empty defaults to MIGRATION) plus RedeemDestinationReservation and
  RenewDestinationReservation, alongside the existing migration destination.
- Define the redeem/renew destination commitment schemas (SHA-256 over canonical
  JSON, mirroring the migration commitment) binding the covenant identity to the
  payout/next-covenant script hash and value. These are the normative schemas the
  client (covenant-manager / dashboard / verification kit) must mirror.
- Dispatch destination validation on the action and reject destinations that do
  not belong to it; the migration plan-quote path stays MIGRATION-only. Bind the
  transaction plan's destinationValueSats to the committed output value so the
  signed output cannot diverge from what the depositor approved.
- Branch the transaction builder on the action to pay the migration deposit
  script, the redeem payout script, or the renew next-covenant script; the input,
  anchor output, and threshold signing are shared unchanged.

Tests cover the commitment schemas, action-dispatched validation (accept/reject),
and assert the signed transaction pays exactly the committed redeem/renew output.

Refs: keep-core prerequisite for vba-dashboard#172.
…new actions

trustRootLookupScope read the scope network only from MigrationDestination, so a
wallet-signed REDEEM/RENEW request (which carries a redeem/renew destination, not
a migration one) resolved an empty network and failed depositor trust-root
matching. Read the network from the destination for the request's action.

Add the headline end-to-end test for vba-dashboard#172: a cooperative REDEEM
whose depositor artifact approval is signed by a pinned ETH identity is accepted.
Address findings from the PR security/simplicity review:

- Reject the zero address as a pinned depositor ethAddress so a
  misconfiguration cannot make ecrecover compare against 0x0.
- Reject mixing ethAddress presence across network entries of the same
  (route, reserve) depositor trust root, closing a silent ETH->secp
  verification downgrade steered by the request-supplied network value.
- Fail closed on unrecognized artifact-approval roles in the authenticity
  loop instead of silently skipping them.
- Correct the defaultArtifactApprovalDomainSalt comment: cross-deployment
  separation comes from the unique reserve/vault in destinationCommitmentHash
  plus trust-root scoping, not from scriptTemplateId; recommend per-deployment
  eip712Salt overrides.
- Drop the misleading "predate the field" backward-compatibility note on
  CovenantAction; v1 approvals are hard-rejected.
…TION action

normalizeRouteSubmitRequest called normalizeMigrationPlanQuote
unconditionally, unlike validateCommonRequest which already gates the same
call on request.ResolvedAction() == CovenantActionMigration. Since REDEEM
and RENEW requests carry no migrationPlanQuote field, any deployment
configuring WithMigrationPlanQuoteTrustRoots (a normal production setup for
a deployment that also verifies MIGRATION plan quotes) rejected every
REDEEM and RENEW submission at Submit, regardless of how well-formed the
request otherwise was.

Apply the same action gate in normalizeRouteSubmitRequest so the plan quote
is only normalized for MIGRATION, defaulting to nil for REDEEM/RENEW. Add
coverage submitting REDEEM and RENEW requests against a service configured
with migrationPlanQuoteTrustRoots, asserting acceptance.
…ation

Poll re-validates the resubmitted request via validateCommonRequest with
policyIndependentDigest set, which skips depositor trust-root resolution
entirely (and Poll never even passed depositorTrustRoots in). For the
secp256k1 depositor key this was harmless: Submit already enforces
scriptTemplate.depositorPublicKey == trust root, so the value embedded in
the resubmitted request is equivalent. The wallet-signed ETH identity has
no such request-embedded equivalent, so Poll always fell back to verifying
the ETH-style ecrecover signature against the secp256k1 script key instead,
which can never succeed. A depositor approval accepted at Submit was
therefore rejected at every subsequent Poll, making the wallet-signed
approval flow unusable end to end.

Resolve the depositor's ETH address once at submit time and persist it on
the job record. Poll now looks up that pinned value and threads it through
its re-validation instead of trying to re-derive it from live
depositorTrustRoots config, keeping the policy-independence Poll relies on
intact.

Added a submit-then-poll regression test for the ETH-signed self_v1 path.
…o the remediation base

Replaying the EIP-712 ETH-leg work onto the Trail of Bits remediation base
widens two signatures that the base's own tests still called with the old
arity, and moves the request digest: the certificate contract the base
introduced (version 2 plus a required endBlock) is part of the normalized
request the digest covers.

Adapt the base's newCovenantSignerEngine and normalizeSignerApprovalCertificate
call sites, and regenerate the three canonical vectors from the production
digest code. The canonical requests themselves are unchanged; only the derived
request digests move.

Add a guarded in-package generator so the next protocol-version change can
refresh the fixture reproducibly instead of by hand-edited hashes. Without
UPDATE_GOLDEN it verifies the checked-in digests, so a stale fixture fails
loudly.
The live-only wallet-state gate predates the cooperative actions, so its
rationale was written as a migration-only argument even though REDEEM and RENEW
now flow through the same check. Nothing asserted that the two new actions are
in fact held to that rule.

Cover every non-live state for both actions, and restate the helper's rationale
in action-neutral terms: a signer approval certificate binds wallet identity,
members hash, and threshold, but nothing about wallet state, so a certificate
issued while a wallet was live stays replayable after the protocol has
deauthorized it. Widening any cell needs an explicit decision about why that
reuse is safe, which this matrix now forces to be deliberate.
@lrsaturnino
lrsaturnino force-pushed the feat/covenant-eip712-eth-leg branch from 7a556f3 to 41f4d9e Compare July 30, 2026 21:25
piotr-roslaniec added a commit that referenced this pull request Jul 31, 2026
…4178)

## What

Adds five regression tests closing genuinely-missing defensive-property
coverage for the
covenant signer. Stacked on `feat/psbt-covenant-final-project-pr`
(#3882); tests only, no
production changes.

## Why

A coverage review of the signer's defensive properties (auth, body cap,
timeouts, replay,
reorg guard, approval-certificate validation) started from a ~27-item
wishlist but found that
**~22 already exist** in the suite. Rather than duplicate them, this PR
adds only the handful
that were actually absent, plus one boundary complement.

## Tests added

**`pkg/tbtc/signer_approval_certificate_test.go`**
- `TestVerifySignerApprovalCertificateRejectsUnsupportedVersion` — the
`certificateVersion == 1`
hard gate (`signer_approval_certificate.go:230`) had no test; rejects
both `0` and `2`.
-
`TestVerifySignerApprovalCertificateRejectsUnsupportedSignatureAlgorithm`
— the
  `tecdsa-secp256k1` algorithm gate (`:233`) had no test.

**`pkg/tbtc/covenant_signer_test.go`**
- `TestEnsureActiveOutpointFinalityPropagatesProviderError` — the
finality guard's error path
(`covenant_signer.go:614`) was untested; existing tests only cover known
confirmation counts
above/below the threshold. Asserts fail-closed when confirmations cannot
be determined.

**`pkg/covenantsigner/server_test.go`**
- `TestServerAcceptsBodyExactlyAtLimit` — complements the existing
`maxRequestBodyBytes+1`
rejection in `TestServerBoundaryErrorMatrix` by pinning the inclusive
boundary (a body of
  exactly `maxRequestBodyBytes` must pass the size guard).

**`pkg/covenantsigner/store_lock_test.go`**
- `TestNewServiceReleasesLockOnInitFailure` — asserts `NewService`
releases the store file lock
when a later init step fails (`service.go:135-142` defer), so a signer
that failed to start
once can restart against the same data dir. Only the store-level
`Close()` release was tested.

Dropped from the original list as **already covered**: the
non-loopback-without-token startup
guard (already asserted inside
`TestInitializeRejectsInvalidOrUnavailablePort`), Poll expiry
checks, confirmation thresholds/boundaries, dedup, lock contention,
digest/signer-set/high-S/DER
cert rejections, trust-root pinning, quote expiry, oversized-body, and
healthz-exempt.

## Verification

- `go test ./pkg/tbtc/ ./pkg/covenantsigner/` — pass.
- `go test -race ./pkg/covenantsigner/` — pass.
- `gofmt -l` clean, `go vet` clean.
- Note: `go test -race ./pkg/tbtc/` trips a **pre-existing** data race
in
`TestSigningExecutor_SignBatch` (reproduces on this branch's base tip
`af236a25a` with none of
these changes; unrelated to the covenant signer). Flagging, not fixing
here.

## Open questions for @lrsaturnino

Two items surfaced during the review that need an owner decision; they
gate two further tests
not included here:

1. **Signer-approval expiry binding.** The three tip commits
(`e572ea3a8` / `a882cf142` /
`ccbc4c3ac`, "cryptographically bind + enforce signer-approval expiry")
were reverted.
`SignerApprovalCertificate.EndBlock` is still checked at Poll
(`service.go:275-290`), but the
ECDSA signature covers `approvalDigest` only — `EndBlock` is not inside
the signed digest, so
its value is caller-settable. Is the binding intended to re-land, or is
Poll-time check-only
the accepted design? (Determines whether we add a
`TestEndBlockIsBoundToSignature`.)

2. **`chainId == 0` guard on the EIP-712 leg.** On the follow-up
`feat/covenant-eip712-eth-leg`
(#4169), `EIP712ChainID` is folded into the EIP-712 domain separator,
but nothing rejects
`EIP712ChainID == 0` at startup — a misconfig fails soft (no real
`eth_signTypedData_v4`
signature is ever produced under chainId 0, so verification just never
matches). Intentional,
   or should we add a startup guard + test?
@piotr-roslaniec

Copy link
Copy Markdown
Collaborator Author

@lrsaturnino Not merging this yet because it's based on audit remediation branch and I don't want to pollute it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants