diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml index e223dc2f85..6b6e6f131f 100644 --- a/.github/workflows/frost-cgo-integration.yml +++ b/.github/workflows/frost-cgo-integration.yml @@ -146,6 +146,11 @@ jobs: frost_tbtc_state_anchor_bootstrap_facts \ frost_tbtc_acknowledge_state_witness_checkpoint \ frost_tbtc_recover_state_witness_checkpoint \ + frost_tbtc_begin_share_repair_session \ + frost_tbtc_finish_share_repair_session \ + frost_tbtc_share_repair_part1 \ + frost_tbtc_share_repair_part2 \ + frost_tbtc_install_repaired_share \ frost_tbtc_version \ frost_tbtc_abi_version; do if ! nm -D --defined-only "$lib" | grep -q " ${sym}$"; then diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 91e4eae83f..8efc819ec5 100644 --- a/ci/frost-signer-pin.env +++ b/ci/frost-signer-pin.env @@ -15,4 +15,4 @@ # # After the scaffold and mirror branches merge into one, replace the cross-branch # checkout with an in-tree cargo build and retire this pin (keep the gate). -FROST_SIGNER_MIRROR_REF=08b6d6f40027016101f32c1ffc509fde4746d0a2 +FROST_SIGNER_MIRROR_REF=c0d08c0c0c831c54ff765c9520be674789b12efa diff --git a/cmd/start.go b/cmd/start.go index c9323ff311..71b2200776 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "time" @@ -249,6 +250,10 @@ func start(cmd *cobra.Command) error { clientConfig.Ethereum.Network, ) if err != nil { + if errors.Is(err, tbtc.ErrFrostShareRepairMaintenanceComplete) { + logger.Infof("%v", tbtc.ErrFrostShareRepairMaintenanceComplete) + return nil + } return fmt.Errorf("error initializing TBTC: [%v]", err) } } diff --git a/docs/development/frost-readiness-manifest.adoc b/docs/development/frost-readiness-manifest.adoc index 1382c48daa..9acbdd984b 100644 --- a/docs/development/frost-readiness-manifest.adoc +++ b/docs/development/frost-readiness-manifest.adoc @@ -15,6 +15,9 @@ This manifest was originally planned for the tBTC monorepo's binaries adopt the `frost_roast_retry` build tag once the gates below read `present` (`docs/development/frost-roast-retry-rollout.adoc`). +Share repair is a separately gated disaster-recovery capability; its +ceremony, crash matrix, and production-shape test are specified in +`docs/development/frost-share-repair.adoc`. == Gates diff --git a/docs/development/frost-share-repair.adoc b/docs/development/frost-share-repair.adoc new file mode 100644 index 0000000000..3ef6778180 --- /dev/null +++ b/docs/development/frost-share-repair.adoc @@ -0,0 +1,293 @@ += FROST Share Repair Disaster-Recovery Runbook + +This procedure repairs one lost `frost-tbtc-signer-v1` signing share from an +exact threshold of healthy shares. It is a disaster-recovery mechanism, not a +capacity mechanism: it does not increase the threshold, group size, signer +state budget, or anchor history budget. If fewer than `threshold` healthy +shares remain, this procedure cannot recover the wallet. + +The repair primitive is wrapped by four independent safety boundaries: + +* one short-lived, offline-authority-signed authorization names the wallet, + key group, target seat, exact helper set, old and new stores, recovery epoch, + public-package commitment, and nonce; +* a second signature from that authority binds the authorization digest to the + exact helper-then-target list of native transport public keys and native + store fingerprints; +* the repaired Rust state is not returned to Go until the existing external + state-anchor CAS acknowledges it; and +* the repaired seat remains disabled until a second offline-authority-signed + activation lease attests the old-store tombstone. The lease registry root is + pinned in the production activation manifest and exchanged on every message + from a recovered seat. + +No raw key package, complete delta/sigma scalar set, or signing share crosses +the Rust ABI. Rust derives authorization-, seat-, role-, and store-bound +transport keys from the same protected state-encryption root that already +guards the native share store. Only compressed public keys and fixed-size +authenticated ciphertexts cross the ABI. Plaintext repair scalars use owned, +zeroizing Rust containers and are stream-combined inside the native signer. +This minimizes secret residency in Go; an in-process FFI library is not a +hardware isolation boundary against arbitrary code that can inspect its own +address space. + +== Preconditions + +Do not start recovery until all of these are true: + +. The incident owner has identified one lost target seat and verified that at + least `threshold` healthy seats retain the same exact key group and share + epoch zero. +. The target has an exclusively owned replacement native signer store. Its + descriptor-derived fingerprint is the authorization's + `new_store_fingerprint`; the lost store's fingerprint is + `old_store_fingerprint`. The signed manifest used for the target's one-shot + maintenance startup must already pin this replacement fingerprint (with a + zero share-repair registry root); otherwise the normal store-binding gate + rejects startup before recovery begins. +. The target's non-secret wallet registry metadata has been restored. It must + identify the same wallet, group operators, target seat, and key-group handle. +. Every participant has reconciled the independently authenticated state + anchor, installed the protocol-output barrier, uses ABI 5.0 or newer, and + has the production command-backed state-key provider available. +. The offline authority can verify the authorization inputs independently. An + authorization is valid for at most 24 hours and contains exactly + `threshold` sorted, distinct helpers; the target cannot also be a helper. + +Choose the helper set once. Adding a replacement helper after signing the +authorization is prohibited; issue a new nonce and authorization instead. + +== Native transport preflight and roster ceremony + +The roster is a mandatory second authorization step. Once issued from trusted +native preflight evidence, it prevents the runtime API from substituting a +caller-controlled recipient key and asking Rust to encrypt a complete +repair-scalar set to that key. Do not hand-author a roster from caller-supplied +public keys. + +. Place the signed base authorization in an owner-only `0600` regular file on + every named operator. Configure an initially absent output path in an + owner-only `0700` directory: + +[source,toml] +---- +[tbtc] +FrostShareRepairTransportPreflightAuthorizationPath = "/canonical/absolute/path/share-repair-authorization.json" +FrostShareRepairTransportPreflightOutputPath = "/canonical/absolute/path/share-repair-transport-preflight.json" +---- + +. Start the production binary. It verifies the production manifest, reconciles + the state anchor, and installs the native output barrier before preflight. + For every locally owned named seat, Rust validates the signed authorization, + proves the role (a helper must load its retained key package; the target must + be running the signed `new_store_fingerprint`), and emits the derived public + transport key plus that exact store fingerprint. The artifact is published + atomically with no-replace semantics, then the process exits cleanly. +. Collect every operator artifact through an independently authenticated + incident channel. The offline authority must verify each artifact's source, + authorization digest, participant ownership, role, and store fingerprint. + In particular, the target entry must name `new_store_fingerprint`. Accepting + a target key supplied only by the Go host defeats scalar confidentiality. + The artifact is not a native hardware attestation: if the incident threat + model includes a modified host during preflight, require independent + machine/workload attestation or collect the entry through a directly + controlled native/KMS provisioning path. Keep the production launch gate + closed when neither control is available. +. Build the canonical roster with helpers in the authorization's ascending + order followed by the target. Reject missing, extra, reordered, + duplicate-key, or mismatched-store entries. Sign the + `tbtc-frost-share-repair-transport-roster/v1` digest with the same offline + authority, then place the base authorization and roster in one + `tbtc-frost-share-repair-bundle/v1` JSON object. +. Remove both preflight paths before restarting. Deploy the final owner-only + bundle on every participant. A state-key rotation, store replacement, seat + change, authorization change, or altered roster changes the derived key or + binding and fails closed; rerun preflight and issue a new roster. + +Preflight is allowed from `issued_at_unix` through (but not including) +`expires_at_unix`, including before `not_before_unix`; repair arithmetic still +requires the full not-before window. `FinishShareRepairSession` wipes only the +in-memory derived-key cache. The key can be re-derived from the protected state +root while the signed authorization remains unexpired; authorization expiry is +the API erasure boundary. + +== One-shot maintenance phase + +Place the signed recovery bundle in an owner-only regular file on exactly the +operators that control a named helper or target seat. On each participating +operator, configure: + +[source,toml] +---- +[tbtc] +FrostShareRepairAuthorizationPath = "/canonical/absolute/path/share-repair-bundle.json" +FrostShareRepairMaintenanceTimeout = "10m" +---- + +`FrostShareRepairMaintenanceTimeout` defaults to ten minutes and must be from +ten seconds through one hour. Start the normal production binary. Startup +authenticates the normal manifest and anchor first, then runs only the dedicated +`frost/share_repair/v1` protocol on a channel scoped by the authorization and +roster digests. Every announcement must equal the signed native key/store +entry. Only locally controlled seats named by the authorization participate. +Public-package frames are capped at 16 KiB. Each receiver admits at most 20 +KiB from one sender and 2 MiB total during the maintenance run, while the +pre-rendezvous queue independently applies those byte caps plus its 1,024-frame +cap. Exceeding a cap fails closed or causes the bounded maintenance run to time +out; it is not a reason to increase the limits during an incident. + +One exact signed authorization-and-roster bundle defines one deterministic +native plaintext repair transcript, including across +`FinishShareRepairSession` and process restart. ECIES envelope randomness is +independent, so retransmissions of the same valid slot can have different +ciphertext bytes; receivers retain only the first correctly shaped candidate +per sender/slot. A same-bundle retry is allowed only with the same frozen +transport and derivation version and unchanged participant stores/state-key +roots. If the signer algorithm or version changes, stop every old +retransmitter and issue a fresh nonce, authorization, preflight roster, and +bundle before restarting repair. + +All participating processes exit successfully when maintenance completes. +This is intentional. Remove `FrostShareRepairAuthorizationPath` before every +restart; leaving it configured would request another one-shot maintenance run. +The target log confirms that Rust durably installed the repaired seat and the +external anchor acknowledged the new state tip. Helper completion never means +that a helper store changed. + +The target publishes one installed receipt whose network retransmitter remains +active until every exact authorized helper validates it and acknowledges the +receipt digest. Each helper keeps its single acknowledgement retransmitter alive +until the target confirms the exact acknowledgement set or the maintenance +deadline expires; the target confirmation merely permits early helper exit. A +target timeout while waiting for acknowledgements is reported as a maintenance +failure even though the target result states that installation and anchor +acknowledgement already completed. In that case, do not repeat the repair +arithmetic; follow the post-install recovery procedure below. + +An exact target install is idempotent. Rust permits the install endpoint to +recognize the identical recovered-seat record after authorization expiry, but +the Go maintenance protocol does not use an expired authorization and helpers +cannot regenerate repair material with one. If the external anchor outcome is +uncertain after Rust persistence, restart without the maintenance authorization +path: startup reconciliation authenticates the anchor history and catches the +anchor up to the durable local checkpoint. The idempotent Rust rule is a final +replay guard, not an alternative to startup reconciliation. + +== Cutover phase + +The repaired target is deliberately unusable after maintenance. Its native +inventory changes to +`tbtc-signer-retained-key-package-inventory/v2` and contains the recovery epoch, +authorization digest, active-store fingerprint, and a separate recovery +activation commitment. This durable readback re-arms the signing block after a +process crash or restart, even when no activation registry is present. + +Complete cutover in this order: + +. Read back the target's v2 inventory and independently confirm the exact new + state-anchor tip. +. Permanently tombstone the old store's anchor stream and make the old store + unavailable. Record the canonical tombstone digest. +. Construct a `tbtc-frost-share-repair-activation/v1` lease containing the + original signed authorization, its digest, the tombstone digest, and the + activation time. Sign the lease with the same offline authority. +. Insert the lease into the unique, key-group/seat-sorted + `tbtc-frost-share-repair-activation-registry/v1` artifact. Compute its root + and publish a newly signed production activation manifest containing that + exact `shareRepairActivationRegistryRoot`. The target's manifest must + continue to pin `durableSessionStoreFingerprint` to the authorization's + exact `new_store_fingerprint`; manifests for the other nodes retain their + own descriptor-bound store fingerprints. +. Deploy the owner-only registry file to every node and configure + `FrostShareRepairActivationRegistryPath`. Roll out the new manifest and + restart the fleet. +. Require every activation handshake to attest the new registry root before + admitting production traffic. + +Startup rejects all mixed states: a registry path with a zero manifest root, a +non-zero root without a registry, a registry bound to the old local store, a +lease not proven by the native v2 inventory, or a v2 recovered seat without its +exact lease. ROAST switches from v1 to the lease-carrying v2 transport as soon +as either a registry or a pending local recovered seat exists. Peers reject a +recovered seat's legacy, missing, or stale lease frames. + +== Crash and rollback behavior + +[cols="1,2",options="header"] +|=== +| Crash point | Required response + +| Before target install +| Retry the same signed bundle while it remains valid and the participant + stores/state-key roots are unchanged. Partial network deltas and sigmas are + not durable. If any roster binding changed, rerun preflight and sign a new + roster before retrying. + +| After Rust persistence, before anchor acknowledgement reaches Go +| Remove the maintenance authorization path and restart. Startup reconciliation + authenticates the anchor history and catches it up to the durable local + checkpoint. Do not begin new repair arithmetic. The recovered seat remains + blocked pending activation. + +| After anchor acknowledgement, before the in-process Go guard records it +| Restart. The Rust v2 inventory independently reconstructs the pending-seat + guard and refuses production signing. + +| After the Go guard records the install, before every helper acknowledges the receipt +| Treat the target share as durably repaired. Remove the maintenance + authorization path and restart without repeating repair arithmetic. Startup + reconciliation authenticates the target's v2 inventory and anchor history; + helpers that missed the receipt may have reported a timeout. + +| After maintenance, before old-store tombstone or activation +| Keep the target offline. A zero registry root is not ready when recovered-seat + facts exist. + +| During fleet cutover +| Keep readiness closed until every node attests the same signed manifest and + registry root. Old ROAST v1 frames from the recovered seat are rejected. + +| After activation +| Exact artifact reinstallation is not needed within a process. A later repair + requires a strictly newer recovery epoch and new authorization. +|=== + +Do not roll the repaired target back to a binary that understands only signer +state schema 1. Such binaries reject schema 2 rather than silently using the +share. Helpers that did not persist a recovered seat remain on schema 1. + +== Launch gates + +The following are mandatory before enabling this path on a production wallet: + +[source,shell] +---- +# Rust correctness, persistence, replay, and malformed-input gates +cd pkg/tbtc/signer +cargo test share_repair_ -- --nocapture + +# Explicit expensive production-shape gate +cargo test share_repair_production_scale_51_of_100_launch_gate \ + -- --ignored --nocapture + +# Go authorization, native preflight/roster, confidentiality, exact-set, +# activation, and restart guard +go test -tags=frost_native ./pkg/frost/signing \ + -run 'TestShareRepair|TestPrepareShareRepair|TestRunShareRepair|TestActivationBound' + +# Build/readiness compatibility +go test ./pkg/tbtc ./cmd +go test -tags=frost_native ./pkg/tbtc ./cmd +---- + +In addition, stage a multi-process rehearsal using the intended anchor service +and a disposable 51-of-100 wallet. Exercise loss of the target process in every +crash-matrix row, duplicate-old-store startup, expired authorization, helper +timeout, malformed ciphertext, state-key/store changes between preflight and +repair, a caller-substituted target key, and a partially upgraded fleet. The +authority-side rehearsal must authenticate every native preflight source and +prove that a caller-supplied or wrong-store target entry cannot obtain a signed +roster. Capture the preflight artifacts, signed roster digest, v2 inventory +commitment, anchor acknowledgement, old-stream tombstone, activation registry +root, and fleet handshake attestations as the incident record. Never capture +scalar plaintexts, key packages, state roots, or debug memory dumps. diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 296a20bcaf..d0d163872c 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -272,6 +272,7 @@ type frostPreSignManifestNativeSignerAnchor struct { type frostPreSignManifestFrostSigner struct { TrustDomainID string `json:"trustDomainID"` DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + ShareRepairActivationRegistryRoot string `json:"shareRepairActivationRegistryRoot,omitempty"` ProtocolID string `json:"protocolID"` ReservationProtocolID string `json:"reservationProtocolID"` BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` @@ -1020,6 +1021,14 @@ func validateFrostPreSignActivationManifest( if err != nil || durableSessionStoreFingerprint == [32]byte{} { return fmt.Errorf("invalid FROST durable session store fingerprint") } + if frost.ShareRepairActivationRegistryRoot != "" { + shareRepairActivationRegistryRoot, err := frostPreSignParseBytes32( + frost.ShareRepairActivationRegistryRoot, + ) + if err != nil || shareRepairActivationRegistryRoot == [32]byte{} { + return fmt.Errorf("invalid FROST share-repair activation registry root") + } + } anchorManifest, err := frostPreSignNativeSignerAnchorManifest(manifest) if err != nil { return err @@ -2923,31 +2932,43 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + shareRepairActivationRegistryRoot := [32]byte{} + if frost.ShareRepairActivationRegistryRoot != "" { + shareRepairActivationRegistryRoot, err = parse( + frost.ShareRepairActivationRegistryRoot, + ) + if err != nil || shareRepairActivationRegistryRoot == [32]byte{} { + return tbtc.FrostPreSignActivationRuntimeManifest{}, fmt.Errorf( + "invalid FROST share-repair activation registry root", + ) + } + } return tbtc.FrostPreSignActivationRuntimeManifest{ - ManifestHash: adapter.profile.ActivationManifestHash, - ActivationAuthorityKeyHash: adapter.manifest.activationAuthorityKeyHash, - VerifierOperatorFingerprint: verifierOperatorFingerprint, - HandshakeOperatorFingerprint: handshakeOperatorFingerprint, - DomainChainID: adapter.profile.DomainChainID, - GenesisBlockHash: genesisBlockHash, - ProfileHash: adapter.profile.ProfileHash, - ImplementationSetHash: adapter.profile.ImplementationSetHash, - LinkedLibraryDescriptorSetHash: linkedLibraryDescriptorSetHash, - EndpointIdentitySetHash: endpointIdentitySetHash, - Deployments: frostPreSignRuntimeDeploymentEvidence(adapter.deployments), - SignerProtocolID: signerProtocolID, - ReservationProtocolID: adapter.profile.ReservationProtocolID, - BitcoinOutboxProtocolID: bitcoinOutboxProtocolID, - SigningPolicyHash: adapter.profile.SigningPolicyHash, - DurableSessionStoreFingerprint: frost.DurableSessionStoreFingerprint, - CompleteRouterAddress: adapter.profile.CompleteRouter, - AuthorizationRegistryAddress: adapter.profile.RegistryAddress, - AttestationSignerKeyHash: attestationSignerKeyHash, - Threshold: frost.Threshold, - MaximumGroupSize: frost.MaximumGroupSize, - RetainedGroupInventoryProtocolID: retainedGroupInventoryProtocolID, - NativeSignerAnchor: nativeSignerAnchor, - ActivationAuthorityPublicKey: adapter.manifest.activationAuthorityPublicKey, + ManifestHash: adapter.profile.ActivationManifestHash, + ActivationAuthorityKeyHash: adapter.manifest.activationAuthorityKeyHash, + VerifierOperatorFingerprint: verifierOperatorFingerprint, + HandshakeOperatorFingerprint: handshakeOperatorFingerprint, + DomainChainID: adapter.profile.DomainChainID, + GenesisBlockHash: genesisBlockHash, + ProfileHash: adapter.profile.ProfileHash, + ImplementationSetHash: adapter.profile.ImplementationSetHash, + LinkedLibraryDescriptorSetHash: linkedLibraryDescriptorSetHash, + EndpointIdentitySetHash: endpointIdentitySetHash, + Deployments: frostPreSignRuntimeDeploymentEvidence(adapter.deployments), + SignerProtocolID: signerProtocolID, + ReservationProtocolID: adapter.profile.ReservationProtocolID, + BitcoinOutboxProtocolID: bitcoinOutboxProtocolID, + SigningPolicyHash: adapter.profile.SigningPolicyHash, + DurableSessionStoreFingerprint: frost.DurableSessionStoreFingerprint, + ShareRepairActivationRegistryRoot: shareRepairActivationRegistryRoot, + CompleteRouterAddress: adapter.profile.CompleteRouter, + AuthorizationRegistryAddress: adapter.profile.RegistryAddress, + AttestationSignerKeyHash: attestationSignerKeyHash, + Threshold: frost.Threshold, + MaximumGroupSize: frost.MaximumGroupSize, + RetainedGroupInventoryProtocolID: retainedGroupInventoryProtocolID, + NativeSignerAnchor: nativeSignerAnchor, + ActivationAuthorityPublicKey: adapter.manifest.activationAuthorityPublicKey, CanonicalJournal: tbtc.FrostRetainedGroupCanonicalJournalManifest{ StoreID: journal.StoreID, StoreFingerprint: storeFingerprint, diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index 2eedb2bc4a..3993feca2f 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -233,6 +233,21 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { t.Fatalf("expected durable-session fingerprint failure, got [%v]", err) } }) + t.Run("valid share-repair activation registry root", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.ShareRepairActivationRegistryRoot = testManifestHex32(0x18) + if err := validateFrostPreSignActivationManifest(manifest); err != nil { + t.Fatalf("expected optional share-repair registry root to validate: %v", err) + } + }) + t.Run("malformed share-repair activation registry root", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.ShareRepairActivationRegistryRoot = "operator-authored-label" + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "share-repair activation registry root") { + t.Fatalf("expected share-repair registry-root failure, got [%v]", err) + } + }) t.Run("native anchor stream mismatch", func(t *testing.T) { manifest := testFrostJournalActivationManifest() manifest.FrostSigner.NativeSignerAnchor.StreamID = testManifestHex32(0xee) diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go index 89fc911f4d..0c20029cda 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -43,6 +43,26 @@ typedef TbtcSignerResult (*tbtc_retire_distributed_dkg_key_packages_fn)( const uint8_t* request_ptr, size_t request_len ); +typedef TbtcSignerResult (*tbtc_begin_share_repair_session_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_finish_share_repair_session_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_share_repair_part1_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_share_repair_part2_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_install_repaired_share_fn)( + const uint8_t* request_ptr, + size_t request_len +); typedef TbtcSignerResult (*tbtc_new_signing_package_fn)( const uint8_t* request_ptr, size_t request_len @@ -184,6 +204,57 @@ static TbtcSignerResult tbtc_signer_retire_distributed_dkg_key_packages(const ui return retire(request_ptr, request_len); } +static TbtcSignerResult tbtc_signer_begin_share_repair_session(const uint8_t* request_ptr, size_t request_len) { + tbtc_begin_share_repair_session_fn begin = + (tbtc_begin_share_repair_session_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_begin_share_repair_session" + ); + if (begin == NULL) { + return unavailable_tbtc_signer_result(); + } + return begin(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_finish_share_repair_session(const uint8_t* request_ptr, size_t request_len) { + tbtc_finish_share_repair_session_fn finish = + (tbtc_finish_share_repair_session_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_finish_share_repair_session" + ); + if (finish == NULL) { + return unavailable_tbtc_signer_result(); + } + return finish(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_share_repair_part1(const uint8_t* request_ptr, size_t request_len) { + tbtc_share_repair_part1_fn part1 = + (tbtc_share_repair_part1_fn)dlsym(RTLD_DEFAULT, "frost_tbtc_share_repair_part1"); + if (part1 == NULL) { + return unavailable_tbtc_signer_result(); + } + return part1(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_share_repair_part2(const uint8_t* request_ptr, size_t request_len) { + tbtc_share_repair_part2_fn part2 = + (tbtc_share_repair_part2_fn)dlsym(RTLD_DEFAULT, "frost_tbtc_share_repair_part2"); + if (part2 == NULL) { + return unavailable_tbtc_signer_result(); + } + return part2(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_install_repaired_share(const uint8_t* request_ptr, size_t request_len) { + tbtc_install_repaired_share_fn install = + (tbtc_install_repaired_share_fn)dlsym(RTLD_DEFAULT, "frost_tbtc_install_repaired_share"); + if (install == NULL) { + return unavailable_tbtc_signer_result(); + } + return install(request_ptr, request_len); +} + static TbtcSignerResult tbtc_signer_new_signing_package(const uint8_t* request_ptr, size_t request_len) { tbtc_new_signing_package_fn new_signing_package = (tbtc_new_signing_package_fn)dlsym( RTLD_DEFAULT, @@ -333,6 +404,15 @@ static int tbtc_signer_free_buffer_available(void) { return dlsym(RTLD_DEFAULT, "frost_tbtc_free_buffer") != NULL; } +static int tbtc_signer_share_repair_symbols_available(void) { + return + dlsym(RTLD_DEFAULT, "frost_tbtc_begin_share_repair_session") != NULL && + dlsym(RTLD_DEFAULT, "frost_tbtc_finish_share_repair_session") != NULL && + dlsym(RTLD_DEFAULT, "frost_tbtc_share_repair_part1") != NULL && + dlsym(RTLD_DEFAULT, "frost_tbtc_share_repair_part2") != NULL && + dlsym(RTLD_DEFAULT, "frost_tbtc_install_repaired_share") != NULL; +} + static void tbtc_signer_scrub_and_free_buffer(uint8_t* ptr, size_t len) { if (ptr != NULL) { volatile uint8_t* cursor = (volatile uint8_t*)ptr; @@ -375,6 +455,7 @@ var _ interactiveSigningEngine = (*buildTaggedTBTCSignerEngine)(nil) // culprits. Compile-check it here against the real engine. var _ Round2ShareVerifyingEngine = (*buildTaggedTBTCSignerEngine)(nil) var _ NativeTBTCSignerDistributedDKGRetirementEngine = (*buildTaggedTBTCSignerEngine)(nil) +var _ NativeTBTCSignerShareRepairEngine = (*buildTaggedTBTCSignerEngine)(nil) type buildTaggedTBTCSignerRunDKGResponse struct { SessionID string `json:"session_id"` @@ -456,6 +537,85 @@ type buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse struct { RetiredKeyPackageCount uint16 `json:"retired_key_package_count"` } +const ( + buildTaggedTBTCSignerShareRepairPublicKeyLength = shareRepairEphemeralPublicKeyLength + buildTaggedTBTCSignerShareRepairPayloadLength = shareRepairEncryptedScalarPayloadLength +) + +type buildTaggedTBTCSignerShareRepairSessionRequest struct { + Authorization *ShareRepairAuthorization `json:"authorization"` + ParticipantIdentifier uint16 `json:"participant_identifier"` +} + +type buildTaggedTBTCSignerBeginShareRepairSessionResponse struct { + ContextDigest string `json:"context_digest"` + ParticipantIdentifier uint16 `json:"participant_identifier"` + StoreFingerprint string `json:"store_fingerprint"` + TransportPublicKeyHex string `json:"transport_public_key_hex"` +} + +type buildTaggedTBTCSignerFinishShareRepairSessionResponse struct { + ContextDigest string `json:"context_digest"` + ParticipantIdentifier uint16 `json:"participant_identifier"` + Finished bool `json:"finished"` +} + +type buildTaggedTBTCSignerShareRepairEncryptedDelta struct { + ContextDigest string `json:"context_digest"` + SenderIdentifier uint16 `json:"sender_identifier"` + RecipientIdentifier uint16 `json:"recipient_identifier"` + PayloadHex string `json:"payload_hex"` +} + +type buildTaggedTBTCSignerShareRepairPart1Request struct { + Authorization *ShareRepairAuthorization `json:"authorization"` + HelperIdentifier uint16 `json:"helper_identifier"` + TransportRoster *ShareRepairTransportRoster `json:"transport_roster"` +} + +type buildTaggedTBTCSignerShareRepairPart1Response struct { + ContextDigest string `json:"context_digest"` + HelperIdentifier uint16 `json:"helper_identifier"` + PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` + Deltas []buildTaggedTBTCSignerShareRepairEncryptedDelta `json:"deltas"` +} + +type buildTaggedTBTCSignerShareRepairPart2Request struct { + Authorization *ShareRepairAuthorization `json:"authorization"` + HelperIdentifier uint16 `json:"helper_identifier"` + Deltas []buildTaggedTBTCSignerShareRepairEncryptedDelta `json:"deltas"` + TransportRoster *ShareRepairTransportRoster `json:"transport_roster"` +} + +type buildTaggedTBTCSignerShareRepairEncryptedSigma struct { + ContextDigest string `json:"context_digest"` + HelperIdentifier uint16 `json:"helper_identifier"` + PayloadHex string `json:"payload_hex"` +} + +type buildTaggedTBTCSignerShareRepairPart2Response struct { + ContextDigest string `json:"context_digest"` + Sigma *buildTaggedTBTCSignerShareRepairEncryptedSigma `json:"sigma"` +} + +type buildTaggedTBTCSignerInstallRepairedShareRequest struct { + Authorization *ShareRepairAuthorization `json:"authorization"` + PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` + Sigmas []buildTaggedTBTCSignerShareRepairEncryptedSigma `json:"sigmas"` + TransportRoster *ShareRepairTransportRoster `json:"transport_roster"` +} + +type buildTaggedTBTCSignerInstallRepairedShareResponse struct { + Schema string `json:"schema"` + SessionID string `json:"session_id"` + KeyGroup string `json:"key_group"` + TargetIdentifier uint16 `json:"target_identifier"` + RecoveryEpoch uint64 `json:"recovery_epoch"` + AuthorizationDigest string `json:"authorization_digest"` + ActiveStoreFingerprint string `json:"active_store_fingerprint"` + Idempotent bool `json:"idempotent"` +} + type buildTaggedTBTCSignerTriggerEmergencyRekeyRequest struct { SessionID string `json:"session_id"` Reason string `json:"reason"` @@ -717,6 +877,131 @@ func (bttse *buildTaggedTBTCSignerEngine) RetireDistributedDKGKeyPackages( ) } +func (bttse *buildTaggedTBTCSignerEngine) BeginShareRepairSession( + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) (*NativeShareRepairSession, error) { + requestPayload, err := buildTaggedTBTCSignerShareRepairSessionRequestPayload( + "BeginShareRepairSession", + authorization, + participantIdentifier, + ) + if err != nil { + return nil, err + } + responsePayload, err := callBuildTaggedTBTCSignerBeginShareRepairSession( + requestPayload, + ) + if err != nil { + return nil, err + } + return decodeBuildTaggedTBTCSignerBeginShareRepairSessionResponse( + responsePayload, + authorization, + participantIdentifier, + ) +} + +func (bttse *buildTaggedTBTCSignerEngine) FinishShareRepairSession( + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) error { + requestPayload, err := buildTaggedTBTCSignerShareRepairSessionRequestPayload( + "FinishShareRepairSession", + authorization, + participantIdentifier, + ) + if err != nil { + return err + } + responsePayload, err := callBuildTaggedTBTCSignerFinishShareRepairSession( + requestPayload, + ) + if err != nil { + return err + } + return decodeBuildTaggedTBTCSignerFinishShareRepairSessionResponse( + responsePayload, + authorization, + participantIdentifier, + ) +} + +func (bttse *buildTaggedTBTCSignerEngine) ShareRepairPart1( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + transportRoster *ShareRepairTransportRoster, +) (*NativeShareRepairPart1Result, error) { + requestPayload, err := buildTaggedTBTCSignerShareRepairPart1RequestPayload( + authorization, + helperIdentifier, + transportRoster, + ) + if err != nil { + return nil, err + } + responsePayload, err := callBuildTaggedTBTCSignerShareRepairPart1(requestPayload) + if err != nil { + return nil, err + } + return decodeBuildTaggedTBTCSignerShareRepairPart1Response( + responsePayload, + authorization, + helperIdentifier, + ) +} + +func (bttse *buildTaggedTBTCSignerEngine) ShareRepairPart2( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairEncryptedDelta, + transportRoster *ShareRepairTransportRoster, +) (*NativeShareRepairPart2Result, error) { + requestPayload, err := buildTaggedTBTCSignerShareRepairPart2RequestPayload( + authorization, + helperIdentifier, + deltas, + transportRoster, + ) + if err != nil { + return nil, err + } + responsePayload, err := callBuildTaggedTBTCSignerShareRepairPart2(requestPayload) + if err != nil { + return nil, err + } + return decodeBuildTaggedTBTCSignerShareRepairPart2Response( + responsePayload, + authorization, + helperIdentifier, + ) +} + +func (bttse *buildTaggedTBTCSignerEngine) InstallRepairedShare( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairEncryptedSigma, + transportRoster *ShareRepairTransportRoster, +) (*NativeShareRepairInstallResult, error) { + requestPayload, err := buildTaggedTBTCSignerInstallRepairedShareRequestPayload( + authorization, + publicKeyPackage, + sigmas, + transportRoster, + ) + if err != nil { + return nil, err + } + responsePayload, err := callBuildTaggedTBTCSignerInstallRepairedShare(requestPayload) + if err != nil { + return nil, err + } + return decodeBuildTaggedTBTCSignerInstallRepairedShareResponse( + responsePayload, + authorization, + ) +} + func (bttse *buildTaggedTBTCSignerEngine) NewSigningPackage( message []byte, commitments []nativeFROSTCommitment, @@ -1117,6 +1402,436 @@ func buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequestPayload( ) } +func buildTaggedTBTCSignerShareRepairSessionRequestPayload( + op string, + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) ([]byte, error) { + if _, err := ComputeShareRepairAuthorizationDigest(authorization); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + if participantIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError(op, "participant identifier is zero") + } + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerShareRepairSessionRequest{ + Authorization: authorization, + ParticipantIdentifier: participantIdentifier, + }, + ) +} + +func shareRepairContextWire( + authorization *ShareRepairAuthorization, +) (string, error) { + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return "", err + } + return fmt.Sprintf("0x%x", digest), nil +} + +func decodeCanonicalShareRepairHex( + op string, + label string, + value string, + expectedLength int, +) ([]byte, error) { + if len(value) != expectedLength*2 || strings.ToLower(value) != value { + return nil, buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("%s is not canonical lowercase %d-byte hex", label, expectedLength), + ) + } + decoded, err := hex.DecodeString(value) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("%s is invalid: %v", label, err), + ) + } + return decoded, nil +} + +func validateBuildTaggedTBTCSignerShareRepairTransportRoster( + op string, + authorization *ShareRepairAuthorization, + transportRoster *ShareRepairTransportRoster, +) error { + if _, err := ComputeShareRepairTransportRosterDigest( + transportRoster, + authorization, + ); err != nil { + return buildTaggedTBTCSignerOperationError(op, err.Error()) + } + if _, err := parseCanonicalShareRepairSignature( + transportRoster.SignatureHex, + ); err != nil { + return buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("invalid transport roster signature encoding: %v", err), + ) + } + return nil +} + +func buildTaggedTBTCSignerShareRepairPart1RequestPayload( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + transportRoster *ShareRepairTransportRoster, +) ([]byte, error) { + const op = "ShareRepairPart1" + if err := validateBuildTaggedTBTCSignerShareRepairTransportRoster( + op, + authorization, + transportRoster, + ); err != nil { + return nil, err + } + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerShareRepairPart1Request{ + Authorization: authorization, + HelperIdentifier: helperIdentifier, + TransportRoster: transportRoster, + }, + ) +} + +func buildTaggedTBTCSignerShareRepairDeltaPayloads( + op string, + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairEncryptedDelta, +) ([]buildTaggedTBTCSignerShareRepairEncryptedDelta, error) { + if len(deltas) != len(authorization.HelperIdentifiers) { + return nil, buildTaggedTBTCSignerOperationError( + op, + "encrypted deltas do not contain the exact helper set", + ) + } + contextDigest, err := shareRepairContextWire(authorization) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + result := make([]buildTaggedTBTCSignerShareRepairEncryptedDelta, len(deltas)) + for index, delta := range deltas { + if delta == nil || delta.ContextDigest != contextDigest || + delta.SenderIdentifier != authorization.HelperIdentifiers[index] || + delta.RecipientIdentifier != helperIdentifier || + len(delta.Payload) != buildTaggedTBTCSignerShareRepairPayloadLength { + return nil, buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("encrypted delta [%d] is invalid or out of order", index), + ) + } + result[index] = buildTaggedTBTCSignerShareRepairEncryptedDelta{ + ContextDigest: delta.ContextDigest, + SenderIdentifier: delta.SenderIdentifier, + RecipientIdentifier: delta.RecipientIdentifier, + PayloadHex: hex.EncodeToString(delta.Payload), + } + } + return result, nil +} + +func buildTaggedTBTCSignerShareRepairPart2RequestPayload( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairEncryptedDelta, + transportRoster *ShareRepairTransportRoster, +) ([]byte, error) { + const op = "ShareRepairPart2" + if err := validateBuildTaggedTBTCSignerShareRepairTransportRoster( + op, + authorization, + transportRoster, + ); err != nil { + return nil, err + } + wireDeltas, err := buildTaggedTBTCSignerShareRepairDeltaPayloads( + op, + authorization, + helperIdentifier, + deltas, + ) + if err != nil { + return nil, err + } + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerShareRepairPart2Request{ + Authorization: authorization, + HelperIdentifier: helperIdentifier, + Deltas: wireDeltas, + TransportRoster: transportRoster, + }, + ) +} + +func buildTaggedTBTCSignerShareRepairSigmaPayloads( + op string, + authorization *ShareRepairAuthorization, + sigmas []*NativeShareRepairEncryptedSigma, +) ([]buildTaggedTBTCSignerShareRepairEncryptedSigma, error) { + if len(sigmas) != len(authorization.HelperIdentifiers) { + return nil, buildTaggedTBTCSignerOperationError( + op, + "encrypted sigmas do not contain the exact helper set", + ) + } + contextDigest, err := shareRepairContextWire(authorization) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + result := make([]buildTaggedTBTCSignerShareRepairEncryptedSigma, len(sigmas)) + for index, sigma := range sigmas { + if sigma == nil || sigma.ContextDigest != contextDigest || + sigma.HelperIdentifier != authorization.HelperIdentifiers[index] || + len(sigma.Payload) != buildTaggedTBTCSignerShareRepairPayloadLength { + return nil, buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("encrypted sigma [%d] is invalid or out of order", index), + ) + } + result[index] = buildTaggedTBTCSignerShareRepairEncryptedSigma{ + ContextDigest: sigma.ContextDigest, + HelperIdentifier: sigma.HelperIdentifier, + PayloadHex: hex.EncodeToString(sigma.Payload), + } + } + return result, nil +} + +func buildTaggedTBTCSignerInstallRepairedShareRequestPayload( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairEncryptedSigma, + transportRoster *ShareRepairTransportRoster, +) ([]byte, error) { + const op = "InstallRepairedShare" + if err := validateBuildTaggedTBTCSignerShareRepairTransportRoster( + op, + authorization, + transportRoster, + ); err != nil { + return nil, err + } + if publicKeyPackage == nil { + return nil, buildTaggedTBTCSignerOperationError(op, "public key package is nil") + } + wireSigmas, err := buildTaggedTBTCSignerShareRepairSigmaPayloads( + op, + authorization, + sigmas, + ) + if err != nil { + return nil, err + } + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerInstallRepairedShareRequest{ + Authorization: authorization, + TransportRoster: transportRoster, + PublicKeyPackage: &buildTaggedTBTCSignerNativeFROSTPublicKeyPackage{ + VerifyingShares: publicKeyPackage.VerifyingShares, + VerifyingKey: publicKeyPackage.VerifyingKey, + }, + Sigmas: wireSigmas, + }, + ) +} + +func decodeBuildTaggedTBTCSignerBeginShareRepairSessionResponse( + responsePayload []byte, + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) (*NativeShareRepairSession, error) { + const op = "BeginShareRepairSession" + response := &buildTaggedTBTCSignerBeginShareRepairSessionResponse{} + if err := json.Unmarshal(responsePayload, response); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("cannot decode response: %v", err)) + } + contextDigest, err := shareRepairContextWire(authorization) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + if response.ContextDigest != contextDigest || + response.ParticipantIdentifier != participantIdentifier { + return nil, buildTaggedTBTCSignerOperationError(op, "response does not match the requested session") + } + if _, err := parseCanonicalShareRepairHex32( + response.StoreFingerprint, + "native share-repair session store_fingerprint", + ); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + publicKey, err := decodeCanonicalShareRepairHex( + op, + "transport_public_key_hex", + response.TransportPublicKeyHex, + buildTaggedTBTCSignerShareRepairPublicKeyLength, + ) + if err != nil { + return nil, err + } + return &NativeShareRepairSession{ + ContextDigest: response.ContextDigest, + ParticipantIdentifier: response.ParticipantIdentifier, + StoreFingerprint: response.StoreFingerprint, + TransportPublicKey: publicKey, + }, nil +} + +func decodeBuildTaggedTBTCSignerFinishShareRepairSessionResponse( + responsePayload []byte, + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) error { + const op = "FinishShareRepairSession" + response := &buildTaggedTBTCSignerFinishShareRepairSessionResponse{} + if err := json.Unmarshal(responsePayload, response); err != nil { + return buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("cannot decode response: %v", err)) + } + contextDigest, err := shareRepairContextWire(authorization) + if err != nil { + return buildTaggedTBTCSignerOperationError(op, err.Error()) + } + if response.ContextDigest != contextDigest || + response.ParticipantIdentifier != participantIdentifier || !response.Finished { + return buildTaggedTBTCSignerOperationError(op, "response does not confirm the requested session") + } + return nil +} + +func decodeBuildTaggedTBTCSignerShareRepairPart1Response( + responsePayload []byte, + authorization *ShareRepairAuthorization, + helperIdentifier uint16, +) (*NativeShareRepairPart1Result, error) { + const op = "ShareRepairPart1" + response := &buildTaggedTBTCSignerShareRepairPart1Response{} + if err := json.Unmarshal(responsePayload, response); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("cannot decode response: %v", err)) + } + contextDigest, err := shareRepairContextWire(authorization) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + if response.ContextDigest != contextDigest || response.HelperIdentifier != helperIdentifier || + response.PublicKeyPackage == nil || + len(response.PublicKeyPackage.VerifyingShares) == 0 || + response.PublicKeyPackage.VerifyingKey == "" || + len(response.Deltas) != len(authorization.HelperIdentifiers) { + return nil, buildTaggedTBTCSignerOperationError(op, "response has the wrong context or shape") + } + deltas := make([]*NativeShareRepairEncryptedDelta, len(response.Deltas)) + for index, delta := range response.Deltas { + if delta.ContextDigest != contextDigest || delta.SenderIdentifier != helperIdentifier || + delta.RecipientIdentifier != authorization.HelperIdentifiers[index] { + return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("invalid delta [%d] bindings", index)) + } + payload, err := decodeCanonicalShareRepairHex( + op, + fmt.Sprintf("deltas[%d].payload_hex", index), + delta.PayloadHex, + buildTaggedTBTCSignerShareRepairPayloadLength, + ) + if err != nil { + return nil, err + } + deltas[index] = &NativeShareRepairEncryptedDelta{ + ContextDigest: delta.ContextDigest, + SenderIdentifier: delta.SenderIdentifier, + RecipientIdentifier: delta.RecipientIdentifier, + Payload: payload, + } + } + return &NativeShareRepairPart1Result{ + ContextDigest: response.ContextDigest, + HelperIdentifier: response.HelperIdentifier, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingShares: appendBuildTaggedTBTCSignerStringMap( + response.PublicKeyPackage.VerifyingShares, + ), + VerifyingKey: response.PublicKeyPackage.VerifyingKey, + }, + Deltas: deltas, + }, nil +} + +func decodeBuildTaggedTBTCSignerShareRepairPart2Response( + responsePayload []byte, + authorization *ShareRepairAuthorization, + helperIdentifier uint16, +) (*NativeShareRepairPart2Result, error) { + const op = "ShareRepairPart2" + response := &buildTaggedTBTCSignerShareRepairPart2Response{} + if err := json.Unmarshal(responsePayload, response); err != nil || response.Sigma == nil { + return nil, buildTaggedTBTCSignerOperationError(op, "cannot decode response sigma") + } + contextDigest, err := shareRepairContextWire(authorization) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + if response.ContextDigest != contextDigest || response.Sigma.ContextDigest != contextDigest || + response.Sigma.HelperIdentifier != helperIdentifier { + return nil, buildTaggedTBTCSignerOperationError(op, "response sigma has invalid bindings") + } + payload, err := decodeCanonicalShareRepairHex( + op, + "sigma.payload_hex", + response.Sigma.PayloadHex, + buildTaggedTBTCSignerShareRepairPayloadLength, + ) + if err != nil { + return nil, err + } + return &NativeShareRepairPart2Result{ + ContextDigest: response.ContextDigest, + Sigma: &NativeShareRepairEncryptedSigma{ + ContextDigest: response.Sigma.ContextDigest, + HelperIdentifier: response.Sigma.HelperIdentifier, + Payload: payload, + }, + }, nil +} + +func decodeBuildTaggedTBTCSignerInstallRepairedShareResponse( + responsePayload []byte, + authorization *ShareRepairAuthorization, +) (*NativeShareRepairInstallResult, error) { + const op = "InstallRepairedShare" + response := &buildTaggedTBTCSignerInstallRepairedShareResponse{} + if err := json.Unmarshal(responsePayload, response); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("cannot decode response: %v", err)) + } + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return nil, err + } + if response.Schema != ShareRepairInstallResultSchema || + response.SessionID != authorization.SessionID || + response.KeyGroup != authorization.KeyGroup || + response.TargetIdentifier != authorization.TargetIdentifier || + response.RecoveryEpoch != authorization.RecoveryEpoch || + response.AuthorizationDigest != fmt.Sprintf("0x%x", digest) || + response.ActiveStoreFingerprint != authorization.NewStoreFingerprint { + return nil, buildTaggedTBTCSignerOperationError(op, "response does not match authorization") + } + return &NativeShareRepairInstallResult{ + Schema: response.Schema, + SessionID: response.SessionID, + KeyGroup: response.KeyGroup, + TargetIdentifier: response.TargetIdentifier, + RecoveryEpoch: response.RecoveryEpoch, + AuthorizationDigest: response.AuthorizationDigest, + ActiveStoreFingerprint: response.ActiveStoreFingerprint, + Idempotent: response.Idempotent, + }, nil +} + func decodeBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse( responsePayload []byte, expectedKeyGroup string, @@ -1930,6 +2645,66 @@ func callBuildTaggedTBTCSignerRetireDistributedDKGKeyPackages( ) } +func callBuildTaggedTBTCSignerBeginShareRepairSession( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "BeginShareRepairSession", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_begin_share_repair_session(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerFinishShareRepairSession( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "FinishShareRepairSession", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_finish_share_repair_session(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerShareRepairPart1( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "ShareRepairPart1", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_share_repair_part1(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerShareRepairPart2( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "ShareRepairPart2", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_share_repair_part2(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerInstallRepairedShare( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InstallRepairedShare", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_install_repaired_share(requestPtr, requestLen) + }, + ) +} + func callBuildTaggedTBTCSignerNewSigningPackage( requestPayload []byte, ) ([]byte, error) { @@ -2039,6 +2814,10 @@ func ensureTBTCSignerFreeBufferAvailable() error { return nil } +func buildTaggedTBTCSignerShareRepairSymbolsAvailable() bool { + return C.tbtc_signer_share_repair_symbols_available() != 0 +} + func parseBuildTaggedTBTCSignerResult( operation string, result C.TbtcSignerResult, diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go index 84560ca26f..5f20c35d0b 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go @@ -3,6 +3,7 @@ package signing import ( + "bytes" "encoding/hex" "encoding/json" "errors" @@ -112,6 +113,23 @@ func TestRegisterBuildTaggedTBTCSignerEngine(t *testing.T) { } } +func TestRealCgoShareRepairSymbolsResolve(t *testing.T) { + resetTBTCSignerABIOnceForTest() + t.Cleanup(resetTBTCSignerABIOnceForTest) + if err := assertTBTCSignerABICompatible(); errors.Is( + err, + ErrNativeCryptographyUnavailable, + ) { + t.Skip("libfrost_tbtc is not linked") + } else if err != nil { + t.Fatalf("linked signer ABI is incompatible: %v", err) + } + + if !buildTaggedTBTCSignerShareRepairSymbolsAvailable() { + t.Fatal("ABI 5.0 library is missing one or more native-custody share-repair symbols") + } +} + func buildTaggedTBTCSignerTestIdentifier(memberIndex byte) string { identifier := make([]byte, 32) identifier[0] = memberIndex @@ -331,6 +349,315 @@ func TestBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesPayloadAndResponse( } } +func TestBuildTaggedTBTCSignerShareRepairPayloadsAndResponses(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + contextDigest := fmt.Sprintf("0x%x", digest) + localPublicKey, err := hex.DecodeString( + transportRoster.ParticipantPublicKeys[0].PublicKeyHex, + ) + if err != nil { + t.Fatal(err) + } + payload1 := bytesOf(0xa1, buildTaggedTBTCSignerShareRepairPayloadLength) + payload2 := bytesOf(0xa2, buildTaggedTBTCSignerShareRepairPayloadLength) + + beginPayload, err := buildTaggedTBTCSignerShareRepairSessionRequestPayload( + "BeginShareRepairSession", + authorization, + 1, + ) + if err != nil { + t.Fatal(err) + } + var beginRequest buildTaggedTBTCSignerShareRepairSessionRequest + if err := json.Unmarshal(beginPayload, &beginRequest); err != nil { + t.Fatal(err) + } + if beginRequest.Authorization == nil || + beginRequest.Authorization.SessionID != authorization.SessionID || + beginRequest.ParticipantIdentifier != 1 { + t.Fatal("share-repair begin request lost its authorization or participant") + } + beginResponse, err := json.Marshal(buildTaggedTBTCSignerBeginShareRepairSessionResponse{ + ContextDigest: contextDigest, + ParticipantIdentifier: 1, + StoreFingerprint: authorization.NewStoreFingerprint, + TransportPublicKeyHex: hex.EncodeToString(localPublicKey), + }) + if err != nil { + t.Fatal(err) + } + session, err := decodeBuildTaggedTBTCSignerBeginShareRepairSessionResponse( + beginResponse, + authorization, + 1, + ) + if err != nil || session == nil || + !bytes.Equal(session.TransportPublicKey, localPublicKey) { + t.Fatalf("share-repair begin response was rejected: %v", err) + } + finishResponse, err := json.Marshal(buildTaggedTBTCSignerFinishShareRepairSessionResponse{ + ContextDigest: contextDigest, + ParticipantIdentifier: 1, + Finished: true, + }) + if err != nil { + t.Fatal(err) + } + if err := decodeBuildTaggedTBTCSignerFinishShareRepairSessionResponse( + finishResponse, + authorization, + 1, + ); err != nil { + t.Fatalf("share-repair finish response was rejected: %v", err) + } + + part1RequestPayload, err := buildTaggedTBTCSignerShareRepairPart1RequestPayload( + authorization, + 1, + transportRoster, + ) + if err != nil { + t.Fatal(err) + } + var part1Request buildTaggedTBTCSignerShareRepairPart1Request + if err := json.Unmarshal(part1RequestPayload, &part1Request); err != nil { + t.Fatal(err) + } + if part1Request.TransportRoster == nil || + part1Request.TransportRoster.SignatureHex != transportRoster.SignatureHex || + len(part1Request.TransportRoster.ParticipantPublicKeys) != 3 { + t.Fatal("share-repair Part1 request lost its signed transport roster") + } + if bytes.Contains(part1RequestPayload, []byte("recipient_public_keys")) { + t.Fatal("share-repair Part1 request retained caller-controlled recipient keys") + } + if bytes.Contains(part1RequestPayload, []byte("data_hex")) { + t.Fatal("share-repair Part1 request exposed the retired plaintext scalar field") + } + + part1Payload, err := json.Marshal(buildTaggedTBTCSignerShareRepairPart1Response{ + ContextDigest: contextDigest, + HelperIdentifier: 1, + PublicKeyPackage: &buildTaggedTBTCSignerNativeFROSTPublicKeyPackage{ + VerifyingShares: map[string]string{"1": "share-1", "2": "share-2"}, + VerifyingKey: "group-key", + }, + Deltas: []buildTaggedTBTCSignerShareRepairEncryptedDelta{ + { + ContextDigest: contextDigest, + SenderIdentifier: 1, + RecipientIdentifier: 1, + PayloadHex: hex.EncodeToString(payload1), + }, + { + ContextDigest: contextDigest, + SenderIdentifier: 1, + RecipientIdentifier: 2, + PayloadHex: hex.EncodeToString(payload2), + }, + }, + }) + if err != nil { + t.Fatal(err) + } + part1, err := decodeBuildTaggedTBTCSignerShareRepairPart1Response( + part1Payload, + authorization, + 1, + ) + if err != nil { + t.Fatal(err) + } + if len(part1.Deltas) != 2 || + !bytes.Equal(part1.Deltas[0].Payload, payload1) || + !bytes.Equal(part1.Deltas[1].Payload, payload2) { + t.Fatal("share-repair Part1 bridge response lost its opaque ciphertexts") + } + + part2Deltas := []*NativeShareRepairEncryptedDelta{ + { + ContextDigest: contextDigest, + SenderIdentifier: 1, + RecipientIdentifier: 2, + Payload: payload1, + }, + { + ContextDigest: contextDigest, + SenderIdentifier: 2, + RecipientIdentifier: 2, + Payload: payload2, + }, + } + + part2Payload, err := buildTaggedTBTCSignerShareRepairPart2RequestPayload( + authorization, + 2, + part2Deltas, + transportRoster, + ) + if err != nil { + t.Fatal(err) + } + part2Request := &buildTaggedTBTCSignerShareRepairPart2Request{} + if err := json.Unmarshal(part2Payload, part2Request); err != nil { + t.Fatal(err) + } + if part2Request.Authorization == nil || + part2Request.Authorization.SessionID != authorization.SessionID || + part2Request.HelperIdentifier != 2 || len(part2Request.Deltas) != 2 || + part2Request.TransportRoster == nil || + part2Request.TransportRoster.SignatureHex != transportRoster.SignatureHex { + t.Fatal("share-repair Part2 bridge request lost its authorization or endpoint") + } + if bytes.Contains(part2Payload, []byte("target_public_key_hex")) { + t.Fatal("share-repair Part2 request retained a caller-controlled target key") + } + if part2Request.Deltas[0].PayloadHex != hex.EncodeToString(payload1) || + part2Request.Deltas[1].PayloadHex != hex.EncodeToString(payload2) { + t.Fatal("share-repair Part2 bridge ciphertexts did not round trip") + } + if bytes.Contains(part2Payload, []byte("data_hex")) { + t.Fatal("share-repair Part2 request exposed the retired plaintext scalar field") + } + + part2ResponsePayload, err := json.Marshal( + buildTaggedTBTCSignerShareRepairPart2Response{ + ContextDigest: contextDigest, + Sigma: &buildTaggedTBTCSignerShareRepairEncryptedSigma{ + ContextDigest: contextDigest, + HelperIdentifier: 2, + PayloadHex: hex.EncodeToString(payload1), + }, + }, + ) + if err != nil { + t.Fatal(err) + } + part2, err := decodeBuildTaggedTBTCSignerShareRepairPart2Response( + part2ResponsePayload, + authorization, + 2, + ) + if err != nil { + t.Fatal(err) + } + if part2.Sigma == nil || !bytes.Equal(part2.Sigma.Payload, payload1) { + t.Fatal("share-repair Part2 bridge response lost its opaque sigma ciphertext") + } + + sigmas := []*NativeShareRepairEncryptedSigma{ + { + ContextDigest: contextDigest, + HelperIdentifier: 1, + Payload: payload1, + }, + part2.Sigma, + } + installPayload, err := buildTaggedTBTCSignerInstallRepairedShareRequestPayload( + authorization, + &NativeFROSTPublicKeyPackage{ + VerifyingShares: map[string]string{"1": "share-1", "2": "share-2"}, + VerifyingKey: "group-key", + }, + sigmas, + transportRoster, + ) + if err != nil { + t.Fatal(err) + } + installRequest := &buildTaggedTBTCSignerInstallRepairedShareRequest{} + if err := json.Unmarshal(installPayload, installRequest); err != nil { + t.Fatal(err) + } + if installRequest.Authorization == nil || + installRequest.Authorization.SessionID != authorization.SessionID || + len(installRequest.Sigmas) != 2 || installRequest.TransportRoster == nil || + installRequest.TransportRoster.SignatureHex != transportRoster.SignatureHex { + t.Fatal("share-repair install bridge request lost its bound inputs") + } + if installRequest.Sigmas[0].PayloadHex != hex.EncodeToString(payload1) || + installRequest.Sigmas[1].PayloadHex != hex.EncodeToString(payload1) { + t.Fatal("share-repair install bridge ciphertexts did not round trip") + } + if bytes.Contains(installPayload, []byte("data_hex")) { + t.Fatal("share-repair install request exposed the retired plaintext scalar field") + } + + installResponsePayload, err := json.Marshal( + buildTaggedTBTCSignerInstallRepairedShareResponse{ + Schema: ShareRepairInstallResultSchema, + SessionID: authorization.SessionID, + KeyGroup: authorization.KeyGroup, + TargetIdentifier: authorization.TargetIdentifier, + RecoveryEpoch: authorization.RecoveryEpoch, + AuthorizationDigest: contextDigest, + ActiveStoreFingerprint: authorization.NewStoreFingerprint, + Idempotent: true, + }, + ) + if err != nil { + t.Fatal(err) + } + installed, err := decodeBuildTaggedTBTCSignerInstallRepairedShareResponse( + installResponsePayload, + authorization, + ) + if err != nil || installed == nil || !installed.Idempotent { + t.Fatalf("share-repair install bridge response was rejected: %v", err) + } +} + +func TestBuildTaggedTBTCSignerShareRepairRejectsMalformedOpaqueMaterial(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + contextDigest, err := shareRepairContextWire(authorization) + if err != nil { + t.Fatal(err) + } + + if _, err := decodeBuildTaggedTBTCSignerBeginShareRepairSessionResponse( + []byte(fmt.Sprintf( + `{"context_digest":%q,"participant_identifier":1,"store_fingerprint":%q,"transport_public_key_hex":%q}`, + contextDigest, + authorization.NewStoreFingerprint, + strings.Repeat("AA", buildTaggedTBTCSignerShareRepairPublicKeyLength), + )), + authorization, + 1, + ); err == nil { + t.Fatal("uppercase native session public key was accepted") + } + + malformed := []*NativeShareRepairEncryptedDelta{ + { + ContextDigest: contextDigest, + SenderIdentifier: 1, + RecipientIdentifier: 2, + Payload: bytesOf(0x11, buildTaggedTBTCSignerShareRepairPayloadLength-1), + }, + { + ContextDigest: contextDigest, + SenderIdentifier: 2, + RecipientIdentifier: 2, + Payload: bytesOf(0x22, buildTaggedTBTCSignerShareRepairPayloadLength), + }, + } + if _, err := buildTaggedTBTCSignerShareRepairPart2RequestPayload( + authorization, + 2, + malformed, + transportRoster, + ); err == nil { + t.Fatal("truncated encrypted delta was accepted") + } +} + func TestBuildTaggedTBTCSignerBuildTaprootTxRequestPayload(t *testing.T) { scriptTreeHex := "deadbeef" diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index dbedef4773..65942b1301 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -24,24 +24,15 @@ const ( // It fails closed with cryptographic_refresh_not_supported until a real // multi-round refresh protocol exists. The changed status and response // semantics are incompatible with ABI 3. - requiredTBTCSignerABIMajor uint32 = 4 - // Minor 1 adds the durable-store identity, exact retained key-package - // inventory, and paginated state-witness proof readbacks. These are new - // symbols and response types, so ABI-4.0 callers remain valid and ignore - // them. Their first public contract uses the v2 stable-store and witness - // transcripts; bridges that consume them must require at least 4.1. // - // Minor 2 adds the constant-size state-witness tip and signed checkpoint - // acknowledgement symbols required by the protocol-output barrier. This - // build must reject 4.1 before it can reach a missing symbol via dlsym. - // - // Minor 3 adds the durable state-anchor trust-head/transition and bootstrap - // facts symbols. Production startup and offline provisioning require that - // complete surface and must reject an ABI-4.2 library before dlsym. - // - // Minor 4 adds durable distributed-DKG key-package retirement. Failed DKG - // reconciliation must reject ABI 4.3 rather than preserving orphaned keys. - requiredTBTCSignerABIMinMinor uint32 = 4 + // Major 5 replaces ABI 4.5's plaintext share-repair Delta/Sigma JSON with + // Rust-owned transient transport sessions and opaque ciphertexts. An ABI-4 + // library would put complete reconstructable scalar sets in Go memory, so + // this bridge must reject it before resolving any repair symbol. + requiredTBTCSignerABIMajor uint32 = 5 + // ABI 5.0 contains the complete native-custody share-repair surface: + // Begin/Finish plus ciphertext-only Part1, Part2, and Install. + requiredTBTCSignerABIMinMinor uint32 = 0 ) // ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go index fc43e22c3c..87dd5f6b50 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -89,15 +89,11 @@ func TestParseTBTCSignerABIVersion(t *testing.T) { } func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { - // Pins the bridge's current required contract: major 4 moves the durable-store - // identity schema and the state-witness transcript to v2, so that state - // commitments bind only the stable `.store-id` and no longer break when a - // benign filesystem change alters the lock file, directory inode, or device. - // Minor 3 adds the trust transition/head and bootstrap-facts surface used - // before production signing can start. Minor 4 adds durable distributed-DKG - // retirement. The matching library version is compatible; ABI 4.3 and a - // different major are not. - if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 4 { + // ABI 5.0 replaces the plaintext ABI-4.5 repair scalar contract with + // Rust-owned transport sessions and ciphertext-only Delta/Sigma messages. + // The matching library version is compatible; ABI 4.5 and every different + // major are not. + if requiredTBTCSignerABIMajor != 5 || requiredTBTCSignerABIMinMinor != 0 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, @@ -107,17 +103,8 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, requiredTBTCSignerABIMinMinor); err != nil { t.Fatalf("the required contract version must be self-compatible: %v", err) } - if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 0); err == nil { - t.Fatal("ABI 4.0 without readiness readbacks must be incompatible") - } - if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 1); err == nil { - t.Fatal("ABI 4.1 without the output-barrier tip/ack symbols must be incompatible") - } - if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 2); err == nil { - t.Fatal("ABI 4.2 without trust transition and bootstrap-facts symbols must be incompatible") - } - if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 3); err == nil { - t.Fatal("ABI 4.3 without distributed-DKG retirement must be incompatible") + if err := checkTBTCSignerABICompatibility(4, 5); err == nil { + t.Fatal("ABI 4.5 exposing plaintext repair scalars must be incompatible") } if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor+1, requiredTBTCSignerABIMinMinor); err == nil { t.Fatal("a higher major must be incompatible") diff --git a/pkg/frost/signing/native_tbtc_signer_inventory.go b/pkg/frost/signing/native_tbtc_signer_inventory.go index 52acc8f2e5..d5973454eb 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory.go @@ -13,13 +13,15 @@ import ( ) const ( - NativeTBTCSignerRetainedKeyPackageInventorySchema = "tbtc-signer-retained-key-package-inventory/v1" - NativeTBTCSignerStateWitnessProofRequestSchema = "tbtc-signer-state-witness-proof-request/v1" - NativeTBTCSignerStateWitnessProofSchema = "tbtc-signer-state-witness-proof/v1" + NativeTBTCSignerRetainedKeyPackageInventorySchema = "tbtc-signer-retained-key-package-inventory/v1" + NativeTBTCSignerRetainedKeyPackageInventoryRecoverySchema = "tbtc-signer-retained-key-package-inventory/v2" + NativeTBTCSignerStateWitnessProofRequestSchema = "tbtc-signer-state-witness-proof-request/v1" + NativeTBTCSignerStateWitnessProofSchema = "tbtc-signer-state-witness-proof/v1" NativeTBTCSignerStateWitnessProofMaximumEntries uint16 = 256 nativeTBTCSignerRetainedKeyPackageInventoryCommitmentDomain = "tbtc-signer-retained-key-package-inventory-commitment-v1\x00" + nativeTBTCSignerRecoveredSeatActivationCommitmentDomain = "tbtc-signer-recovered-seat-activation-commitment-v1\x00" nativeTBTCSignerStateWitnessGenesisDomain = "tbtc-signer-state-witness-genesis-v2\x00" nativeTBTCSignerStateWitnessCommitmentDomain = "tbtc-signer-state-witness-commitment-v2\x00" ) @@ -47,19 +49,34 @@ type NativeTBTCSignerRetainedKeyGroup struct { KeyPackages []NativeTBTCSignerRetainedKeyPackage } +// NativeTBTCSignerRecoveredSeat is durable evidence that this exact key-group +// seat was reconstructed into ActiveStoreFingerprint under one offline-signed +// authorization. A recovered seat remains inactive until the separately signed +// cutover registry supplies its matching old-stream tombstone lease. +type NativeTBTCSignerRecoveredSeat struct { + WalletID [32]byte + KeyGroup string + ParticipantSeat uint16 + RecoveryEpoch uint64 + AuthorizationDigest [32]byte + ActiveStoreFingerprint [32]byte +} + // NativeTBTCSignerRetainedKeyPackageInventory is a descriptor-locked snapshot // of the native signer. The state witness covers every durable engine-state // mutation, including replay markers; InventoryCommitment covers the sorted // public key-package inventory alone. type NativeTBTCSignerRetainedKeyPackageInventory struct { - Schema string - StoreFingerprint [32]byte - StateGeneration uint64 - StateCommitment [32]byte - PreviousStateCommitment [32]byte - StateImageDigest [32]byte - InventoryCommitment [32]byte - Entries []NativeTBTCSignerRetainedKeyGroup + Schema string + StoreFingerprint [32]byte + StateGeneration uint64 + StateCommitment [32]byte + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + InventoryCommitment [32]byte + Entries []NativeTBTCSignerRetainedKeyGroup + RecoveredSeats []NativeTBTCSignerRecoveredSeat + RecoveryActivationCommitment [32]byte } type nativeTBTCSignerRetainedKeyPackageWire struct { @@ -78,14 +95,25 @@ type nativeTBTCSignerRetainedKeyGroupWire struct { } type nativeTBTCSignerRetainedKeyPackageInventoryWire struct { - Schema string `json:"schema"` - StoreFingerprint string `json:"storeFingerprint"` - StateGeneration uint64 `json:"stateGeneration"` - StateCommitment string `json:"stateCommitment"` - PreviousStateCommitment string `json:"previousStateCommitment"` - StateImageDigest string `json:"stateImageDigest"` - InventoryCommitment string `json:"inventoryCommitment"` - Entries *[]nativeTBTCSignerRetainedKeyGroupWire `json:"entries"` + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + StateGeneration uint64 `json:"stateGeneration"` + StateCommitment string `json:"stateCommitment"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + InventoryCommitment string `json:"inventoryCommitment"` + Entries *[]nativeTBTCSignerRetainedKeyGroupWire `json:"entries"` + RecoveredSeats *[]nativeTBTCSignerRecoveredSeatWire `json:"recoveredSeats,omitempty"` + RecoveryActivationCommitment *string `json:"recoveryActivationCommitment,omitempty"` +} + +type nativeTBTCSignerRecoveredSeatWire struct { + WalletID string `json:"walletID"` + KeyGroup string `json:"keyGroup"` + ParticipantSeat uint16 `json:"participantSeat"` + RecoveryEpoch uint64 `json:"recoveryEpoch"` + AuthorizationDigest string `json:"authorizationDigest"` + ActiveStoreFingerprint string `json:"activeStoreFingerprint"` } // DecodeNativeTBTCSignerRetainedKeyPackageInventory validates the exact wire @@ -97,7 +125,8 @@ func DecodeNativeTBTCSignerRetainedKeyPackageInventory( if err := decodeStrictNativeTBTCSignerJSON(payload, wire, "retained key-package inventory"); err != nil { return nil, err } - if wire.Schema != NativeTBTCSignerRetainedKeyPackageInventorySchema { + if wire.Schema != NativeTBTCSignerRetainedKeyPackageInventorySchema && + wire.Schema != NativeTBTCSignerRetainedKeyPackageInventoryRecoverySchema { return nil, fmt.Errorf("unsupported retained key-package inventory schema") } if wire.StateGeneration == 0 { @@ -222,6 +251,87 @@ func DecodeNativeTBTCSignerRetainedKeyPackageInventory( if computed != result.InventoryCommitment { return nil, fmt.Errorf("retained key-package inventory commitment mismatch") } + if wire.Schema == NativeTBTCSignerRetainedKeyPackageInventorySchema { + if wire.RecoveredSeats != nil || wire.RecoveryActivationCommitment != nil { + return nil, fmt.Errorf("v1 retained inventory contains recovered-seat metadata") + } + return result, nil + } + if wire.RecoveredSeats == nil || len(*wire.RecoveredSeats) == 0 || + wire.RecoveryActivationCommitment == nil { + return nil, fmt.Errorf("v2 retained inventory recovered-seat metadata is incomplete") + } + entryByWallet := make(map[[32]byte]*NativeTBTCSignerRetainedKeyGroup, len(result.Entries)) + for index := range result.Entries { + entryByWallet[result.Entries[index].WalletID] = &result.Entries[index] + } + result.RecoveredSeats = make( + []NativeTBTCSignerRecoveredSeat, + len(*wire.RecoveredSeats), + ) + var previousWallet [32]byte + var previousSeat uint16 + for index, recoveredWire := range *wire.RecoveredSeats { + recovered := &result.RecoveredSeats[index] + walletID, err := decodeNativeTBTCSignerStoreBytes32(recoveredWire.WalletID) + if err != nil { + return nil, fmt.Errorf("invalid recovered-seat wallet ID: %w", err) + } + if index > 0 && (bytes.Compare(previousWallet[:], walletID[:]) > 0 || + (bytes.Equal(previousWallet[:], walletID[:]) && recoveredWire.ParticipantSeat <= previousSeat)) { + return nil, fmt.Errorf("recovered-seat entries are not unique and strictly sorted") + } + previousWallet = walletID + previousSeat = recoveredWire.ParticipantSeat + entry := entryByWallet[walletID] + if entry == nil || entry.KeyGroup != recoveredWire.KeyGroup || + recoveredWire.ParticipantSeat == 0 || + recoveredWire.ParticipantSeat > entry.ParticipantCount || + recoveredWire.RecoveryEpoch == 0 { + return nil, fmt.Errorf("recovered-seat entry does not match retained key-group inventory") + } + retained := false + for _, keyPackage := range entry.KeyPackages { + if keyPackage.ParticipantSeat == recoveredWire.ParticipantSeat { + retained = true + break + } + } + if !retained { + return nil, fmt.Errorf("recovered seat has no retained key package") + } + authorizationDigest, err := decodeNativeTBTCSignerStoreBytes32( + recoveredWire.AuthorizationDigest, + ) + if err != nil || authorizationDigest == [32]byte{} { + return nil, fmt.Errorf("invalid recovered-seat authorization digest") + } + activeStoreFingerprint, err := decodeNativeTBTCSignerStoreBytes32( + recoveredWire.ActiveStoreFingerprint, + ) + if err != nil || activeStoreFingerprint != result.StoreFingerprint { + return nil, fmt.Errorf("recovered seat belongs to another durable store") + } + *recovered = NativeTBTCSignerRecoveredSeat{ + WalletID: walletID, + KeyGroup: recoveredWire.KeyGroup, + ParticipantSeat: recoveredWire.ParticipantSeat, + RecoveryEpoch: recoveredWire.RecoveryEpoch, + AuthorizationDigest: authorizationDigest, + ActiveStoreFingerprint: activeStoreFingerprint, + } + } + recoveryCommitment, err := decodeNativeTBTCSignerStoreBytes32( + *wire.RecoveryActivationCommitment, + ) + if err != nil || recoveryCommitment == [32]byte{} { + return nil, fmt.Errorf("invalid recovered-seat activation commitment") + } + result.RecoveryActivationCommitment = recoveryCommitment + if ComputeNativeTBTCSignerRecoveredSeatActivationCommitment(result.RecoveredSeats) != + result.RecoveryActivationCommitment { + return nil, fmt.Errorf("recovered-seat activation commitment mismatch") + } return result, nil } @@ -253,6 +363,27 @@ func ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment( return result } +// ComputeNativeTBTCSignerRecoveredSeatActivationCommitment reproduces the +// native v2 inventory commitment over the sorted recovered-seat cutover facts. +func ComputeNativeTBTCSignerRecoveredSeatActivationCommitment( + seats []NativeTBTCSignerRecoveredSeat, +) [32]byte { + digest := sha256.New() + _, _ = digest.Write([]byte(nativeTBTCSignerRecoveredSeatActivationCommitmentDomain)) + writeNativeTBTCSignerUint32(digest, uint32(len(seats))) + for _, seat := range seats { + _, _ = digest.Write(seat.WalletID[:]) + writeNativeTBTCSignerStoreFingerprintField(digest, []byte(seat.KeyGroup)) + writeNativeTBTCSignerUint16(digest, seat.ParticipantSeat) + writeNativeTBTCSignerUint64(digest, seat.RecoveryEpoch) + _, _ = digest.Write(seat.AuthorizationDigest[:]) + _, _ = digest.Write(seat.ActiveStoreFingerprint[:]) + } + result := [32]byte{} + copy(result[:], digest.Sum(nil)) + return result +} + // ComputeNativeTBTCSignerStateWitnessGenesis derives the root preceding a // store's first state-witness record. It is exported so independent anchor // implementations and cross-language tests can reproduce the Rust transcript. diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_test.go b/pkg/frost/signing/native_tbtc_signer_inventory_test.go index bfd09bfcd1..fd31c988b2 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_test.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_test.go @@ -98,6 +98,99 @@ func TestNativeTBTCSignerInventoryCommitmentMatchesRustFrozenVector(t *testing.T } } +func TestNativeTBTCSignerRecoveryActivationCommitmentMatchesRustFrozenVector(t *testing.T) { + seats := []NativeTBTCSignerRecoveredSeat{ + { + WalletID: repeatedNativeTBTCSignerBytes32(0x11), + KeyGroup: "02" + strings.Repeat("11", 32), + ParticipantSeat: 3, + RecoveryEpoch: 7, + AuthorizationDigest: repeatedNativeTBTCSignerBytes32(0x22), + ActiveStoreFingerprint: repeatedNativeTBTCSignerBytes32(0x33), + }, + } + + actual := ComputeNativeTBTCSignerRecoveredSeatActivationCommitment(seats) + const expected = "48484643db480de91c011eece129e51fb32864f33887975009f993e54f7a2f20" + if hex.EncodeToString(actual[:]) != expected { + t.Fatalf("unexpected recovery activation commitment: [%x]", actual) + } +} + +func TestDecodeNativeTBTCSignerRetainedKeyPackageInventoryV2(t *testing.T) { + wire := testNativeTBTCSignerRetainedKeyPackageInventoryWire() + wire.Schema = NativeTBTCSignerRetainedKeyPackageInventoryRecoverySchema + recoveredSeats := []nativeTBTCSignerRecoveredSeatWire{ + { + WalletID: (*wire.Entries)[0].WalletID, + KeyGroup: (*wire.Entries)[0].KeyGroup, + ParticipantSeat: 3, + RecoveryEpoch: 7, + AuthorizationDigest: nativeTBTCSignerBytes32([32]byte{0x07}), + ActiveStoreFingerprint: wire.StoreFingerprint, + }, + } + wire.RecoveredSeats = &recoveredSeats + commitment := ComputeNativeTBTCSignerRecoveredSeatActivationCommitment( + []NativeTBTCSignerRecoveredSeat{ + { + WalletID: [32]byte{0x04}, + KeyGroup: (*wire.Entries)[0].KeyGroup, + ParticipantSeat: 3, + RecoveryEpoch: 7, + AuthorizationDigest: [32]byte{0x07}, + ActiveStoreFingerprint: [32]byte{0x01}, + }, + }, + ) + commitmentWire := nativeTBTCSignerBytes32(commitment) + wire.RecoveryActivationCommitment = &commitmentWire + + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + inventory, err := DecodeNativeTBTCSignerRetainedKeyPackageInventory(payload) + if err != nil { + t.Fatalf("valid v2 native inventory was rejected: [%v]", err) + } + if inventory.Schema != NativeTBTCSignerRetainedKeyPackageInventoryRecoverySchema || + len(inventory.RecoveredSeats) != 1 || + inventory.RecoveredSeats[0].RecoveryEpoch != 7 || + inventory.RecoveryActivationCommitment != commitment { + t.Fatalf("unexpected decoded v2 inventory: %+v", inventory) + } +} + +func TestDecodeNativeTBTCSignerRetainedKeyPackageInventoryRejectsRecoverySubstitution( + t *testing.T, +) { + tests := map[string]func(*nativeTBTCSignerRetainedKeyPackageInventoryWire){ + "v1 recovery fields": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + recovered := []nativeTBTCSignerRecoveredSeatWire{} + commitment := nativeTBTCSignerBytes32([32]byte{0x01}) + wire.RecoveredSeats = &recovered + wire.RecoveryActivationCommitment = &commitment + }, + "v2 missing recovery fields": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + wire.Schema = NativeTBTCSignerRetainedKeyPackageInventoryRecoverySchema + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + wire := testNativeTBTCSignerRetainedKeyPackageInventoryWire() + mutate(wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeNativeTBTCSignerRetainedKeyPackageInventory(payload); err == nil { + t.Fatal("substituted recovered-seat inventory was accepted") + } + }) + } +} + func TestNativeTBTCSignerStateWitnessCommitmentMatchesRustV2Vector(t *testing.T) { storeFingerprint := repeatedNativeTBTCSignerBytes32(0x11) genesis := ComputeNativeTBTCSignerStateWitnessGenesis(storeFingerprint) diff --git a/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go index 6c3d738c01..bf45cef987 100644 --- a/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go +++ b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go @@ -92,6 +92,15 @@ func driveInteractiveRoastSigningIfEnabled( // From here the node has COMMITTED to interactive signing: gate on, engine // present, orchestration active. Every failure below HARD-FAILS. + if err := ValidateLocalShareRepairSeatActivation( + attemptCtx.KeyGroupID, + request.MemberIndex, + ); err != nil { + return nil, fmt.Errorf( + "interactive ROAST signing: recovered-seat activation: %w", + err, + ) + } dkgGroupPublicKey, err := ExtractDkgGroupPublicKeyFromMaterial(request.SignerMaterial) if err != nil { return nil, fmt.Errorf( @@ -122,8 +131,12 @@ func driveInteractiveRoastSigningIfEnabled( return nil, fmt.Errorf("interactive ROAST signing: bind attempt: %w", err) } - bus, err := NewBroadcastChannelRunnerBus( - ctx, logger, request.Channel, request.MembershipValidator, + bus, err := NewActivationBoundBroadcastChannelRunnerBus( + ctx, + logger, + request.Channel, + request.MembershipValidator, + attemptCtx.KeyGroupID, ) if err != nil { return nil, fmt.Errorf("interactive ROAST signing: build transport bus: %w", err) diff --git a/pkg/frost/signing/roast_runner_bus_activation_frost_native_test.go b/pkg/frost/signing/roast_runner_bus_activation_frost_native_test.go new file mode 100644 index 0000000000..1d770746c6 --- /dev/null +++ b/pkg/frost/signing/roast_runner_bus_activation_frost_native_test.go @@ -0,0 +1,210 @@ +//go:build frost_native + +package signing + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "testing" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/net" +) + +type captureRunnerBroadcastChannel struct { + sent []*runnerTransportMessage +} + +func (*captureRunnerBroadcastChannel) Name() string { return "runner-activation-test" } +func (channel *captureRunnerBroadcastChannel) Send( + _ context.Context, + message net.TaggedMarshaler, + _ ...net.RetransmissionStrategy, +) error { + wire, ok := message.(*runnerTransportMessage) + if ok { + copy := *wire + copy.payload = append([]byte(nil), wire.payload...) + copy.activationLease = append([]byte(nil), wire.activationLease...) + channel.sent = append(channel.sent, ©) + } + return nil +} +func (*captureRunnerBroadcastChannel) Recv(context.Context, func(net.Message)) {} +func (*captureRunnerBroadcastChannel) SetUnmarshaler(func() net.TaggedUnmarshaler) {} +func (*captureRunnerBroadcastChannel) SetFilter(net.BroadcastChannelFilter) error { return nil } + +func installTestShareRepairRegistry( + t *testing.T, + localStore string, +) (*ShareRepairAuthorization, []byte) { + t.Helper() + authorization, authority := testShareRepairAuthorization(t) + lease := testShareRepairActivationLease(t, authorization, authority) + registry := &ShareRepairActivationRegistry{ + Schema: ShareRepairActivationRegistrySchema, + Leases: []ShareRepairActivationLease{lease}, + } + publicKey := authority.Public().(ed25519.PublicKey) + root, err := ShareRepairActivationRegistryRoot(registry, publicKey) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(registry) + if err != nil { + t.Fatal(err) + } + storeFingerprint, err := parseCanonicalShareRepairHex32( + localStore, + "local_store_fingerprint", + ) + if err != nil { + t.Fatal(err) + } + configureTestShareRepairActivationGuard( + t, + authorization, + storeFingerprint, + localStore == authorization.NewStoreFingerprint, + ) + if err := InstallShareRepairActivationRegistry( + payload, + publicKey, + root, + storeFingerprint, + ); err != nil { + t.Fatal(err) + } + wire, err := shareRepairActivationLeaseForBroadcast( + authorization.KeyGroup, + 3, + ) + if localStore == authorization.NewStoreFingerprint && (err != nil || len(wire) == 0) { + t.Fatalf("expected active-store lease: %v", err) + } + return authorization, wire +} + +func TestActivationBoundRunnerBusRequiresExactRecoveredSeatLease(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, leaseWire := installTestShareRepairRegistry( + t, + testShareRepairHex32(0x52), + ) + fixture := newRunnerBusAuthFixture(t, 8) + channel := &captureRunnerBroadcastChannel{} + busInterface, err := NewActivationBoundBroadcastChannelRunnerBus( + context.Background(), + &testutils.MockLogger{}, + channel, + fixture.validator, + authorization.KeyGroup, + ) + if err != nil { + t.Fatal(err) + } + bus := busInterface.(*broadcastChannelRunnerBus) + if !bus.activationBound { + t.Fatal("non-zero signed registry root did not select the v2 transport") + } + subscriber := bus.Subscribe() + message := RunnerMessage{ + Type: RunnerMsgShareSubmission, + Sender: 3, + Attempt: [32]byte{0x44}, + Payload: []byte("recovered-share"), + } + bus.Broadcast(message) + if len(channel.sent) != 1 || !channel.sent[0].activationBound || + channel.sent[0].Type() != "frost/roast_runner/v2/share_submission" || + string(channel.sent[0].activationLease) != string(leaseWire) { + t.Fatalf("recovered-seat broadcast omitted the exact activation lease: %+v", channel.sent) + } + + missingLease := fakeNetMessage{ + senderPublicKey: fixture.operatorA, + payload: &runnerTransportMessage{ + messageType: message.Type, + sender: message.Sender, + attempt: message.Attempt, + payload: message.Payload, + activationBound: true, + }, + } + bus.handleMessage(missingLease) + select { + case <-subscriber.Shares(): + t.Fatal("recovered-seat message without a lease was delivered") + default: + } + + exactLease := missingLease + wire := *missingLease.payload.(*runnerTransportMessage) + wire.activationLease = append([]byte(nil), leaseWire...) + exactLease.payload = &wire + bus.handleMessage(exactLease) + select { + case received := <-subscriber.Shares(): + if received.Sender != 3 || string(received.Payload) != "recovered-share" { + t.Fatalf("unexpected recovered-seat delivery: %+v", received) + } + default: + t.Fatal("exact authority-signed active-store lease was not delivered") + } + + encoded, err := channel.sent[0].Marshal() + if err != nil { + t.Fatal(err) + } + decoded := &runnerTransportMessage{ + messageType: RunnerMsgShareSubmission, + activationBound: true, + } + if err := decoded.Unmarshal(encoded); err != nil { + t.Fatal(err) + } + if string(decoded.activationLease) != string(leaseWire) || + string(decoded.payload) != "recovered-share" { + t.Fatal("v2 activation-bound frame did not round-trip") + } +} + +func TestActivationBoundRunnerBusRefusesPendingRecoveredSeatWithoutRegistry(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, _ := testShareRepairAuthorization(t) + newStore, err := parseCanonicalShareRepairHex32( + authorization.NewStoreFingerprint, + "new_store_fingerprint", + ) + if err != nil { + t.Fatal(err) + } + configureTestShareRepairActivationGuard(t, authorization, newStore, true) + fixture := newRunnerBusAuthFixture(t, 8) + channel := &captureRunnerBroadcastChannel{} + bus, err := NewActivationBoundBroadcastChannelRunnerBus( + context.Background(), + &testutils.MockLogger{}, + channel, + fixture.validator, + authorization.KeyGroup, + ) + if err != nil { + t.Fatal(err) + } + if !bus.(*broadcastChannelRunnerBus).activationBound { + t.Fatal("pending recovered seat did not force the v2 transport") + } + bus.Broadcast(RunnerMessage{ + Type: RunnerMsgCommitments, + Sender: 3, + Attempt: [32]byte{0x45}, + Payload: []byte("must-not-leave-old-store"), + }) + if len(channel.sent) != 0 { + t.Fatal("pending recovered seat broadcast before signed cutover") + } +} diff --git a/pkg/frost/signing/roast_runner_bus_net_frost_native.go b/pkg/frost/signing/roast_runner_bus_net_frost_native.go index 0f2e97eedc..474811a9d2 100644 --- a/pkg/frost/signing/roast_runner_bus_net_frost_native.go +++ b/pkg/frost/signing/roast_runner_bus_net_frost_native.go @@ -53,16 +53,26 @@ var runnerTransportType = map[RunnerMessageType]string{ RunnerMsgTransitionBundle: "frost/roast_runner/transition_bundle", } +var runnerActivationTransportType = map[RunnerMessageType]string{ + RunnerMsgCommitments: "frost/roast_runner/v2/commitments", + RunnerMsgSigningPackage: "frost/roast_runner/v2/signing_package", + RunnerMsgShareSubmission: "frost/roast_runner/v2/share_submission", + RunnerMsgEvidenceSnapshot: "frost/roast_runner/v2/evidence_snapshot", + RunnerMsgTransitionBundle: "frost/roast_runner/v2/transition_bundle", +} + // runnerTransportMessage is the wire envelope for one RunnerMessage. The five // runner stream types share this body and are distinguished by the Type() // string (set per registered unmarshaler), matching the RegisterUnmarshallers // convention. The body carries the CLAIMED sender seat, the attempt context // hash, and the opaque runner payload. type runnerTransportMessage struct { - messageType RunnerMessageType - sender group.MemberIndex - attempt [attemptContextHashLength]byte - payload []byte + messageType RunnerMessageType + sender group.MemberIndex + attempt [attemptContextHashLength]byte + payload []byte + activationBound bool + activationLease []byte } // attemptContextHashLength is the fixed wire length of the attempt context hash @@ -72,6 +82,9 @@ const attemptContextHashLength = sha256.Size // Type returns the pkg/net dispatch tag for this message's runner type. func (m *runnerTransportMessage) Type() string { + if m.activationBound { + return runnerActivationTransportType[m.messageType] + } return runnerTransportType[m.messageType] } @@ -82,10 +95,23 @@ func (m *runnerTransportMessage) Marshal() ([]byte, error) { if m.sender == 0 { return nil, fmt.Errorf("runner transport: sender is zero") } - out := make([]byte, 4+attemptContextHashLength+len(m.payload)) + leasePrefix := 0 + if m.activationBound { + if len(m.activationLease) > 1024*1024 { + return nil, fmt.Errorf("runner transport: activation lease exceeds the 1 MiB cap") + } + leasePrefix = 4 + len(m.activationLease) + } + out := make([]byte, 4+attemptContextHashLength+leasePrefix+len(m.payload)) binary.BigEndian.PutUint32(out[0:4], uint32(m.sender)) copy(out[4:4+attemptContextHashLength], m.attempt[:]) - copy(out[4+attemptContextHashLength:], m.payload) + offset := 4 + attemptContextHashLength + if m.activationBound { + binary.BigEndian.PutUint32(out[offset:offset+4], uint32(len(m.activationLease))) + offset += 4 + offset += copy(out[offset:], m.activationLease) + } + copy(out[offset:], m.payload) return out, nil } @@ -114,18 +140,35 @@ func (m *runnerTransportMessage) Unmarshal(data []byte) error { } m.sender = group.MemberIndex(rawSender) copy(m.attempt[:], data[4:prefix]) - m.payload = append([]byte(nil), data[prefix:]...) + offset := prefix + if m.activationBound { + if len(data) < offset+4 { + return fmt.Errorf("runner transport: activation lease length is truncated") + } + leaseLength := int(binary.BigEndian.Uint32(data[offset : offset+4])) + offset += 4 + if leaseLength > 1024*1024 || len(data) < offset+leaseLength { + return fmt.Errorf("runner transport: activation lease is truncated or oversized") + } + m.activationLease = append([]byte(nil), data[offset:offset+leaseLength]...) + offset += leaseLength + } + m.payload = append([]byte(nil), data[offset:]...) return nil } // registerRunnerTransportUnmarshalers registers one unmarshaler per runner // stream type, each presetting messageType so Type() and the demux know the // stream without a wire type tag. -func registerRunnerTransportUnmarshalers(channel net.BroadcastChannel) { - for messageType := range runnerTransportType { +func registerRunnerTransportUnmarshalers(channel net.BroadcastChannel, activationBound bool) { + transportTypes := runnerTransportType + if activationBound { + transportTypes = runnerActivationTransportType + } + for messageType := range transportTypes { mt := messageType channel.SetUnmarshaler(func() net.TaggedUnmarshaler { - return &runnerTransportMessage{messageType: mt} + return &runnerTransportMessage{messageType: mt, activationBound: activationBound} }) } } @@ -156,6 +199,8 @@ type broadcastChannelRunnerBus struct { membershipValidator *group.MembershipValidator streamBuffer int seenBound int + activationKeyGroup string + activationBound bool mu sync.Mutex subscribers []*RunnerBusSubscriber @@ -173,6 +218,46 @@ func NewBroadcastChannelRunnerBus( logger log.StandardLogger, channel net.BroadcastChannel, membershipValidator *group.MembershipValidator, +) (RunnerBus, error) { + return newBroadcastChannelRunnerBus( + ctx, + logger, + channel, + membershipValidator, + "", + false, + ) +} + +// NewActivationBoundBroadcastChannelRunnerBus switches to the v2 transport +// and enforces the authority-signed active-store lease whenever a recovery +// registry is installed or a locally recovered seat is awaiting cutover. With +// neither condition it preserves the v1 transport for pre-recovery fleets. +func NewActivationBoundBroadcastChannelRunnerBus( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + keyGroup string, +) (RunnerBus, error) { + activationBound := ShareRepairActivationTransportRequired() + return newBroadcastChannelRunnerBus( + ctx, + logger, + channel, + membershipValidator, + keyGroup, + activationBound, + ) +} + +func newBroadcastChannelRunnerBus( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + keyGroup string, + activationBound bool, ) (RunnerBus, error) { if ctx == nil { return nil, fmt.Errorf("runner bus: context is nil") @@ -183,6 +268,9 @@ func NewBroadcastChannelRunnerBus( if membershipValidator == nil { return nil, fmt.Errorf("runner bus: membership validator is nil") } + if activationBound && keyGroup == "" { + return nil, fmt.Errorf("runner bus: activation-bound key group is empty") + } if logger == nil { logger = log.Logger("frost-roast-runner-bus") } @@ -194,9 +282,11 @@ func NewBroadcastChannelRunnerBus( membershipValidator: membershipValidator, streamBuffer: defaultRunnerBusStreamBuffer, seenBound: defaultRunnerBusSeenBound, + activationKeyGroup: keyGroup, + activationBound: activationBound, } - registerRunnerTransportUnmarshalers(channel) + registerRunnerTransportUnmarshalers(channel, activationBound) return b, nil } @@ -234,10 +324,22 @@ func (b *broadcastChannelRunnerBus) Subscribe() *RunnerBusSubscriber { // records its OWN produced messages directly rather than relying on self-echo. func (b *broadcastChannelRunnerBus) Broadcast(msg RunnerMessage) { wire := &runnerTransportMessage{ - messageType: msg.Type, - sender: msg.Sender, - attempt: msg.Attempt, - payload: msg.Payload, + messageType: msg.Type, + sender: msg.Sender, + attempt: msg.Attempt, + payload: msg.Payload, + activationBound: b.activationBound, + } + if b.activationBound { + lease, err := shareRepairActivationLeaseForBroadcast( + b.activationKeyGroup, + msg.Sender, + ) + if err != nil { + b.logger.Warnf("runner bus: refusing stale recovered seat [%d]: [%v]", msg.Sender, err) + return + } + wire.activationLease = lease } if err := b.channel.Send(b.ctx, wire); err != nil { b.logger.Warnf("runner bus: failed to broadcast [%s] message: [%v]", wire.Type(), err) @@ -266,6 +368,19 @@ func (b *broadcastChannelRunnerBus) handleMessage(m net.Message) { ) return } + if b.activationBound { + if err := validateShareRepairActivationLeaseForMessage( + b.activationKeyGroup, + wire.sender, + wire.activationLease, + ); err != nil { + b.logger.Warnf( + "runner bus: dropping [%s] message from seat [%d] with invalid active-store lease: [%v]", + wire.Type(), wire.sender, err, + ) + return + } + } msg := RunnerMessage{ Type: wire.messageType, diff --git a/pkg/frost/signing/share_repair_bus_frost_native.go b/pkg/frost/signing/share_repair_bus_frost_native.go new file mode 100644 index 0000000000..eb9fabe342 --- /dev/null +++ b/pkg/frost/signing/share_repair_bus_frost_native.go @@ -0,0 +1,430 @@ +//go:build frost_native + +package signing + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "sync" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type shareRepairMessageType uint8 + +const ( + shareRepairAnnouncementMessage shareRepairMessageType = 1 + shareRepairDeltaMessage shareRepairMessageType = 2 + shareRepairSigmaMessage shareRepairMessageType = 3 + shareRepairInstalledMessage shareRepairMessageType = 4 + shareRepairPublicPackageMessage shareRepairMessageType = 5 + shareRepairInstalledAcknowledgementMessage shareRepairMessageType = 6 + shareRepairCompletionMessage shareRepairMessageType = 7 +) + +type shareRepairMessage struct { + Type shareRepairMessageType + Sender group.MemberIndex + Recipient group.MemberIndex + ContextDigest [32]byte + EphemeralPublicKey []byte + Payload []byte +} + +func (message shareRepairMessage) contentHash() [32]byte { + hasher := sha256.New() + hasher.Write([]byte{byte(message.Type), byte(message.Sender), byte(message.Recipient)}) + hasher.Write(message.ContextDigest[:]) + hasher.Write(message.EphemeralPublicKey) + hasher.Write(message.Payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +type shareRepairBus interface { + Subscribe(group.MemberIndex) <-chan shareRepairMessage + Start() + // Broadcast delivers the message and returns a function that stops its + // network retransmissions without canceling any other protocol message. + Broadcast(shareRepairMessage) context.CancelFunc +} + +type shareRepairBusSubscriber struct { + member group.MemberIndex + stream chan shareRepairMessage + mutex sync.Mutex + seen map[[32]byte]struct{} + acceptedBytes int + acceptedBytesBySender map[group.MemberIndex]int +} + +func (subscriber *shareRepairBusSubscriber) deliver( + message shareRepairMessage, + seenBound int, +) { + if message.Recipient != 0 && message.Recipient != subscriber.member { + return + } + if err := validateShareRepairMessage(message); err != nil { + return + } + hash := message.contentHash() + messageBytes := shareRepairMessageRetainedBytes(message) + subscriber.mutex.Lock() + defer subscriber.mutex.Unlock() + if subscriber.seen == nil { + subscriber.seen = make(map[[32]byte]struct{}) + } + if _, exists := subscriber.seen[hash]; exists { + return + } + if messageBytes > shareRepairMaximumSessionBytesPerSender- + subscriber.acceptedBytesBySender[message.Sender] || + messageBytes > shareRepairMaximumSessionBytes-subscriber.acceptedBytes { + return + } + // Check capacity before retaining attacker-controlled slices. Deliver is + // serialized per subscriber, so no other producer can fill this stream + // between this check and the non-blocking send below. + if len(subscriber.stream) >= cap(subscriber.stream) { + return + } + delivered := message + delivered.EphemeralPublicKey = append([]byte(nil), message.EphemeralPublicKey...) + delivered.Payload = append([]byte(nil), message.Payload...) + select { + case subscriber.stream <- delivered: + if seenBound > 0 && len(subscriber.seen) >= seenBound { + subscriber.seen = make(map[[32]byte]struct{}) + } + subscriber.seen[hash] = struct{}{} + if subscriber.acceptedBytesBySender == nil { + subscriber.acceptedBytesBySender = make(map[group.MemberIndex]int) + } + subscriber.acceptedBytes += messageBytes + subscriber.acceptedBytesBySender[message.Sender] += messageBytes + default: + // Honest traffic is O(threshold); a full stream means flooding. Drop the + // newest and let the bounded recovery context time out fail-closed. + } +} + +type inProcessShareRepairBus struct { + mutex sync.Mutex + subscribers []*shareRepairBusSubscriber + bufferSize int +} + +func newInProcessShareRepairBus(bufferSize int) shareRepairBus { + if bufferSize < 1 { + bufferSize = 1 + } + return &inProcessShareRepairBus{bufferSize: bufferSize} +} + +func (bus *inProcessShareRepairBus) Subscribe(member group.MemberIndex) <-chan shareRepairMessage { + subscriber := &shareRepairBusSubscriber{ + member: member, + stream: make(chan shareRepairMessage, bus.bufferSize), + seen: make(map[[32]byte]struct{}), + acceptedBytesBySender: make(map[group.MemberIndex]int), + } + bus.mutex.Lock() + bus.subscribers = append(bus.subscribers, subscriber) + bus.mutex.Unlock() + return subscriber.stream +} + +func (*inProcessShareRepairBus) Start() {} + +func (bus *inProcessShareRepairBus) Broadcast( + message shareRepairMessage, +) context.CancelFunc { + bus.mutex.Lock() + subscribers := append([]*shareRepairBusSubscriber(nil), bus.subscribers...) + bus.mutex.Unlock() + for _, subscriber := range subscribers { + subscriber.deliver(message, 4096) + } + return func() {} +} + +const shareRepairTransportType = "frost/share_repair/v1" + +const ( + shareRepairEphemeralPublicKeyLength = 33 + // A native repair envelope is compressed ephemeral SEC1 (33), XChaCha20 + // nonce (24), encrypted scalar (32), and Poly1305 tag (16). Keeping this + // exact at the transport boundary prevents a stale or custom engine from + // putting plaintext scalars on the wire. + shareRepairEncryptedScalarPayloadLength = 33 + 24 + 32 + 16 + shareRepairMaximumSecretPayload = 4 * 1024 + // A 100-seat native public-key package currently serializes below 14 KiB. + // Sixteen KiB leaves format headroom without allowing one frame to dominate + // the maintenance process's receive queues. + shareRepairMaximumPublicPayload = 16 * 1024 + // One honest helper contributes one maximum public package, one encrypted + // scalar to a given local seat, its announcement, and at most one receipt + // acknowledgement. Twenty KiB covers that traffic with room to spare; the + // aggregate cap covers the complete 100-seat authorization. + shareRepairMaximumSessionBytesPerSender = 20 * 1024 + shareRepairMaximumSessionBytes = 2 * 1024 * 1024 + shareRepairSubscriberStreamBuffer = 1024 +) + +func shareRepairMessageRetainedBytes(message shareRepairMessage) int { + return len(message.EphemeralPublicKey) + len(message.Payload) +} + +func validateShareRepairMessage(message shareRepairMessage) error { + if message.Type < shareRepairAnnouncementMessage || + message.Type > shareRepairCompletionMessage || + message.Sender == 0 || message.ContextDigest == [32]byte{} { + return fmt.Errorf("share-repair transport message is invalid") + } + return validateShareRepairMessageShape( + message.Type, + message.Recipient, + len(message.EphemeralPublicKey), + len(message.Payload), + ) +} + +func validateShareRepairMessageShape( + messageType shareRepairMessageType, + recipient group.MemberIndex, + ephemeralLength int, + payloadLength int, +) error { + switch messageType { + case shareRepairAnnouncementMessage: + if recipient != 0 || + ephemeralLength != shareRepairEphemeralPublicKeyLength || + payloadLength != 0 { + return fmt.Errorf("share-repair announcement shape is invalid") + } + case shareRepairInstalledMessage: + if recipient != 0 || ephemeralLength != 0 || payloadLength == 0 || + payloadLength > shareRepairMaximumSecretPayload { + return fmt.Errorf("share-repair installed receipt shape is invalid") + } + case shareRepairPublicPackageMessage: + if recipient != 0 || ephemeralLength != 0 || payloadLength == 0 || + payloadLength > shareRepairMaximumPublicPayload { + return fmt.Errorf("share-repair public-package message shape is invalid") + } + case shareRepairInstalledAcknowledgementMessage: + if recipient == 0 || ephemeralLength != 0 || payloadLength != sha256.Size { + return fmt.Errorf("share-repair installed acknowledgement shape is invalid") + } + case shareRepairCompletionMessage: + if recipient != 0 || ephemeralLength != 0 || payloadLength != sha256.Size { + return fmt.Errorf("share-repair completion shape is invalid") + } + case shareRepairDeltaMessage, shareRepairSigmaMessage: + if recipient == 0 || ephemeralLength != 0 || + payloadLength != shareRepairEncryptedScalarPayloadLength { + return fmt.Errorf("share-repair secret message shape is invalid") + } + default: + return fmt.Errorf("share-repair transport message is invalid") + } + return nil +} + +type shareRepairTransportMessage struct { + message shareRepairMessage +} + +func (*shareRepairTransportMessage) Type() string { return shareRepairTransportType } + +// Marshal encodes type(1) || sender(4) || recipient(4) || context(32) || +// ephemeral-key-length(2) || ephemeral-key || payload. +func (message *shareRepairTransportMessage) Marshal() ([]byte, error) { + value := message.message + if err := validateShareRepairMessage(value); err != nil { + return nil, err + } + result := make([]byte, 43+len(value.EphemeralPublicKey)+len(value.Payload)) + result[0] = byte(value.Type) + binary.BigEndian.PutUint32(result[1:5], uint32(value.Sender)) + binary.BigEndian.PutUint32(result[5:9], uint32(value.Recipient)) + copy(result[9:41], value.ContextDigest[:]) + binary.BigEndian.PutUint16(result[41:43], uint16(len(value.EphemeralPublicKey))) + offset := 43 + offset += copy(result[offset:], value.EphemeralPublicKey) + copy(result[offset:], value.Payload) + return result, nil +} + +func (message *shareRepairTransportMessage) Unmarshal(data []byte) error { + if len(data) < 43 { + return fmt.Errorf("share-repair transport message is truncated") + } + messageType := shareRepairMessageType(data[0]) + rawSender := binary.BigEndian.Uint32(data[1:5]) + rawRecipient := binary.BigEndian.Uint32(data[5:9]) + if messageType < shareRepairAnnouncementMessage || + messageType > shareRepairCompletionMessage || + rawSender == 0 || rawSender > uint32(group.MaxMemberIndex) || + rawRecipient > uint32(group.MaxMemberIndex) { + return fmt.Errorf("share-repair transport header is invalid") + } + contextDigest := [32]byte{} + copy(contextDigest[:], data[9:41]) + if contextDigest == [32]byte{} { + return fmt.Errorf("share-repair context digest is zero") + } + ephemeralLength := int(binary.BigEndian.Uint16(data[41:43])) + if len(data) < 43+ephemeralLength { + return fmt.Errorf("share-repair ephemeral key is truncated") + } + payloadLength := len(data) - (43 + ephemeralLength) + if err := validateShareRepairMessageShape( + messageType, + group.MemberIndex(rawRecipient), + ephemeralLength, + payloadLength, + ); err != nil { + return err + } + value := shareRepairMessage{ + Type: messageType, + Sender: group.MemberIndex(rawSender), + Recipient: group.MemberIndex(rawRecipient), + ContextDigest: contextDigest, + EphemeralPublicKey: append([]byte(nil), data[43:43+ephemeralLength]...), + Payload: append([]byte(nil), data[43+ephemeralLength:]...), + } + message.message = value + return nil +} + +type broadcastChannelShareRepairBus struct { + ctx context.Context + logger log.StandardLogger + channel net.BroadcastChannel + membershipValidator *group.MembershipValidator + participants map[group.MemberIndex]struct{} + expectedContext [32]byte + mutex sync.Mutex + subscribers []*shareRepairBusSubscriber + startOnce sync.Once +} + +func newBroadcastChannelShareRepairBus( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + participants map[group.MemberIndex]struct{}, + expectedContext [32]byte, +) (shareRepairBus, error) { + if ctx == nil || channel == nil || membershipValidator == nil || + len(participants) == 0 || expectedContext == [32]byte{} { + return nil, fmt.Errorf("share-repair bus dependencies are incomplete") + } + participantCopy := make(map[group.MemberIndex]struct{}, len(participants)) + for participant := range participants { + if participant == 0 || participant > group.MaxMemberIndex { + return nil, fmt.Errorf("share-repair participant [%d] is invalid", participant) + } + participantCopy[participant] = struct{}{} + } + if logger == nil { + logger = log.Logger("frost-share-repair-bus") + } + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &shareRepairTransportMessage{} + }) + return &broadcastChannelShareRepairBus{ + ctx: ctx, + logger: logger, + channel: channel, + membershipValidator: membershipValidator, + participants: participantCopy, + expectedContext: expectedContext, + }, nil +} + +func (bus *broadcastChannelShareRepairBus) Subscribe( + member group.MemberIndex, +) <-chan shareRepairMessage { + subscriber := &shareRepairBusSubscriber{ + member: member, + stream: make(chan shareRepairMessage, shareRepairSubscriberStreamBuffer), + seen: make(map[[32]byte]struct{}), + acceptedBytesBySender: make(map[group.MemberIndex]int), + } + bus.mutex.Lock() + bus.subscribers = append(bus.subscribers, subscriber) + bus.mutex.Unlock() + return subscriber.stream +} + +func (bus *broadcastChannelShareRepairBus) Start() { + bus.startOnce.Do(func() { bus.channel.Recv(bus.ctx, bus.handleMessage) }) +} + +func (bus *broadcastChannelShareRepairBus) deliver(message shareRepairMessage) { + if message.ContextDigest != bus.expectedContext { + return + } + bus.mutex.Lock() + subscribers := append([]*shareRepairBusSubscriber(nil), bus.subscribers...) + bus.mutex.Unlock() + for _, subscriber := range subscribers { + subscriber.deliver(message, 4096) + } +} + +func (bus *broadcastChannelShareRepairBus) Broadcast( + message shareRepairMessage, +) context.CancelFunc { + sendContext, cancel := context.WithCancel(bus.ctx) + // Deliver locally first. A channel implementation may not echo the sender; + // the content hash suppresses a later network echo. + bus.deliver(message) + if err := bus.channel.Send( + sendContext, + &shareRepairTransportMessage{message: message}, + ); err != nil { + bus.logger.Warnf("share-repair bus send failed: [%v]", err) + } + return cancel +} + +func (bus *broadcastChannelShareRepairBus) handleMessage(message net.Message) { + wire, ok := message.Payload().(*shareRepairTransportMessage) + if !ok { + return + } + if wire.message.ContextDigest != bus.expectedContext { + return + } + // Full wallet membership is broader than the exact helper/target set named + // by this recovery authorization. Drop an authenticated but unauthorized + // wallet seat before it can consume subscriber or pre-rendezvous capacity. + if _, participant := bus.participants[wire.message.Sender]; !participant { + // This path is attacker-controlled by any authenticated wallet seat not + // named in the repair certificate. Drop silently so admission control + // cannot be repurposed into warning-log amplification. + return + } + if !bus.membershipValidator.IsValidMembership( + wire.message.Sender, + message.SenderPublicKey(), + ) { + bus.logger.Warnf( + "share-repair bus dropped unauthenticated seat [%d]", + wire.message.Sender, + ) + return + } + bus.deliver(wire.message) +} diff --git a/pkg/frost/signing/share_repair_bus_frost_native_test.go b/pkg/frost/signing/share_repair_bus_frost_native_test.go new file mode 100644 index 0000000000..7404c76e77 --- /dev/null +++ b/pkg/frost/signing/share_repair_bus_frost_native_test.go @@ -0,0 +1,568 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type contextRecordingShareRepairChannel struct { + immediateRecvBroadcastChannel + sendContexts []context.Context +} + +func TestShareRepairPublicPackageTransportPayloadCap(t *testing.T) { + message := shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 1, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x55}, shareRepairMaximumPublicPayload), + } + wire, err := (&shareRepairTransportMessage{message: message}).Marshal() + if err != nil { + t.Fatalf("public package at the cap was rejected: %v", err) + } + decoded := &shareRepairTransportMessage{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("public package at the cap failed decoding: %v", err) + } + if len(decoded.message.Payload) != shareRepairMaximumPublicPayload { + t.Fatalf("decoded public package length is [%d]", len(decoded.message.Payload)) + } + + overCap := message + overCap.Payload = append(append([]byte(nil), message.Payload...), 0x56) + if _, err := (&shareRepairTransportMessage{message: overCap}).Marshal(); err == nil { + t.Fatal("public package above the cap was marshaled") + } + // Append directly so the receive boundary is tested independently from + // Marshal. Unmarshal must reject the shape before assigning copied slices. + overCapWire := append(append([]byte(nil), wire...), 0x56) + rejected := &shareRepairTransportMessage{} + if err := rejected.Unmarshal(overCapWire); err == nil { + t.Fatal("public package above the cap was decoded") + } + if rejected.message.Payload != nil || rejected.message.EphemeralPublicKey != nil { + t.Fatal("rejected public package populated retained message slices") + } +} + +func TestShareRepairPublicPackageProductionScale100SeatCap(t *testing.T) { + verifyingShares := make(map[string]string, 100) + for identifier := 1; identifier <= 100; identifier++ { + // Rust's bridge representation intentionally carries the 32-byte FROST + // identifier as a JSON-string-wrapped hex string. Preserve the quotes + // here so JSON map-key escaping is included in the launch-gate size. + wireIdentifier := fmt.Sprintf("\"%064x\"", identifier) + verifyingShares[wireIdentifier] = "03" + strings.Repeat("f", 64) + } + publicPackage, err := json.Marshal(&NativeFROSTPublicKeyPackage{ + VerifyingShares: verifyingShares, + VerifyingKey: strings.Repeat("f", 64), + }) + if err != nil { + t.Fatal(err) + } + if len(publicPackage) >= shareRepairMaximumPublicPayload { + t.Fatalf( + "100-seat native public package is [%d] bytes, cap is [%d]", + len(publicPackage), + shareRepairMaximumPublicPayload, + ) + } + wire, err := (&shareRepairTransportMessage{message: shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 1, + ContextDigest: [32]byte{0x44}, + Payload: publicPackage, + }}).Marshal() + if err != nil { + t.Fatalf("100-seat native public package exceeded transport shape: %v", err) + } + decoded := &shareRepairTransportMessage{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("100-seat native public package failed transport decoding: %v", err) + } + if !bytes.Equal(decoded.message.Payload, publicPackage) { + t.Fatal("100-seat native public package changed across transport") + } +} + +func (channel *contextRecordingShareRepairChannel) Send( + ctx context.Context, + _ net.TaggedMarshaler, + _ ...net.RetransmissionStrategy, +) error { + channel.sendContexts = append(channel.sendContexts, ctx) + return nil +} + +func TestShareRepairTransportRejectsMalformedFrames(t *testing.T) { + valid := shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: 1, + ContextDigest: [32]byte{0x01}, + EphemeralPublicKey: bytes.Repeat([]byte{0x02}, 33), + } + wire, err := (&shareRepairTransportMessage{message: valid}).Marshal() + if err != nil { + t.Fatal(err) + } + decoded := &shareRepairTransportMessage{} + if err := decoded.Unmarshal(wire); err != nil || decoded.message.Sender != 1 { + t.Fatalf("valid share-repair frame failed round trip: %v", err) + } + + mutations := map[string]func([]byte) []byte{ + "unknown type": func(value []byte) []byte { + value[0] = 0xff + return value + }, + "zero sender": func(value []byte) []byte { + for index := 1; index < 5; index++ { + value[index] = 0 + } + return value + }, + "zero context": func(value []byte) []byte { + for index := 9; index < 41; index++ { + value[index] = 0 + } + return value + }, + "truncated ephemeral key": func(value []byte) []byte { + return value[:len(value)-1] + }, + "announcement recipient": func(value []byte) []byte { + value[8] = 2 + return value + }, + "announcement payload": func(value []byte) []byte { + return append(value, 0x01) + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + candidate := mutate(append([]byte(nil), wire...)) + if err := (&shareRepairTransportMessage{}).Unmarshal(candidate); err == nil { + t.Fatal("malformed share-repair frame was accepted") + } + }) + } +} + +func TestShareRepairInstalledAcknowledgementTransportShape(t *testing.T) { + message := shareRepairMessage{ + Type: shareRepairInstalledAcknowledgementMessage, + Sender: 1, + Recipient: 3, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x55}, sha256.Size), + } + wire, err := (&shareRepairTransportMessage{message: message}).Marshal() + if err != nil { + t.Fatal(err) + } + decoded := &shareRepairTransportMessage{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatal(err) + } + if decoded.message.Type != message.Type || + decoded.message.Sender != message.Sender || + decoded.message.Recipient != message.Recipient || + decoded.message.ContextDigest != message.ContextDigest || + !bytes.Equal(decoded.message.Payload, message.Payload) { + t.Fatalf("installed acknowledgement changed across round trip: %+v", decoded.message) + } + + invalidRecipient := message + invalidRecipient.Recipient = 0 + if _, err := (&shareRepairTransportMessage{message: invalidRecipient}).Marshal(); err == nil { + t.Fatal("installed acknowledgement with zero recipient was accepted") + } + invalidDigest := message + invalidDigest.Payload = invalidDigest.Payload[:sha256.Size-1] + if _, err := (&shareRepairTransportMessage{message: invalidDigest}).Marshal(); err == nil { + t.Fatal("installed acknowledgement with truncated receipt digest was accepted") + } + + completion := message + completion.Type = shareRepairCompletionMessage + completion.Sender = message.Recipient + completion.Recipient = 0 + completionWire, err := (&shareRepairTransportMessage{message: completion}).Marshal() + if err != nil { + t.Fatal(err) + } + decodedCompletion := &shareRepairTransportMessage{} + if err := decodedCompletion.Unmarshal(completionWire); err != nil { + t.Fatal(err) + } + if decodedCompletion.message.Type != shareRepairCompletionMessage || + decodedCompletion.message.Sender != completion.Sender || + decodedCompletion.message.Recipient != 0 || + !bytes.Equal(decodedCompletion.message.Payload, completion.Payload) { + t.Fatalf("share-repair completion changed across round trip: %+v", decodedCompletion.message) + } + invalidCompletion := completion + invalidCompletion.Recipient = message.Sender + if _, err := (&shareRepairTransportMessage{message: invalidCompletion}).Marshal(); err == nil { + t.Fatal("share-repair completion with a recipient was accepted") + } +} + +func TestShareRepairBusCancelsRetransmissionsPerMessage(t *testing.T) { + fixture := newRunnerBusAuthFixture(t, 8) + parentContext, cancelParent := context.WithCancel(context.Background()) + defer cancelParent() + channel := &contextRecordingShareRepairChannel{} + bus, err := newBroadcastChannelShareRepairBus( + parentContext, + &testutils.MockLogger{}, + channel, + fixture.validator, + map[group.MemberIndex]struct{}{1: {}, 2: {}}, + [32]byte{0x44}, + ) + if err != nil { + t.Fatal(err) + } + message := shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: 1, + ContextDigest: [32]byte{0x44}, + EphemeralPublicKey: bytes.Repeat([]byte{0x55}, shareRepairEphemeralPublicKeyLength), + } + cancelFirst := bus.Broadcast(message) + message.Sender = 2 + cancelSecond := bus.Broadcast(message) + defer cancelSecond() + if len(channel.sendContexts) != 2 { + t.Fatalf("expected two per-message send contexts, got [%d]", len(channel.sendContexts)) + } + if channel.sendContexts[0] == channel.sendContexts[1] { + t.Fatal("share-repair messages reused one retransmission context") + } + cancelFirst() + select { + case <-channel.sendContexts[0].Done(): + default: + t.Fatal("canceling a broadcast did not stop its retransmission context") + } + select { + case <-channel.sendContexts[1].Done(): + t.Fatal("canceling one broadcast stopped a different message") + default: + } + if parentContext.Err() != nil { + t.Fatal("canceling a broadcast canceled the maintenance context") + } +} + +func TestShareRepairBusAuthenticatesSenderAndSuppressesReplay(t *testing.T) { + fixture := newRunnerBusAuthFixture(t, 8) + channel := &immediateRecvBroadcastChannel{} + busInterface, err := newBroadcastChannelShareRepairBus( + context.Background(), + &testutils.MockLogger{}, + channel, + fixture.validator, + map[group.MemberIndex]struct{}{1: {}, 2: {}}, + [32]byte{0x44}, + ) + if err != nil { + t.Fatal(err) + } + bus := busInterface.(*broadcastChannelShareRepairBus) + stream := bus.Subscribe(group.MemberIndex(2)) + message := shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: 1, + ContextDigest: [32]byte{0x44}, + EphemeralPublicKey: bytes.Repeat([]byte{0x03}, 33), + } + wire := &shareRepairTransportMessage{message: message} + bus.handleMessage(fakeNetMessage{senderPublicKey: fixture.operatorB, payload: wire}) + select { + case <-stream: + t.Fatal("claimed sender authenticated by the wrong operator was delivered") + default: + } + + wrongContext := message + wrongContext.ContextDigest = [32]byte{0x45} + bus.handleMessage(fakeNetMessage{ + senderPublicKey: fixture.operatorA, + payload: &shareRepairTransportMessage{message: wrongContext}, + }) + select { + case <-stream: + t.Fatal("wrong-context share-repair message was delivered") + default: + } + bus.subscribers[0].mutex.Lock() + if bus.subscribers[0].acceptedBytes != 0 { + bus.subscribers[0].mutex.Unlock() + t.Fatal("wrong-context message consumed subscriber budget") + } + bus.subscribers[0].mutex.Unlock() + + authenticated := fakeNetMessage{senderPublicKey: fixture.operatorA, payload: wire} + bus.handleMessage(authenticated) + bus.handleMessage(authenticated) + select { + case received := <-stream: + if received.Sender != 1 || received.ContextDigest != message.ContextDigest { + t.Fatalf("unexpected authenticated share-repair message: %+v", received) + } + default: + t.Fatal("authenticated share-repair message was not delivered") + } + select { + case <-stream: + t.Fatal("replayed share-repair message was delivered twice") + default: + } +} + +func TestShareRepairBusRejectsAuthenticatedNonparticipantBeforeDelivery(t *testing.T) { + fixture := newRunnerBusAuthFixture(t, 8) + busInterface, err := newBroadcastChannelShareRepairBus( + context.Background(), + &testutils.MockLogger{}, + &immediateRecvBroadcastChannel{}, + fixture.validator, + map[group.MemberIndex]struct{}{1: {}, 2: {}}, + [32]byte{0x44}, + ) + if err != nil { + t.Fatal(err) + } + bus := busInterface.(*broadcastChannelShareRepairBus) + stream := bus.Subscribe(group.MemberIndex(2)) + + // Operator A legitimately owns both seat 1 and seat 3 in the wallet, but + // this repair authorization names only seats 1 and 2. Seat 3 must be + // rejected before it consumes subscriber capacity. + nonparticipant := &shareRepairTransportMessage{message: shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: 3, + Recipient: 2, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x31}, shareRepairEncryptedScalarPayloadLength), + }} + bus.handleMessage(fakeNetMessage{ + senderPublicKey: fixture.operatorA, + payload: nonparticipant, + }) + select { + case <-stream: + t.Fatal("authenticated nonparticipant frame was delivered") + default: + } + + // Early phase frames from an exact participant remain valid while another + // subscriber may still be collecting announcements. + participant := &shareRepairTransportMessage{message: shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: 1, + Recipient: 2, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x32}, shareRepairEncryptedScalarPayloadLength), + }} + bus.handleMessage(fakeNetMessage{ + senderPublicKey: fixture.operatorA, + payload: participant, + }) + select { + case received := <-stream: + if received.Sender != 1 || received.Type != shareRepairDeltaMessage { + t.Fatalf("unexpected participant frame: %+v", received) + } + default: + t.Fatal("authorized early phase frame was not delivered") + } +} + +func TestShareRepairSubscriberPerSenderByteBudget(t *testing.T) { + subscriber := &shareRepairBusSubscriber{ + member: 1, + stream: make(chan shareRepairMessage, 4), + seen: make(map[[32]byte]struct{}), + acceptedBytesBySender: make(map[group.MemberIndex]int), + } + contextDigest := [32]byte{0x44} + publicPackage := shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 2, + ContextDigest: contextDigest, + Payload: bytes.Repeat([]byte{0x41}, shareRepairMaximumPublicPayload), + } + installed := shareRepairMessage{ + Type: shareRepairInstalledMessage, + Sender: 2, + ContextDigest: contextDigest, + Payload: bytes.Repeat([]byte{0x42}, shareRepairMaximumSecretPayload), + } + subscriber.deliver(publicPackage, 4096) + subscriber.deliver(installed, 4096) + if subscriber.acceptedBytes != shareRepairMaximumSessionBytesPerSender || + subscriber.acceptedBytesBySender[2] != shareRepairMaximumSessionBytesPerSender || + len(subscriber.stream) != 2 { + t.Fatalf( + "subscriber accepted [%d]/[%d] bytes and [%d] messages", + subscriber.acceptedBytes, + subscriber.acceptedBytesBySender[2], + len(subscriber.stream), + ) + } + subscriber.deliver(shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: 2, + ContextDigest: contextDigest, + Payload: bytes.Repeat([]byte{0x43}, sha256.Size), + }, 4096) + if subscriber.acceptedBytes != shareRepairMaximumSessionBytesPerSender || + len(subscriber.stream) != 2 { + t.Fatal("over-budget sender consumed subscriber capacity") + } +} + +func TestShareRepairSubscriberTotalByteBudget(t *testing.T) { + subscriber := &shareRepairBusSubscriber{ + member: 200, + stream: make(chan shareRepairMessage, 256), + seen: make(map[[32]byte]struct{}), + acceptedBytesBySender: make(map[group.MemberIndex]int), + } + contextDigest := [32]byte{0x44} + for rawSender := 1; rawSender <= 102; rawSender++ { + sender := group.MemberIndex(rawSender) + subscriber.deliver(shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: sender, + ContextDigest: contextDigest, + Payload: bytes.Repeat([]byte{byte(sender)}, shareRepairMaximumPublicPayload), + }, 4096) + subscriber.deliver(shareRepairMessage{ + Type: shareRepairInstalledMessage, + Sender: sender, + ContextDigest: contextDigest, + Payload: bytes.Repeat([]byte{byte(sender)}, shareRepairMaximumSecretPayload), + }, 4096) + } + remaining := shareRepairMaximumSessionBytes - subscriber.acceptedBytes + if remaining <= 0 || remaining > shareRepairMaximumPublicPayload { + t.Fatalf("unexpected remaining total budget [%d]", remaining) + } + subscriber.deliver(shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 103, + ContextDigest: contextDigest, + Payload: bytes.Repeat([]byte{0x67}, remaining), + }, 4096) + if subscriber.acceptedBytes != shareRepairMaximumSessionBytes { + t.Fatalf("subscriber accepted [%d] total bytes", subscriber.acceptedBytes) + } + acceptedMessages := len(subscriber.stream) + subscriber.deliver(shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: 104, + ContextDigest: contextDigest, + EphemeralPublicKey: bytes.Repeat([]byte{0x02}, shareRepairEphemeralPublicKeyLength), + }, 4096) + if subscriber.acceptedBytes != shareRepairMaximumSessionBytes || + len(subscriber.stream) != acceptedMessages { + t.Fatal("message above the total subscriber budget was retained") + } +} + +func TestShareRepairSubscriberDuplicateAndFullStreamDoNotCharge(t *testing.T) { + subscriber := &shareRepairBusSubscriber{ + member: 3, + stream: make(chan shareRepairMessage, 1), + seen: make(map[[32]byte]struct{}), + acceptedBytesBySender: make(map[group.MemberIndex]int), + } + first := shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: 1, + ContextDigest: [32]byte{0x44}, + EphemeralPublicKey: bytes.Repeat([]byte{0x02}, shareRepairEphemeralPublicKeyLength), + } + second := first + second.Sender = 2 + second.EphemeralPublicKey = bytes.Repeat( + []byte{0x03}, + shareRepairEphemeralPublicKeyLength, + ) + subscriber.deliver(first, 4096) + subscriber.deliver(first, 4096) + subscriber.deliver(second, 4096) + if subscriber.acceptedBytes != shareRepairEphemeralPublicKeyLength || + subscriber.acceptedBytesBySender[2] != 0 || len(subscriber.stream) != 1 { + t.Fatal("duplicate or full-stream delivery consumed subscriber budget") + } + if _, seen := subscriber.seen[second.contentHash()]; seen { + t.Fatal("full-stream delivery was marked as seen") + } + <-subscriber.stream + subscriber.deliver(second, 4096) + if subscriber.acceptedBytes != 2*shareRepairEphemeralPublicKeyLength || + subscriber.acceptedBytesBySender[2] != shareRepairEphemeralPublicKeyLength || + len(subscriber.stream) != 1 { + t.Fatal("previously full-stream delivery could not be retried") + } +} + +func TestShareRepairSubscriberConcurrentDeliveryIsRaceSafe(t *testing.T) { + const senderCount = 64 + const messagesPerSender = 8 + subscriber := &shareRepairBusSubscriber{ + member: 100, + stream: make(chan shareRepairMessage, senderCount*messagesPerSender), + seen: make(map[[32]byte]struct{}), + acceptedBytesBySender: make(map[group.MemberIndex]int), + } + var waitGroup sync.WaitGroup + for rawSender := 1; rawSender <= senderCount; rawSender++ { + sender := group.MemberIndex(rawSender) + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + for sequence := 0; sequence < messagesPerSender; sequence++ { + payload := make([]byte, sha256.Size) + payload[0] = byte(sender) + payload[1] = byte(sequence) + subscriber.deliver(shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: sender, + ContextDigest: [32]byte{0x44}, + Payload: payload, + }, 4096) + } + }() + } + waitGroup.Wait() + expectedMessages := senderCount * messagesPerSender + expectedBytes := expectedMessages * sha256.Size + if len(subscriber.stream) != expectedMessages || + subscriber.acceptedBytes != expectedBytes { + t.Fatalf( + "concurrent delivery retained [%d] messages and [%d] bytes", + len(subscriber.stream), + subscriber.acceptedBytes, + ) + } +} diff --git a/pkg/frost/signing/share_repair_frost_native.go b/pkg/frost/signing/share_repair_frost_native.go new file mode 100644 index 0000000000..01cbcca5a7 --- /dev/null +++ b/pkg/frost/signing/share_repair_frost_native.go @@ -0,0 +1,1206 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const ( + ShareRepairAuthorizationSchema = "tbtc-frost-share-repair-authorization/v1" + ShareRepairTransportRosterSchema = "tbtc-frost-share-repair-transport-roster/v1" + ShareRepairRecoveryBundleSchema = "tbtc-frost-share-repair-bundle/v1" + ShareRepairTransportPreflightSchema = "tbtc-frost-share-repair-transport-preflight/v1" + ShareRepairInstallResultSchema = "tbtc-frost-share-repair-install-result/v1" + ShareRepairActivationLeaseSchema = "tbtc-frost-share-repair-activation/v1" + ShareRepairActivationRegistrySchema = "tbtc-frost-share-repair-activation-registry/v1" + shareRepairAuthorizationDomain = "tbtc-frost-share-repair-authorization/v1\x00" + shareRepairTransportRosterDomain = "tbtc-frost-share-repair-transport-roster/v1\x00" + shareRepairActivationLeaseDomain = "tbtc-frost-share-repair-activation/v1\x00" + shareRepairActivationRegistryDomain = "tbtc-frost-share-repair-activation-registry/v1\x00" + shareRepairMaximumAuthorizationAge = 24 * time.Hour + shareRepairMaximumActivationRegistry = 4096 + shareRepairMaximumSessionIDLength = 128 +) + +// ShareRepairAuthorization is the frozen offline-authority certificate used by +// both the Go recovery protocol and the Rust signer. Every field except the +// signature is included in the domain-separated digest below. +type ShareRepairAuthorization struct { + Schema string `json:"schema"` + SessionID string `json:"session_id"` + WalletID string `json:"wallet_id"` + KeyGroup string `json:"key_group"` + PublicKeyPackageCommitment string `json:"public_key_package_commitment"` + TargetIdentifier uint16 `json:"target_identifier"` + HelperIdentifiers []uint16 `json:"helper_identifiers"` + Threshold uint16 `json:"threshold"` + ParticipantCount uint16 `json:"participant_count"` + OldStoreFingerprint string `json:"old_store_fingerprint"` + NewStoreFingerprint string `json:"new_store_fingerprint"` + RecoveryEpoch uint64 `json:"recovery_epoch"` + IssuedAtUnix uint64 `json:"issued_at_unix"` + NotBeforeUnix uint64 `json:"not_before_unix"` + ExpiresAtUnix uint64 `json:"expires_at_unix"` + Nonce string `json:"nonce"` + SignatureHex string `json:"signature_hex"` +} + +// ShareRepairTransportPublicKey binds one authorized participant and native +// store to the public half of its Rust-derived, authorization-scoped repair +// transport key. +type ShareRepairTransportPublicKey struct { + ParticipantIdentifier uint16 `json:"participant_identifier"` + StoreFingerprint string `json:"store_fingerprint"` + PublicKeyHex string `json:"public_key_hex"` +} + +// ShareRepairTransportRoster is the offline-authority-signed rendezvous +// artifact. Its signature prevents the Go host from substituting a transport +// key it controls when asking Rust to encrypt a repair scalar. +type ShareRepairTransportRoster struct { + Schema string `json:"schema"` + AuthorizationDigest string `json:"authorization_digest"` + ParticipantPublicKeys []ShareRepairTransportPublicKey `json:"participant_public_keys"` + SignatureHex string `json:"signature_hex"` +} + +// ShareRepairRecoveryBundle is the single owner-only maintenance artifact. +// The authorization and transport roster carry independent signatures from +// the same offline authority; the outer schema only makes file decoding +// explicit and downgrade-safe. +type ShareRepairRecoveryBundle struct { + Schema string `json:"schema"` + Authorization ShareRepairAuthorization `json:"authorization"` + TransportRoster ShareRepairTransportRoster `json:"transport_roster"` +} + +// ShareRepairTransportPreflight is an unsigned, public ceremony artifact +// emitted by one operator after the native signer proves local seat/store +// possession to the API. The offline authority authenticates its source and +// workload out of band, merges the exact participant set, and signs +// ShareRepairTransportRoster. This artifact is not hardware attestation. +type ShareRepairTransportPreflight struct { + Schema string `json:"schema"` + AuthorizationDigest string `json:"authorization_digest"` + ParticipantPublicKeys []ShareRepairTransportPublicKey `json:"participant_public_keys"` +} + +// NativeShareRepairSession exposes only the public half and store binding of an +// authorization-scoped repair transport key. The matching derived private key +// and every plaintext repair scalar remain behind the native API. Finish evicts +// the live cache; the key remains re-derivable from the protected state root +// until the signed authorization expires. +type NativeShareRepairSession struct { + ContextDigest string + ParticipantIdentifier uint16 + StoreFingerprint string + TransportPublicKey []byte +} + +// NativeShareRepairEncryptedDelta and NativeShareRepairEncryptedSigma contain +// opaque authenticated ciphertexts. Protocol-conformant Go may route and +// retransmit Payload but receives no decryption capability; complete plaintext +// scalar sets never appear in FFI requests or responses. This API property is +// not process isolation from arbitrary same-address-space memory access. +type NativeShareRepairEncryptedDelta struct { + ContextDigest string + SenderIdentifier uint16 + RecipientIdentifier uint16 + Payload []byte +} + +type NativeShareRepairEncryptedSigma struct { + ContextDigest string + HelperIdentifier uint16 + Payload []byte +} + +type NativeShareRepairPart1Result struct { + ContextDigest string + HelperIdentifier uint16 + PublicKeyPackage *NativeFROSTPublicKeyPackage + Deltas []*NativeShareRepairEncryptedDelta +} + +type NativeShareRepairPart2Result struct { + ContextDigest string + Sigma *NativeShareRepairEncryptedSigma +} + +type NativeShareRepairInstallResult struct { + Schema string + SessionID string + KeyGroup string + TargetIdentifier uint16 + RecoveryEpoch uint64 + AuthorizationDigest string + ActiveStoreFingerprint string + Idempotent bool +} + +// NativeTBTCSignerShareRepairEngine is kept separate from the ordinary DKG +// capability so a stale ABI cannot accidentally be treated as DR-capable. +type NativeTBTCSignerShareRepairEngine interface { + BeginShareRepairSession( + authorization *ShareRepairAuthorization, + participantIdentifier uint16, + ) (*NativeShareRepairSession, error) + FinishShareRepairSession( + authorization *ShareRepairAuthorization, + participantIdentifier uint16, + ) error + ShareRepairPart1( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + transportRoster *ShareRepairTransportRoster, + ) (*NativeShareRepairPart1Result, error) + ShareRepairPart2( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairEncryptedDelta, + transportRoster *ShareRepairTransportRoster, + ) (*NativeShareRepairPart2Result, error) + InstallRepairedShare( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairEncryptedSigma, + transportRoster *ShareRepairTransportRoster, + ) (*NativeShareRepairInstallResult, error) +} + +// ShareRepairActivationLease is signed only after the repaired Rust state and +// its independent anchor acknowledgement are durable and the old anchor stream +// has been tombstoned. Nodes exchange this public certificate on every ROAST +// message from the recovered seat; a stale process has no current lease. +type ShareRepairActivationLease struct { + Schema string `json:"schema"` + Authorization ShareRepairAuthorization `json:"authorization"` + AuthorizationDigest string `json:"authorization_digest"` + OldStoreTombstoneDigest string `json:"old_store_tombstone_digest"` + ActivatedAtUnix uint64 `json:"activated_at_unix"` + SignatureHex string `json:"signature_hex"` +} + +type ShareRepairActivationRegistry struct { + Schema string `json:"schema"` + Leases []ShareRepairActivationLease `json:"leases"` +} + +type validatedShareRepairAuthorization struct { + digest [32]byte + walletID [32]byte + newStoreFingerprint [32]byte +} + +// DecodeShareRepairAuthorization strictly decodes the detached, public +// recovery certificate. Signature and wall-clock validation remain in +// RunShareRepair, where the manifest-pinned authority key is available. +func DecodeShareRepairAuthorization(payload []byte) (*ShareRepairAuthorization, error) { + if len(payload) == 0 || len(payload) > 256*1024 { + return nil, fmt.Errorf("share-repair authorization size is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + authorization := &ShareRepairAuthorization{} + if err := decoder.Decode(authorization); err != nil { + return nil, fmt.Errorf("cannot decode share-repair authorization: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("share-repair authorization has trailing JSON") + } + if _, err := ComputeShareRepairAuthorizationDigest(authorization); err != nil { + return nil, err + } + if _, err := parseCanonicalShareRepairSignature(authorization.SignatureHex); err != nil { + return nil, err + } + return authorization, nil +} + +// DecodeShareRepairRecoveryBundle strictly decodes the one-shot maintenance +// artifact. Authority and time validation remain in RunShareRepair, after the +// manifest-pinned authority key is available. +func DecodeShareRepairRecoveryBundle( + payload []byte, +) (*ShareRepairRecoveryBundle, error) { + if len(payload) == 0 || len(payload) > 256*1024 { + return nil, fmt.Errorf("share-repair recovery bundle size is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + bundle := &ShareRepairRecoveryBundle{} + if err := decoder.Decode(bundle); err != nil { + return nil, fmt.Errorf("cannot decode share-repair recovery bundle: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("share-repair recovery bundle has trailing JSON") + } + if bundle.Schema != ShareRepairRecoveryBundleSchema { + return nil, fmt.Errorf("unsupported share-repair recovery bundle schema") + } + if _, err := ComputeShareRepairAuthorizationDigest(&bundle.Authorization); err != nil { + return nil, err + } + if _, err := parseCanonicalShareRepairSignature( + bundle.Authorization.SignatureHex, + ); err != nil { + return nil, err + } + if _, err := ComputeShareRepairTransportRosterDigest( + &bundle.TransportRoster, + &bundle.Authorization, + ); err != nil { + return nil, err + } + if _, err := parseCanonicalShareRepairSignature( + bundle.TransportRoster.SignatureHex, + ); err != nil { + return nil, err + } + return bundle, nil +} + +func parseCanonicalShareRepairHex32(value string, label string) ([32]byte, error) { + result := [32]byte{} + if len(value) != 66 || !strings.HasPrefix(value, "0x") || value != strings.ToLower(value) { + return result, fmt.Errorf("%s must be canonical lowercase 0x-prefixed bytes32", label) + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != 32 { + return result, fmt.Errorf("%s must be canonical lowercase 0x-prefixed bytes32", label) + } + copy(result[:], decoded) + if result == [32]byte{} { + return result, fmt.Errorf("%s must not be zero", label) + } + return result, nil +} + +func parseCanonicalShareRepairSignature(value string) ([]byte, error) { + if len(value) != 130 || !strings.HasPrefix(value, "0x") || value != strings.ToLower(value) { + return nil, fmt.Errorf("signature must be canonical lowercase 0x-prefixed 64-byte hex") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != ed25519.SignatureSize { + return nil, fmt.Errorf("signature must be canonical lowercase 0x-prefixed 64-byte hex") + } + return decoded, nil +} + +func writeShareRepairLengthPrefixed(buffer *bytes.Buffer, value []byte) error { + if uint64(len(value)) > uint64(^uint32(0)) { + return fmt.Errorf("share-repair transcript field exceeds uint32") + } + length := [4]byte{} + binary.BigEndian.PutUint32(length[:], uint32(len(value))) + buffer.Write(length[:]) + buffer.Write(value) + return nil +} + +func isValidShareRepairSessionID(sessionID string) bool { + if len(sessionID) == 0 || + len(sessionID) > shareRepairMaximumSessionIDLength || + !utf8.ValidString(sessionID) { + return false + } + + // Keep this byte-level grammar synchronized with Rust's + // validate_session_id. The signed value is forwarded unchanged across the + // FFI boundary, so Go must never authorize a value the native signer rejects. + for i := 0; i < len(sessionID); i++ { + value := sessionID[i] + if value <= 0x1f || value == 0x7f { + return false + } + switch value { + case ' ', '=', '"', '\\': + return false + } + } + + return true +} + +// ComputeShareRepairAuthorizationDigest implements the same frozen transcript +// as Rust. It performs all structural validation but intentionally does not +// check wall-clock validity or the signature. +func ComputeShareRepairAuthorizationDigest( + authorization *ShareRepairAuthorization, +) ([32]byte, error) { + result := [32]byte{} + if authorization == nil { + return result, fmt.Errorf("share-repair authorization is nil") + } + if authorization.Schema != ShareRepairAuthorizationSchema { + return result, fmt.Errorf("unsupported share-repair authorization schema") + } + if !isValidShareRepairSessionID(authorization.SessionID) { + return result, fmt.Errorf("share-repair session id is invalid") + } + if authorization.Threshold < 2 || authorization.ParticipantCount < authorization.Threshold || + authorization.ParticipantCount > 100 || + authorization.ParticipantCount > uint16(group.MaxMemberIndex) { + return result, fmt.Errorf("share-repair threshold or participant count is invalid") + } + if len(authorization.HelperIdentifiers) != int(authorization.Threshold) { + return result, fmt.Errorf("helper set must contain exactly threshold members") + } + if authorization.TargetIdentifier == 0 || + authorization.TargetIdentifier > authorization.ParticipantCount { + return result, fmt.Errorf("target identifier is outside the participant set") + } + previous := uint16(0) + for _, helper := range authorization.HelperIdentifiers { + if helper == 0 || helper > authorization.ParticipantCount || helper <= previous || + helper == authorization.TargetIdentifier { + return result, fmt.Errorf("helper set must be sorted, distinct, in-range, and exclude the target") + } + previous = helper + } + if authorization.RecoveryEpoch == 0 || + authorization.IssuedAtUnix > authorization.NotBeforeUnix || + authorization.NotBeforeUnix >= authorization.ExpiresAtUnix || + authorization.ExpiresAtUnix-authorization.IssuedAtUnix > + uint64(shareRepairMaximumAuthorizationAge/time.Second) { + return result, fmt.Errorf("share-repair epoch or authorization lifetime is invalid") + } + + walletID, err := parseCanonicalShareRepairHex32(authorization.WalletID, "wallet_id") + if err != nil { + return result, err + } + publicCommitment, err := parseCanonicalShareRepairHex32( + authorization.PublicKeyPackageCommitment, + "public_key_package_commitment", + ) + if err != nil { + return result, err + } + oldStore, err := parseCanonicalShareRepairHex32( + authorization.OldStoreFingerprint, + "old_store_fingerprint", + ) + if err != nil { + return result, err + } + newStore, err := parseCanonicalShareRepairHex32( + authorization.NewStoreFingerprint, + "new_store_fingerprint", + ) + if err != nil { + return result, err + } + if oldStore == newStore { + return result, fmt.Errorf("old and new share-repair stores must differ") + } + nonce, err := parseCanonicalShareRepairHex32(authorization.Nonce, "nonce") + if err != nil { + return result, err + } + if len(authorization.KeyGroup) != 66 || + authorization.KeyGroup != strings.ToLower(authorization.KeyGroup) { + return result, fmt.Errorf("key_group must be canonical lowercase compressed SEC1 hex") + } + compressedKeyGroup, err := hex.DecodeString(authorization.KeyGroup) + if err != nil || len(compressedKeyGroup) != 33 { + return result, fmt.Errorf("key_group must be canonical lowercase compressed SEC1 hex") + } + publicKey, err := btcec.ParsePubKey(compressedKeyGroup) + if err != nil || !bytes.Equal(publicKey.SerializeCompressed(), compressedKeyGroup) { + return result, fmt.Errorf("key_group must be canonical lowercase compressed SEC1 hex") + } + derivedWalletID := publicKey.X().FillBytes(make([]byte, 32)) + if !bytes.Equal(derivedWalletID, walletID[:]) { + return result, fmt.Errorf("wallet_id does not match key_group") + } + + transcript := bytes.NewBuffer(nil) + transcript.WriteString(shareRepairAuthorizationDomain) + if err := writeShareRepairLengthPrefixed(transcript, []byte(authorization.SessionID)); err != nil { + return result, err + } + transcript.Write(walletID[:]) + transcript.Write(compressedKeyGroup) + transcript.Write(publicCommitment[:]) + _ = binary.Write(transcript, binary.BigEndian, authorization.TargetIdentifier) + _ = binary.Write(transcript, binary.BigEndian, uint16(len(authorization.HelperIdentifiers))) + for _, helper := range authorization.HelperIdentifiers { + _ = binary.Write(transcript, binary.BigEndian, helper) + } + _ = binary.Write(transcript, binary.BigEndian, authorization.Threshold) + _ = binary.Write(transcript, binary.BigEndian, authorization.ParticipantCount) + transcript.Write(oldStore[:]) + transcript.Write(newStore[:]) + _ = binary.Write(transcript, binary.BigEndian, authorization.RecoveryEpoch) + _ = binary.Write(transcript, binary.BigEndian, authorization.IssuedAtUnix) + _ = binary.Write(transcript, binary.BigEndian, authorization.NotBeforeUnix) + _ = binary.Write(transcript, binary.BigEndian, authorization.ExpiresAtUnix) + transcript.Write(nonce[:]) + return sha256.Sum256(transcript.Bytes()), nil +} + +func parseCanonicalShareRepairTransportPublicKey( + value string, + label string, +) ([33]byte, error) { + result := [33]byte{} + if len(value) != 66 || value != strings.ToLower(value) { + return result, fmt.Errorf( + "%s must be canonical lowercase unprefixed 33-byte compressed SEC1 hex", + label, + ) + } + decoded, err := hex.DecodeString(value) + if err != nil || len(decoded) != len(result) { + return result, fmt.Errorf( + "%s must be canonical lowercase unprefixed 33-byte compressed SEC1 hex", + label, + ) + } + publicKey, err := btcec.ParsePubKey(decoded) + if err != nil || !bytes.Equal(publicKey.SerializeCompressed(), decoded) { + return result, fmt.Errorf( + "%s must be canonical lowercase unprefixed 33-byte compressed SEC1 hex", + label, + ) + } + copy(result[:], decoded) + return result, nil +} + +// ComputeShareRepairTransportRosterDigest implements the frozen transcript +// shared with Rust. It validates the exact helper-then-target participant +// ordering and public-key encoding, but intentionally does not verify either +// offline-authority signature. +func ComputeShareRepairTransportRosterDigest( + transportRoster *ShareRepairTransportRoster, + authorization *ShareRepairAuthorization, +) ([32]byte, error) { + result := [32]byte{} + authorizationDigest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return result, err + } + if transportRoster == nil { + return result, fmt.Errorf("share-repair transport roster is nil") + } + if transportRoster.Schema != ShareRepairTransportRosterSchema { + return result, fmt.Errorf("unsupported share-repair transport roster schema") + } + wireAuthorizationDigest, err := parseCanonicalShareRepairHex32( + transportRoster.AuthorizationDigest, + "transport roster authorization_digest", + ) + if err != nil { + return result, err + } + if wireAuthorizationDigest != authorizationDigest { + return result, fmt.Errorf("share-repair transport roster authorization digest mismatch") + } + targetStoreFingerprint, err := parseCanonicalShareRepairHex32( + authorization.NewStoreFingerprint, + "new_store_fingerprint", + ) + if err != nil { + return result, err + } + + expectedCount := len(authorization.HelperIdentifiers) + 1 + if len(transportRoster.ParticipantPublicKeys) != expectedCount { + return result, fmt.Errorf( + "share-repair transport roster must contain the exact helper and target set", + ) + } + + transcript := bytes.NewBuffer(nil) + transcript.WriteString(shareRepairTransportRosterDomain) + transcript.Write(authorizationDigest[:]) + _ = binary.Write(transcript, binary.BigEndian, uint16(expectedCount)) + seenPublicKeys := make(map[[33]byte]struct{}, expectedCount) + for index, participantPublicKey := range transportRoster.ParticipantPublicKeys { + expectedIdentifier := authorization.TargetIdentifier + if index < len(authorization.HelperIdentifiers) { + expectedIdentifier = authorization.HelperIdentifiers[index] + } + if participantPublicKey.ParticipantIdentifier != expectedIdentifier { + return result, fmt.Errorf( + "share-repair transport roster participant [%d] is invalid or out of order", + index, + ) + } + storeFingerprint, err := parseCanonicalShareRepairHex32( + participantPublicKey.StoreFingerprint, + fmt.Sprintf("transport roster store fingerprint [%d]", index), + ) + if err != nil { + return result, err + } + if participantPublicKey.ParticipantIdentifier == authorization.TargetIdentifier && + storeFingerprint != targetStoreFingerprint { + return result, fmt.Errorf( + "share-repair target transport roster entry does not name new_store_fingerprint", + ) + } + publicKey, err := parseCanonicalShareRepairTransportPublicKey( + participantPublicKey.PublicKeyHex, + fmt.Sprintf("transport roster public key [%d]", index), + ) + if err != nil { + return result, err + } + if _, duplicate := seenPublicKeys[publicKey]; duplicate { + return result, fmt.Errorf("share-repair transport roster public keys must be unique") + } + seenPublicKeys[publicKey] = struct{}{} + _ = binary.Write( + transcript, + binary.BigEndian, + participantPublicKey.ParticipantIdentifier, + ) + transcript.Write(storeFingerprint[:]) + transcript.Write(publicKey[:]) + } + + return sha256.Sum256(transcript.Bytes()), nil +} + +func validateShareRepairAuthorization( + authorization *ShareRepairAuthorization, + authorityPublicKey ed25519.PublicKey, + enforceTime bool, +) (*validatedShareRepairAuthorization, error) { + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return nil, err + } + if len(authorityPublicKey) != ed25519.PublicKeySize { + return nil, fmt.Errorf("share-repair authority public key is invalid") + } + signature, err := parseCanonicalShareRepairSignature(authorization.SignatureHex) + if err != nil { + return nil, err + } + if !ed25519.Verify(authorityPublicKey, digest[:], signature) { + return nil, fmt.Errorf("share-repair authorization signature is invalid") + } + if enforceTime { + now := uint64(time.Now().Unix()) + if now < authorization.NotBeforeUnix || now >= authorization.ExpiresAtUnix { + return nil, fmt.Errorf("share-repair authorization is not currently valid") + } + } + walletID, _ := parseCanonicalShareRepairHex32(authorization.WalletID, "wallet_id") + newStore, _ := parseCanonicalShareRepairHex32( + authorization.NewStoreFingerprint, + "new_store_fingerprint", + ) + return &validatedShareRepairAuthorization{ + digest: digest, + walletID: walletID, + newStoreFingerprint: newStore, + }, nil +} + +// ValidateShareRepairTransportRoster verifies that the authorization and its +// exact transport roster were signed by the same manifest-pinned authority. +// The caller remains responsible for enforcing the authorization time window. +func ValidateShareRepairTransportRoster( + transportRoster *ShareRepairTransportRoster, + authorization *ShareRepairAuthorization, + authorityPublicKey ed25519.PublicKey, +) error { + if _, err := validateShareRepairAuthorization( + authorization, + authorityPublicKey, + false, + ); err != nil { + return fmt.Errorf("invalid share-repair transport roster authorization: %w", err) + } + digest, err := ComputeShareRepairTransportRosterDigest( + transportRoster, + authorization, + ) + if err != nil { + return err + } + signature, err := parseCanonicalShareRepairSignature(transportRoster.SignatureHex) + if err != nil { + return fmt.Errorf("invalid share-repair transport roster signature encoding: %w", err) + } + if !ed25519.Verify(authorityPublicKey, digest[:], signature) { + return fmt.Errorf("share-repair transport roster signature is invalid") + } + return nil +} + +func computeShareRepairActivationLeaseDigest( + lease *ShareRepairActivationLease, + authorizationDigest [32]byte, + tombstoneDigest [32]byte, +) [32]byte { + transcript := bytes.NewBuffer(nil) + transcript.WriteString(shareRepairActivationLeaseDomain) + transcript.Write(authorizationDigest[:]) + transcript.Write(tombstoneDigest[:]) + _ = binary.Write(transcript, binary.BigEndian, lease.ActivatedAtUnix) + return sha256.Sum256(transcript.Bytes()) +} + +func validatedShareRepairActivationLeaseDigest( + lease *ShareRepairActivationLease, + authorityPublicKey ed25519.PublicKey, +) ([32]byte, *validatedShareRepairAuthorization, error) { + result := [32]byte{} + if lease == nil || lease.Schema != ShareRepairActivationLeaseSchema { + return result, nil, fmt.Errorf("unsupported share-repair activation lease schema") + } + validatedAuthorization, err := validateShareRepairAuthorization( + &lease.Authorization, + authorityPublicKey, + false, + ) + if err != nil { + return result, nil, fmt.Errorf("invalid share-repair activation authorization: %w", err) + } + wireAuthorizationDigest, err := parseCanonicalShareRepairHex32( + lease.AuthorizationDigest, + "authorization_digest", + ) + if err != nil || wireAuthorizationDigest != validatedAuthorization.digest { + return result, nil, fmt.Errorf("activation lease authorization digest mismatch") + } + tombstoneDigest, err := parseCanonicalShareRepairHex32( + lease.OldStoreTombstoneDigest, + "old_store_tombstone_digest", + ) + if err != nil { + return result, nil, err + } + if lease.ActivatedAtUnix == 0 || lease.ActivatedAtUnix < lease.Authorization.IssuedAtUnix { + return result, nil, fmt.Errorf("share-repair activation time is invalid") + } + return computeShareRepairActivationLeaseDigest( + lease, + validatedAuthorization.digest, + tombstoneDigest, + ), validatedAuthorization, nil +} + +// ComputeShareRepairActivationLeaseDigest returns the exact digest an offline +// authority signs after independently confirming both the new-store anchor ACK +// and the old-store stream tombstone. The embedded recovery authorization must +// already carry a valid signature from the same authority. +func ComputeShareRepairActivationLeaseDigest( + lease *ShareRepairActivationLease, + authorityPublicKey ed25519.PublicKey, +) ([32]byte, error) { + digest, _, err := validatedShareRepairActivationLeaseDigest( + lease, + authorityPublicKey, + ) + return digest, err +} + +type validatedShareRepairActivationLease struct { + lease ShareRepairActivationLease + authorizationDigest [32]byte + activationDigest [32]byte + newStoreFingerprint [32]byte + wire []byte +} + +func validateShareRepairActivationLease( + lease *ShareRepairActivationLease, + authorityPublicKey ed25519.PublicKey, +) (*validatedShareRepairActivationLease, error) { + activationDigest, validatedAuthorization, err := + validatedShareRepairActivationLeaseDigest( + lease, + authorityPublicKey, + ) + if err != nil { + return nil, err + } + signature, err := parseCanonicalShareRepairSignature(lease.SignatureHex) + if err != nil { + return nil, err + } + if !ed25519.Verify(authorityPublicKey, activationDigest[:], signature) { + return nil, fmt.Errorf("share-repair activation lease signature is invalid") + } + wire, err := json.Marshal(lease) + if err != nil { + return nil, fmt.Errorf("cannot encode share-repair activation lease: %w", err) + } + return &validatedShareRepairActivationLease{ + lease: *lease, + authorizationDigest: validatedAuthorization.digest, + activationDigest: activationDigest, + newStoreFingerprint: validatedAuthorization.newStoreFingerprint, + wire: wire, + }, nil +} + +func validateShareRepairActivationRegistry( + registry *ShareRepairActivationRegistry, + authorityPublicKey ed25519.PublicKey, +) (map[shareRepairActivationKey]*validatedShareRepairActivationLease, [32]byte, error) { + result := [32]byte{} + if registry == nil || registry.Schema != ShareRepairActivationRegistrySchema || + len(registry.Leases) == 0 || + len(registry.Leases) > shareRepairMaximumActivationRegistry { + return nil, result, fmt.Errorf("share-repair activation registry shape is invalid") + } + + validated := make(map[shareRepairActivationKey]*validatedShareRepairActivationLease) + orderedDigests := make([][32]byte, 0, len(registry.Leases)) + previous := shareRepairActivationKey{} + for index := range registry.Leases { + lease, err := validateShareRepairActivationLease( + ®istry.Leases[index], + authorityPublicKey, + ) + if err != nil { + return nil, result, fmt.Errorf("invalid share-repair activation lease [%d]: %w", index, err) + } + key := shareRepairActivationKey{ + keyGroup: lease.lease.Authorization.KeyGroup, + seat: lease.lease.Authorization.TargetIdentifier, + } + if index > 0 && (key.keyGroup < previous.keyGroup || + (key.keyGroup == previous.keyGroup && key.seat <= previous.seat)) { + return nil, result, fmt.Errorf("share-repair activation leases must be unique and canonically sorted") + } + previous = key + validated[key] = lease + orderedDigests = append(orderedDigests, lease.activationDigest) + } + rootInput := bytes.NewBuffer(nil) + rootInput.WriteString(shareRepairActivationRegistryDomain) + _ = binary.Write(rootInput, binary.BigEndian, uint32(len(orderedDigests))) + for _, digest := range orderedDigests { + rootInput.Write(digest[:]) + } + return validated, sha256.Sum256(rootInput.Bytes()), nil +} + +type shareRepairActivationKey struct { + keyGroup string + seat uint16 +} + +type shareRepairRecoveredSeatBinding struct { + recoveryEpoch uint64 + authorizationDigest [32]byte + activeStoreFingerprint [32]byte +} + +type installedShareRepairActivationRegistry struct { + root [32]byte + leases map[shareRepairActivationKey]*validatedShareRepairActivationLease +} + +var shareRepairActivationRegistryState struct { + sync.RWMutex + configured bool + localStoreFingerprint [32]byte + localSeats map[shareRepairActivationKey]struct{} + recoveredSeats map[shareRepairActivationKey]shareRepairRecoveredSeatBinding + installed *installedShareRepairActivationRegistry +} + +// ConfigureShareRepairActivationGuard installs the descriptor-bound recovery +// facts read directly from the Rust inventory. This call must precede signing +// readiness and registry installation. In particular, a v2 inventory with no +// matching activation lease remains unable to sign across process restarts. +func ConfigureShareRepairActivationGuard( + inventory *NativeTBTCSignerRetainedKeyPackageInventory, +) error { + if inventory == nil || inventory.StoreFingerprint == [32]byte{} { + return fmt.Errorf("share-repair activation inventory is missing its store binding") + } + if inventory.Schema != NativeTBTCSignerRetainedKeyPackageInventorySchema && + inventory.Schema != NativeTBTCSignerRetainedKeyPackageInventoryRecoverySchema { + return fmt.Errorf("share-repair activation inventory schema is unsupported") + } + + localSeats := make(map[shareRepairActivationKey]struct{}) + for _, entry := range inventory.Entries { + if entry.KeyGroup == "" { + return fmt.Errorf("share-repair activation inventory contains an empty key group") + } + for _, keyPackage := range entry.KeyPackages { + key := shareRepairActivationKey{ + keyGroup: entry.KeyGroup, + seat: keyPackage.ParticipantSeat, + } + if key.seat == 0 { + return fmt.Errorf("share-repair activation inventory contains a zero seat") + } + if _, exists := localSeats[key]; exists { + return fmt.Errorf("share-repair activation inventory contains a duplicate local seat") + } + localSeats[key] = struct{}{} + } + } + + recoveredSeats := make( + map[shareRepairActivationKey]shareRepairRecoveredSeatBinding, + len(inventory.RecoveredSeats), + ) + for _, recovered := range inventory.RecoveredSeats { + key := shareRepairActivationKey{ + keyGroup: recovered.KeyGroup, + seat: recovered.ParticipantSeat, + } + if _, retained := localSeats[key]; !retained || recovered.RecoveryEpoch == 0 || + recovered.AuthorizationDigest == [32]byte{} || + recovered.ActiveStoreFingerprint != inventory.StoreFingerprint { + return fmt.Errorf("share-repair recovered seat is not bound to the retained inventory") + } + if _, exists := recoveredSeats[key]; exists { + return fmt.Errorf("share-repair activation inventory contains a duplicate recovered seat") + } + recoveredSeats[key] = shareRepairRecoveredSeatBinding{ + recoveryEpoch: recovered.RecoveryEpoch, + authorizationDigest: recovered.AuthorizationDigest, + activeStoreFingerprint: recovered.ActiveStoreFingerprint, + } + } + if inventory.Schema == NativeTBTCSignerRetainedKeyPackageInventorySchema { + if len(recoveredSeats) != 0 || inventory.RecoveryActivationCommitment != [32]byte{} { + return fmt.Errorf("v1 share-repair activation inventory contains recovery facts") + } + } else if len(recoveredSeats) == 0 || + inventory.RecoveryActivationCommitment == [32]byte{} || + ComputeNativeTBTCSignerRecoveredSeatActivationCommitment(inventory.RecoveredSeats) != + inventory.RecoveryActivationCommitment { + return fmt.Errorf("v2 share-repair activation inventory commitment is invalid") + } + + shareRepairActivationRegistryState.Lock() + defer shareRepairActivationRegistryState.Unlock() + if shareRepairActivationRegistryState.configured { + return fmt.Errorf("share-repair activation guard is already configured") + } + if shareRepairActivationRegistryState.installed != nil { + return fmt.Errorf("share-repair activation registry preceded its inventory guard") + } + shareRepairActivationRegistryState.configured = true + shareRepairActivationRegistryState.localStoreFingerprint = inventory.StoreFingerprint + shareRepairActivationRegistryState.localSeats = localSeats + shareRepairActivationRegistryState.recoveredSeats = recoveredSeats + return nil +} + +func shareRepairActivationLeaseMatchesBinding( + lease *validatedShareRepairActivationLease, + binding shareRepairRecoveredSeatBinding, +) bool { + return lease != nil && + lease.authorizationDigest == binding.authorizationDigest && + lease.lease.Authorization.RecoveryEpoch == binding.recoveryEpoch && + lease.newStoreFingerprint == binding.activeStoreFingerprint +} + +func validateLocalShareRepairActivationRegistryLocked( + registry *installedShareRepairActivationRegistry, +) error { + for key, binding := range shareRepairActivationRegistryState.recoveredSeats { + if !shareRepairActivationLeaseMatchesBinding(registry.leases[key], binding) { + return fmt.Errorf( + "recovered seat [%d] for key group [%s] has no exact activation lease", + key.seat, + key.keyGroup, + ) + } + } + for key := range shareRepairActivationRegistryState.localSeats { + lease := registry.leases[key] + if lease == nil { + continue + } + binding, recovered := shareRepairActivationRegistryState.recoveredSeats[key] + if !recovered || !shareRepairActivationLeaseMatchesBinding(lease, binding) || + lease.newStoreFingerprint != shareRepairActivationRegistryState.localStoreFingerprint { + return fmt.Errorf( + "activation lease for local seat [%d] does not match the recovered durable store", + key.seat, + ) + } + } + return nil +} + +func recordInstalledShareRepair(result *NativeShareRepairInstallResult) error { + if result == nil || result.KeyGroup == "" || result.TargetIdentifier == 0 || + result.RecoveryEpoch == 0 { + return fmt.Errorf("installed share-repair result is incomplete") + } + authorizationDigest, err := parseCanonicalShareRepairHex32( + result.AuthorizationDigest, + "authorization_digest", + ) + if err != nil { + return err + } + activeStoreFingerprint, err := parseCanonicalShareRepairHex32( + result.ActiveStoreFingerprint, + "active_store_fingerprint", + ) + if err != nil { + return err + } + key := shareRepairActivationKey{ + keyGroup: result.KeyGroup, + seat: result.TargetIdentifier, + } + binding := shareRepairRecoveredSeatBinding{ + recoveryEpoch: result.RecoveryEpoch, + authorizationDigest: authorizationDigest, + activeStoreFingerprint: activeStoreFingerprint, + } + + shareRepairActivationRegistryState.Lock() + defer shareRepairActivationRegistryState.Unlock() + if !shareRepairActivationRegistryState.configured { + shareRepairActivationRegistryState.configured = true + shareRepairActivationRegistryState.localStoreFingerprint = activeStoreFingerprint + shareRepairActivationRegistryState.localSeats = make(map[shareRepairActivationKey]struct{}) + shareRepairActivationRegistryState.recoveredSeats = + make(map[shareRepairActivationKey]shareRepairRecoveredSeatBinding) + } + if shareRepairActivationRegistryState.localStoreFingerprint != activeStoreFingerprint { + return fmt.Errorf("installed repaired share belongs to another durable store") + } + shareRepairActivationRegistryState.localSeats[key] = struct{}{} + if existing, exists := shareRepairActivationRegistryState.recoveredSeats[key]; exists { + if existing.recoveryEpoch > binding.recoveryEpoch || + (existing.recoveryEpoch == binding.recoveryEpoch && existing != binding) { + return fmt.Errorf("installed repaired share conflicts with the activation guard") + } + } + shareRepairActivationRegistryState.recoveredSeats[key] = binding + return nil +} + +// InstallShareRepairActivationRegistry verifies the complete authority-signed +// cutover artifact and installs it immutably for this process. expectedRoot is +// taken from the signed activation manifest; localStoreFingerprint is the Rust +// descriptor-bound readback. +func InstallShareRepairActivationRegistry( + payload []byte, + authorityPublicKey ed25519.PublicKey, + expectedRoot [32]byte, + localStoreFingerprint [32]byte, +) error { + if len(payload) == 0 || len(payload) > 8*1024*1024 || expectedRoot == [32]byte{} || + localStoreFingerprint == [32]byte{} { + return fmt.Errorf("share-repair activation registry dependencies are incomplete") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + registry := &ShareRepairActivationRegistry{} + if err := decoder.Decode(registry); err != nil { + return fmt.Errorf("cannot decode share-repair activation registry: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("share-repair activation registry has trailing JSON") + } + validated, root, err := validateShareRepairActivationRegistry( + registry, + authorityPublicKey, + ) + if err != nil { + return err + } + if root != expectedRoot { + return fmt.Errorf("share-repair activation registry root differs from signed manifest") + } + + shareRepairActivationRegistryState.Lock() + defer shareRepairActivationRegistryState.Unlock() + if !shareRepairActivationRegistryState.configured || + shareRepairActivationRegistryState.localStoreFingerprint != localStoreFingerprint { + return fmt.Errorf("share-repair activation registry is not bound to the native inventory") + } + if shareRepairActivationRegistryState.installed != nil { + return fmt.Errorf("share-repair activation registry is already installed") + } + installed := &installedShareRepairActivationRegistry{ + root: root, + leases: validated, + } + if err := validateLocalShareRepairActivationRegistryLocked(installed); err != nil { + return err + } + shareRepairActivationRegistryState.installed = installed + return nil +} + +// CurrentShareRepairActivationRegistryRoot is included in readiness and the +// activation handshake. Zero means the signed manifest declared no recovered +// seats and no registry was installed. +func CurrentShareRepairActivationRegistryRoot() [32]byte { + shareRepairActivationRegistryState.RLock() + defer shareRepairActivationRegistryState.RUnlock() + if shareRepairActivationRegistryState.installed == nil { + return [32]byte{} + } + return shareRepairActivationRegistryState.installed.root +} + +func shareRepairActivationLeaseForBroadcast( + keyGroup string, + seat group.MemberIndex, +) ([]byte, error) { + shareRepairActivationRegistryState.RLock() + defer shareRepairActivationRegistryState.RUnlock() + key := shareRepairActivationKey{keyGroup: keyGroup, seat: uint16(seat)} + registry := shareRepairActivationRegistryState.installed + binding, recovered := shareRepairActivationRegistryState.recoveredSeats[key] + if registry == nil { + if recovered { + return nil, fmt.Errorf( + "recovered seat [%d] is pending authority-signed activation", + seat, + ) + } + return nil, nil + } + lease := registry.leases[key] + if lease == nil { + if recovered { + return nil, fmt.Errorf("recovered seat [%d] has no activation lease", seat) + } + return nil, nil + } + if lease.newStoreFingerprint != shareRepairActivationRegistryState.localStoreFingerprint { + return nil, fmt.Errorf( + "recovered seat [%d] is bound to another durable signer store", + seat, + ) + } + if _, local := shareRepairActivationRegistryState.localSeats[key]; local && + (!recovered || !shareRepairActivationLeaseMatchesBinding(lease, binding)) { + return nil, fmt.Errorf( + "recovered seat [%d] is not proven by the native signer inventory", + seat, + ) + } + return append([]byte(nil), lease.wire...), nil +} + +func validateShareRepairActivationLeaseForMessage( + keyGroup string, + seat group.MemberIndex, + wire []byte, +) error { + shareRepairActivationRegistryState.RLock() + defer shareRepairActivationRegistryState.RUnlock() + registry := shareRepairActivationRegistryState.installed + if registry == nil { + if len(wire) != 0 { + return fmt.Errorf("unexpected share-repair activation lease") + } + return nil + } + expected := registry.leases[shareRepairActivationKey{keyGroup: keyGroup, seat: uint16(seat)}] + if expected == nil { + if len(wire) != 0 { + return fmt.Errorf("unexpected share-repair activation lease") + } + return nil + } + if !bytes.Equal(wire, expected.wire) { + return fmt.Errorf("missing or stale share-repair activation lease") + } + return nil +} + +func ValidateLocalShareRepairSeatActivation( + keyGroup string, + seat group.MemberIndex, +) error { + _, err := shareRepairActivationLeaseForBroadcast(keyGroup, seat) + return err +} + +// ShareRepairActivationReady is the fail-closed readiness predicate used by +// startup and the activation handshake. A zero manifest root is ready only +// when the native inventory has no recovered seats awaiting cutover. +func ShareRepairActivationReady(expectedRoot [32]byte) bool { + shareRepairActivationRegistryState.RLock() + defer shareRepairActivationRegistryState.RUnlock() + if !shareRepairActivationRegistryState.configured { + return false + } + registry := shareRepairActivationRegistryState.installed + if registry == nil { + return expectedRoot == [32]byte{} && + len(shareRepairActivationRegistryState.recoveredSeats) == 0 + } + if registry.root != expectedRoot { + return false + } + return validateLocalShareRepairActivationRegistryLocked(registry) == nil +} + +// ShareRepairActivationTransportRequired reports whether ROAST must use its +// lease-carrying v2 transport. Pending recovered seats force v2 even before a +// registry exists, preventing a same-process fallback to legacy frames. +func ShareRepairActivationTransportRequired() bool { + shareRepairActivationRegistryState.RLock() + defer shareRepairActivationRegistryState.RUnlock() + return shareRepairActivationRegistryState.installed != nil || + len(shareRepairActivationRegistryState.recoveredSeats) != 0 +} + +// ShareRepairActivationRegistryRoot computes the manifest pin for an artifact +// without installing process-global state. It is used by offline tooling/tests. +func ShareRepairActivationRegistryRoot( + registry *ShareRepairActivationRegistry, + authorityPublicKey ed25519.PublicKey, +) ([32]byte, error) { + result := [32]byte{} + _, root, err := validateShareRepairActivationRegistry( + registry, + authorityPublicKey, + ) + if err != nil { + return result, err + } + return root, nil +} + +func resetShareRepairActivationRegistryForTest() { + shareRepairActivationRegistryState.Lock() + defer shareRepairActivationRegistryState.Unlock() + shareRepairActivationRegistryState.configured = false + shareRepairActivationRegistryState.localStoreFingerprint = [32]byte{} + shareRepairActivationRegistryState.localSeats = nil + shareRepairActivationRegistryState.recoveredSeats = nil + shareRepairActivationRegistryState.installed = nil +} + +// ResetShareRepairActivationStateForTest clears the process-global guard for +// cross-package tests. Production code must never call this function. +func ResetShareRepairActivationStateForTest() { + resetShareRepairActivationRegistryForTest() +} diff --git a/pkg/frost/signing/share_repair_frost_native_test.go b/pkg/frost/signing/share_repair_frost_native_test.go new file mode 100644 index 0000000000..0cd95a94dc --- /dev/null +++ b/pkg/frost/signing/share_repair_frost_native_test.go @@ -0,0 +1,553 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func testShareRepairHex32(value byte) string { + return "0x" + hex.EncodeToString(bytes.Repeat([]byte{value}, 32)) +} + +func testShareRepairAuthorization( + t *testing.T, +) (*ShareRepairAuthorization, ed25519.PrivateKey) { + t.Helper() + _, keyGroup := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x09}, 32)) + walletID := make([]byte, 32) + keyGroup.X().FillBytes(walletID) + authority := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x42}, ed25519.SeedSize)) + authorization := &ShareRepairAuthorization{ + Schema: ShareRepairAuthorizationSchema, + SessionID: "repair-wallet-a-seat-3-epoch-1", + WalletID: "0x" + hex.EncodeToString(walletID), + KeyGroup: hex.EncodeToString(keyGroup.SerializeCompressed()), + PublicKeyPackageCommitment: testShareRepairHex32(0x31), + TargetIdentifier: 3, + HelperIdentifiers: []uint16{1, 2}, + Threshold: 2, + ParticipantCount: 3, + OldStoreFingerprint: testShareRepairHex32(0x51), + NewStoreFingerprint: testShareRepairHex32(0x52), + RecoveryEpoch: 1, + IssuedAtUnix: 1_700_000_000, + NotBeforeUnix: 1_700_000_000, + ExpiresAtUnix: 1_700_003_600, + Nonce: testShareRepairHex32(0x61), + } + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatalf("compute authorization digest: %v", err) + } + authorization.SignatureHex = "0x" + hex.EncodeToString( + ed25519.Sign(authority, digest[:]), + ) + return authorization, authority +} + +func testShareRepairTransportRoster( + t *testing.T, + authorization *ShareRepairAuthorization, + authority ed25519.PrivateKey, +) *ShareRepairTransportRoster { + t.Helper() + authorizationDigest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatalf("compute authorization digest: %v", err) + } + participantIdentifiers := append( + append([]uint16(nil), authorization.HelperIdentifiers...), + authorization.TargetIdentifier, + ) + participantPublicKeys := make( + []ShareRepairTransportPublicKey, + len(participantIdentifiers), + ) + for index, participantIdentifier := range participantIdentifiers { + _, publicKey := btcec.PrivKeyFromBytes( + bytes.Repeat([]byte{byte(0x11 * (index + 1))}, 32), + ) + participantPublicKeys[index] = ShareRepairTransportPublicKey{ + ParticipantIdentifier: participantIdentifier, + StoreFingerprint: authorization.NewStoreFingerprint, + PublicKeyHex: hex.EncodeToString(publicKey.SerializeCompressed()), + } + } + roster := &ShareRepairTransportRoster{ + Schema: ShareRepairTransportRosterSchema, + AuthorizationDigest: "0x" + hex.EncodeToString(authorizationDigest[:]), + ParticipantPublicKeys: participantPublicKeys, + } + digest, err := ComputeShareRepairTransportRosterDigest(roster, authorization) + if err != nil { + t.Fatalf("compute transport roster digest: %v", err) + } + roster.SignatureHex = "0x" + hex.EncodeToString(ed25519.Sign(authority, digest[:])) + return roster +} + +func cloneShareRepairTransportRoster( + roster *ShareRepairTransportRoster, +) *ShareRepairTransportRoster { + cloned := *roster + cloned.ParticipantPublicKeys = append( + []ShareRepairTransportPublicKey(nil), + roster.ParticipantPublicKeys..., + ) + return &cloned +} + +func testShareRepairActivationLease( + t *testing.T, + authorization *ShareRepairAuthorization, + authority ed25519.PrivateKey, +) ShareRepairActivationLease { + t.Helper() + authorizationDigest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + lease := ShareRepairActivationLease{ + Schema: ShareRepairActivationLeaseSchema, + Authorization: *authorization, + AuthorizationDigest: "0x" + hex.EncodeToString(authorizationDigest[:]), + OldStoreTombstoneDigest: testShareRepairHex32(0x71), + ActivatedAtUnix: authorization.ExpiresAtUnix + 1, + } + digest, err := ComputeShareRepairActivationLeaseDigest( + &lease, + authority.Public().(ed25519.PublicKey), + ) + if err != nil { + t.Fatalf("compute activation digest: %v", err) + } + lease.SignatureHex = "0x" + hex.EncodeToString(ed25519.Sign(authority, digest[:])) + return lease +} + +func TestShareRepairAuthorizationDigestFrozenVector(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + encoded, err := json.Marshal(authorization) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeShareRepairAuthorization(encoded); err != nil { + t.Fatalf("strict authorization decoder rejected valid input: %v", err) + } + if _, err := DecodeShareRepairAuthorization(append(encoded, []byte(`{}`)...)); err == nil || !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("strict authorization decoder accepted trailing JSON: %v", err) + } + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + const expected = "aa8e36cbf287d988c6ed34bf0c38fd64c177500c768fbd3ea7c184b031d7511b" + if actual := hex.EncodeToString(digest[:]); actual != expected { + t.Fatalf("authorization digest changed: got [%s], want [%s]", actual, expected) + } + if _, err := validateShareRepairAuthorization( + authorization, + authority.Public().(ed25519.PublicKey), + false, + ); err != nil { + t.Fatalf("validate authorization: %v", err) + } + + malformed := *authorization + malformed.HelperIdentifiers = []uint16{2, 1} + if _, err := ComputeShareRepairAuthorizationDigest(&malformed); err == nil { + t.Fatal("expected an unsorted helper set to be rejected") + } + malformed = *authorization + malformed.WalletID = testShareRepairHex32(0x99) + if _, err := ComputeShareRepairAuthorizationDigest(&malformed); err == nil { + t.Fatal("expected a key-group/wallet mismatch to be rejected") + } + malformed = *authorization + malformed.ParticipantCount = 101 + if _, err := ComputeShareRepairAuthorizationDigest(&malformed); err == nil { + t.Fatal("expected a participant count above the production group bound to be rejected") + } +} + +func TestShareRepairAuthorizationSessionIDMatchesNativeContract(t *testing.T) { + authorization, _ := testShareRepairAuthorization(t) + + tests := []struct { + name string + sessionID string + valid bool + }{ + {"one byte", "a", true}, + {"maximum ASCII byte length", strings.Repeat("a", 128), true}, + {"maximum UTF-8 byte length", strings.Repeat("é", 64), true}, + {"allowed punctuation", "repair/a:b@c+%[]~", true}, + {"native-valid Unicode whitespace", "\u00a0repair\u00a0", true}, + {"empty", "", false}, + {"over maximum ASCII byte length", strings.Repeat("a", 129), false}, + {"over maximum UTF-8 byte length", strings.Repeat("é", 65), false}, + {"embedded space", "repair wallet", false}, + {"embedded control", "repair\x01wallet", false}, + {"delete control", "repair\x7fwallet", false}, + {"equals", "repair=wallet", false}, + {"quote", "repair\"wallet", false}, + {"backslash", `repair\wallet`, false}, + {"invalid UTF-8", string([]byte{'r', 0xff}), false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := *authorization + candidate.SessionID = test.sessionID + _, err := ComputeShareRepairAuthorizationDigest(&candidate) + if test.valid && err != nil { + t.Fatalf("native-valid session ID was rejected: %v", err) + } + if !test.valid && err == nil { + t.Fatal("native-invalid session ID was accepted") + } + }) + } +} + +func TestShareRepairTransportRosterDigestFrozenVector(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + roster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairTransportRosterDigest(roster, authorization) + if err != nil { + t.Fatal(err) + } + const expected = "1a46b993431f075de1adef58a668e8133cca8ca7070eb5d6ffbedee92d224364" + if actual := hex.EncodeToString(digest[:]); actual != expected { + t.Fatalf("transport roster digest changed: got [%s], want [%s]", actual, expected) + } + if err := ValidateShareRepairTransportRoster( + roster, + authorization, + authority.Public().(ed25519.PublicKey), + ); err != nil { + t.Fatalf("validate transport roster: %v", err) + } +} + +func TestShareRepairTransportRosterRejectsMalformedAndSubstitutedKeys(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + roster := testShareRepairTransportRoster(t, authorization, authority) + + tests := map[string]func(*ShareRepairTransportRoster){ + "wrong schema": func(candidate *ShareRepairTransportRoster) { + candidate.Schema = "tbtc-frost-share-repair-transport-roster/v2" + }, + "wrong authorization digest": func(candidate *ShareRepairTransportRoster) { + candidate.AuthorizationDigest = testShareRepairHex32(0x99) + }, + "wrong participant order": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys[0], candidate.ParticipantPublicKeys[1] = + candidate.ParticipantPublicKeys[1], candidate.ParticipantPublicKeys[0] + }, + "missing target": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys = candidate.ParticipantPublicKeys[:2] + }, + "duplicate public key": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys[1].PublicKeyHex = + candidate.ParticipantPublicKeys[0].PublicKeyHex + }, + "uppercase public key": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys[0].PublicKeyHex = + strings.ToUpper(candidate.ParticipantPublicKeys[0].PublicKeyHex) + }, + "prefixed public key": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys[0].PublicKeyHex = + "0x" + candidate.ParticipantPublicKeys[0].PublicKeyHex + }, + "invalid SEC1 public key": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys[0].PublicKeyHex = strings.Repeat("00", 33) + }, + "invalid store fingerprint": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys[0].StoreFingerprint = "0x00" + }, + "target wrong store": func(candidate *ShareRepairTransportRoster) { + candidate.ParticipantPublicKeys[len(candidate.ParticipantPublicKeys)-1].StoreFingerprint = + testShareRepairHex32(0x99) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + candidate := cloneShareRepairTransportRoster(roster) + mutate(candidate) + if _, err := ComputeShareRepairTransportRosterDigest( + candidate, + authorization, + ); err == nil { + t.Fatal("malformed transport roster was accepted") + } + }) + } + + t.Run("caller key substitution", func(t *testing.T) { + candidate := cloneShareRepairTransportRoster(roster) + _, substitutedPublicKey := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x44}, 32)) + candidate.ParticipantPublicKeys[0].PublicKeyHex = + hex.EncodeToString(substitutedPublicKey.SerializeCompressed()) + if _, err := ComputeShareRepairTransportRosterDigest( + candidate, + authorization, + ); err != nil { + t.Fatalf("structurally valid substituted roster was rejected too early: %v", err) + } + if err := ValidateShareRepairTransportRoster( + candidate, + authorization, + authority.Public().(ed25519.PublicKey), + ); err == nil || !strings.Contains(err.Error(), "signature is invalid") { + t.Fatalf("authority-unapproved key substitution was accepted: %v", err) + } + }) + + t.Run("noncanonical signature", func(t *testing.T) { + candidate := cloneShareRepairTransportRoster(roster) + candidate.SignatureHex = strings.ToUpper(candidate.SignatureHex) + if err := ValidateShareRepairTransportRoster( + candidate, + authorization, + authority.Public().(ed25519.PublicKey), + ); err == nil { + t.Fatal("noncanonical transport roster signature was accepted") + } + }) + + t.Run("different authority", func(t *testing.T) { + differentAuthority := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x24}, 32)) + if err := ValidateShareRepairTransportRoster( + roster, + authorization, + differentAuthority.Public().(ed25519.PublicKey), + ); err == nil { + t.Fatal("transport roster from a different authority was accepted") + } + }) +} + +func TestShareRepairRecoveryBundleStrictDecode(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + bundle := ShareRepairRecoveryBundle{ + Schema: ShareRepairRecoveryBundleSchema, + Authorization: *authorization, + TransportRoster: *testShareRepairTransportRoster(t, authorization, authority), + } + payload, err := json.Marshal(bundle) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeShareRepairRecoveryBundle(payload) + if err != nil { + t.Fatal(err) + } + if decoded.Schema != bundle.Schema || + decoded.TransportRoster.SignatureHex != bundle.TransportRoster.SignatureHex { + t.Fatalf("recovery bundle changed across strict decode: %+v", decoded) + } + if _, err := DecodeShareRepairRecoveryBundle( + append(payload, []byte(`{}`)...), + ); err == nil || !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("recovery bundle accepted trailing JSON: %v", err) + } + + wrongSchema := bundle + wrongSchema.Schema = "tbtc-frost-share-repair-bundle/v2" + payload, err = json.Marshal(wrongSchema) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeShareRepairRecoveryBundle(payload); err == nil { + t.Fatal("recovery bundle accepted an unsupported schema") + } +} + +func configureTestShareRepairActivationGuard( + t *testing.T, + authorization *ShareRepairAuthorization, + storeFingerprint [32]byte, + recovered bool, +) { + t.Helper() + walletID, err := parseCanonicalShareRepairHex32(authorization.WalletID, "wallet_id") + if err != nil { + t.Fatal(err) + } + inventory := &NativeTBTCSignerRetainedKeyPackageInventory{ + Schema: NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: storeFingerprint, + Entries: []NativeTBTCSignerRetainedKeyGroup{ + { + WalletID: walletID, + KeyGroup: authorization.KeyGroup, + KeyPackages: []NativeTBTCSignerRetainedKeyPackage{ + {ParticipantSeat: authorization.TargetIdentifier}, + }, + }, + }, + } + if recovered { + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + inventory.Schema = NativeTBTCSignerRetainedKeyPackageInventoryRecoverySchema + inventory.RecoveredSeats = []NativeTBTCSignerRecoveredSeat{ + { + WalletID: walletID, + KeyGroup: authorization.KeyGroup, + ParticipantSeat: authorization.TargetIdentifier, + RecoveryEpoch: authorization.RecoveryEpoch, + AuthorizationDigest: digest, + ActiveStoreFingerprint: storeFingerprint, + }, + } + inventory.RecoveryActivationCommitment = + ComputeNativeTBTCSignerRecoveredSeatActivationCommitment( + inventory.RecoveredSeats, + ) + } + if err := ConfigureShareRepairActivationGuard(inventory); err != nil { + t.Fatalf("configure share-repair activation guard: %v", err) + } +} + +func TestShareRepairActivationRegistryEnforcesStoreCutover(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, authority := testShareRepairAuthorization(t) + lease := testShareRepairActivationLease(t, authorization, authority) + registry := &ShareRepairActivationRegistry{ + Schema: ShareRepairActivationRegistrySchema, + Leases: []ShareRepairActivationLease{lease}, + } + publicKey := authority.Public().(ed25519.PublicKey) + root, err := ShareRepairActivationRegistryRoot(registry, publicKey) + if err != nil { + t.Fatalf("compute registry root: %v", err) + } + payload, err := json.Marshal(registry) + if err != nil { + t.Fatal(err) + } + newStore, err := parseCanonicalShareRepairHex32( + authorization.NewStoreFingerprint, + "new_store_fingerprint", + ) + if err != nil { + t.Fatal(err) + } + oldStore, err := parseCanonicalShareRepairHex32( + authorization.OldStoreFingerprint, + "old_store_fingerprint", + ) + if err != nil { + t.Fatal(err) + } + configureTestShareRepairActivationGuard(t, authorization, newStore, true) + + if err := InstallShareRepairActivationRegistry( + append(append([]byte(nil), payload...), []byte(`{}`)...), + publicKey, + root, + newStore, + ); err == nil || !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("expected trailing registry JSON to fail, got [%v]", err) + } + if err := InstallShareRepairActivationRegistry(payload, publicKey, root, newStore); err != nil { + t.Fatalf("install registry: %v", err) + } + if CurrentShareRepairActivationRegistryRoot() != root { + t.Fatal("installed registry root mismatch") + } + if err := ValidateLocalShareRepairSeatActivation( + authorization.KeyGroup, + group.MemberIndex(authorization.TargetIdentifier), + ); err != nil { + t.Fatalf("new store rejected recovered seat: %v", err) + } + wire, err := shareRepairActivationLeaseForBroadcast( + authorization.KeyGroup, + group.MemberIndex(authorization.TargetIdentifier), + ) + if err != nil || len(wire) == 0 { + t.Fatalf("missing activation lease for recovered seat: [%v]", err) + } + if err := validateShareRepairActivationLeaseForMessage( + authorization.KeyGroup, + group.MemberIndex(authorization.TargetIdentifier), + wire, + ); err != nil { + t.Fatalf("exact activation lease rejected: %v", err) + } + if err := validateShareRepairActivationLeaseForMessage( + authorization.KeyGroup, + group.MemberIndex(authorization.TargetIdentifier), + nil, + ); err == nil { + t.Fatal("expected a recovered seat without its exact lease to be rejected") + } + if err := validateShareRepairActivationLeaseForMessage( + authorization.KeyGroup, + 1, + nil, + ); err != nil { + t.Fatalf("unrecovered seat unexpectedly required a lease: %v", err) + } + + resetShareRepairActivationRegistryForTest() + configureTestShareRepairActivationGuard(t, authorization, oldStore, false) + if err := InstallShareRepairActivationRegistry( + payload, + publicKey, + root, + oldStore, + ); err == nil { + t.Fatal("expected the old store process to reject the recovered-seat registry") + } +} + +func TestShareRepairActivationRegistryRejectsNonCanonicalAndWrongRoot(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, authority := testShareRepairAuthorization(t) + lease := testShareRepairActivationLease(t, authorization, authority) + publicKey := authority.Public().(ed25519.PublicKey) + duplicate := &ShareRepairActivationRegistry{ + Schema: ShareRepairActivationRegistrySchema, + Leases: []ShareRepairActivationLease{lease, lease}, + } + if _, err := ShareRepairActivationRegistryRoot(duplicate, publicKey); err == nil { + t.Fatal("expected duplicate activation leases to be rejected") + } + registry := &ShareRepairActivationRegistry{ + Schema: ShareRepairActivationRegistrySchema, + Leases: []ShareRepairActivationLease{lease}, + } + payload, err := json.Marshal(registry) + if err != nil { + t.Fatal(err) + } + newStore, _ := parseCanonicalShareRepairHex32( + authorization.NewStoreFingerprint, + "new_store_fingerprint", + ) + if err := InstallShareRepairActivationRegistry( + payload, + publicKey, + [32]byte{0xff}, + newStore, + ); err == nil { + t.Fatal("expected a registry root different from the signed manifest to fail") + } +} diff --git a/pkg/frost/signing/share_repair_runner_frost_native.go b/pkg/frost/signing/share_repair_runner_frost_native.go new file mode 100644 index 0000000000..f1151ed0e5 --- /dev/null +++ b/pkg/frost/signing/share_repair_runner_frost_native.go @@ -0,0 +1,975 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const shareRepairMaximumPendingMessages = 1024 + +// PrepareShareRepairTransportRosterEntry obtains the authorization-scoped +// public key generated by the native signer for one locally owned repair seat. +// Incident tooling collects one entry from each participant over independently +// authenticated operator channels; the offline authority verifies the exact +// authorization/store/seat and workload provenance before signing the +// canonical roster. The returned artifact is not hardware attestation. +func PrepareShareRepairTransportRosterEntry( + engine NativeTBTCSignerShareRepairEngine, + authorization *ShareRepairAuthorization, + authorityPublicKey ed25519.PublicKey, + participantIdentifier uint16, +) (*ShareRepairTransportPublicKey, error) { + if engine == nil { + return nil, fmt.Errorf("share-repair transport preparation engine is nil") + } + if _, err := validateShareRepairAuthorization( + authorization, + authorityPublicKey, + false, + ); err != nil { + return nil, fmt.Errorf("share-repair authorization is invalid: %w", err) + } + now := uint64(time.Now().Unix()) + if now < authorization.IssuedAtUnix || now >= authorization.ExpiresAtUnix { + return nil, fmt.Errorf("share-repair authorization is outside its transport-preparation window") + } + participant := group.MemberIndex(participantIdentifier) + if _, authorized := shareRepairParticipantSet(authorization)[participant]; !authorized { + return nil, fmt.Errorf("seat [%d] is not in the repair authorization", participantIdentifier) + } + session, err := engine.BeginShareRepairSession( + authorization, + participantIdentifier, + ) + if err != nil { + _ = engine.FinishShareRepairSession(authorization, participantIdentifier) + return nil, fmt.Errorf("begin native share-repair transport preparation: %w", err) + } + finish := func() error { + return engine.FinishShareRepairSession(authorization, participantIdentifier) + } + digest, _ := ComputeShareRepairAuthorizationDigest(authorization) + if session == nil || session.ContextDigest != fmt.Sprintf("0x%x", digest) || + session.ParticipantIdentifier != participantIdentifier || + len(session.TransportPublicKey) != shareRepairEphemeralPublicKeyLength { + _ = finish() + return nil, fmt.Errorf("native share-repair transport preparation returned an invalid session") + } + if _, err := parseCanonicalShareRepairHex32( + session.StoreFingerprint, + "native share-repair transport store fingerprint", + ); err != nil { + _ = finish() + return nil, err + } + publicKey, err := btcec.ParsePubKey(session.TransportPublicKey) + if err != nil || !bytes.Equal( + publicKey.SerializeCompressed(), + session.TransportPublicKey, + ) { + _ = finish() + return nil, fmt.Errorf("native share-repair transport preparation returned an invalid public key") + } + if err := finish(); err != nil { + return nil, fmt.Errorf("finish native share-repair transport preparation: %w", err) + } + return &ShareRepairTransportPublicKey{ + ParticipantIdentifier: participantIdentifier, + StoreFingerprint: session.StoreFingerprint, + PublicKeyHex: hex.EncodeToString(session.TransportPublicKey), + }, nil +} + +type shareRepairInstalledWire struct { + Schema string `json:"schema"` + SessionID string `json:"session_id"` + KeyGroup string `json:"key_group"` + TargetIdentifier uint16 `json:"target_identifier"` + RecoveryEpoch uint64 `json:"recovery_epoch"` + AuthorizationDigest string `json:"authorization_digest"` + ActiveStoreFingerprint string `json:"active_store_fingerprint"` + Idempotent bool `json:"idempotent"` +} + +type shareRepairRunner struct { + member group.MemberIndex + authorization *ShareRepairAuthorization + transportRoster *ShareRepairTransportRoster + authorizationDigest [32]byte + contextWire string + participants map[group.MemberIndex]struct{} + helperSet map[group.MemberIndex]struct{} + engine NativeTBTCSignerShareRepairEngine + bus shareRepairBus + stream <-chan shareRepairMessage + ephemeralPublic []byte + pending []shareRepairMessage + pendingBytes int + pendingBytesBySender map[group.MemberIndex]int +} + +type shareRepairRunnerOutcome struct { + member group.MemberIndex + result *NativeShareRepairInstallResult + err error +} + +// RunShareRepair executes the authenticated confidential RTS protocol for this +// node's local helper/target seats. Every participating node invokes it with +// the same authorization and public package. Only the target node returns a +// non-nil install result; helper-only nodes return nil after delivering sigma. +// The target's native Install call returns only after the existing independent +// state-anchor barrier has acknowledged the durable Rust replacement. +func RunShareRepair( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + engine NativeTBTCSignerShareRepairEngine, + authorization *ShareRepairAuthorization, + transportRoster *ShareRepairTransportRoster, + authorityPublicKey ed25519.PublicKey, + localMemberIndexes []group.MemberIndex, +) (*NativeShareRepairInstallResult, error) { + if ctx == nil || engine == nil || len(localMemberIndexes) == 0 { + return nil, fmt.Errorf("share repair dependencies are incomplete") + } + validated, err := validateShareRepairAuthorization( + authorization, + authorityPublicKey, + true, + ) + if err != nil { + return nil, fmt.Errorf("share repair authorization is invalid: %w", err) + } + if err := ValidateShareRepairTransportRoster( + transportRoster, + authorization, + authorityPublicKey, + ); err != nil { + return nil, fmt.Errorf("share repair transport roster is invalid: %w", err) + } + bus, err := newBroadcastChannelShareRepairBus( + ctx, + logger, + channel, + membershipValidator, + shareRepairParticipantSet(authorization), + validated.digest, + ) + if err != nil { + return nil, err + } + return runShareRepairOnBus( + ctx, + engine, + authorization, + transportRoster, + validated.digest, + localMemberIndexes, + bus, + ) +} + +func shareRepairParticipantSet( + authorization *ShareRepairAuthorization, +) map[group.MemberIndex]struct{} { + participants := make( + map[group.MemberIndex]struct{}, + len(authorization.HelperIdentifiers)+1, + ) + for _, helper := range authorization.HelperIdentifiers { + participants[group.MemberIndex(helper)] = struct{}{} + } + participants[group.MemberIndex(authorization.TargetIdentifier)] = struct{}{} + return participants +} + +func shareRepairRosterPublicKeys( + transportRoster *ShareRepairTransportRoster, + authorization *ShareRepairAuthorization, +) (map[group.MemberIndex][]byte, error) { + if _, err := ComputeShareRepairTransportRosterDigest( + transportRoster, + authorization, + ); err != nil { + return nil, err + } + publicKeys := make( + map[group.MemberIndex][]byte, + len(transportRoster.ParticipantPublicKeys), + ) + for index, endpoint := range transportRoster.ParticipantPublicKeys { + publicKey, err := parseCanonicalShareRepairTransportPublicKey( + endpoint.PublicKeyHex, + fmt.Sprintf("transport roster public key [%d]", index), + ) + if err != nil { + return nil, err + } + publicKeys[group.MemberIndex(endpoint.ParticipantIdentifier)] = append( + []byte(nil), + publicKey[:]..., + ) + } + return publicKeys, nil +} + +func runShareRepairOnBus( + ctx context.Context, + engine NativeTBTCSignerShareRepairEngine, + authorization *ShareRepairAuthorization, + transportRoster *ShareRepairTransportRoster, + authorizationDigest [32]byte, + localMemberIndexes []group.MemberIndex, + bus shareRepairBus, +) (targetResult *NativeShareRepairInstallResult, resultErr error) { + if ctx == nil || engine == nil || authorization == nil || transportRoster == nil || + bus == nil || len(localMemberIndexes) == 0 { + return nil, fmt.Errorf("share repair runner dependencies are incomplete") + } + participants := shareRepairParticipantSet(authorization) + rosterPublicKeys, err := shareRepairRosterPublicKeys( + transportRoster, + authorization, + ) + if err != nil { + return nil, fmt.Errorf("invalid share-repair transport roster: %w", err) + } + helperSet := make(map[group.MemberIndex]struct{}, len(authorization.HelperIdentifiers)) + for _, helper := range authorization.HelperIdentifiers { + member := group.MemberIndex(helper) + helperSet[member] = struct{}{} + } + + localSet := make(map[group.MemberIndex]struct{}, len(localMemberIndexes)) + runners := make([]*shareRepairRunner, 0, len(localMemberIndexes)) + defer func() { + for index := len(runners) - 1; index >= 0; index-- { + runner := runners[index] + if err := engine.FinishShareRepairSession( + authorization, + uint16(runner.member), + ); err != nil && resultErr == nil { + resultErr = fmt.Errorf( + "finish native share-repair session for seat [%d]: %w", + runner.member, + err, + ) + } + } + }() + for _, member := range localMemberIndexes { + if _, duplicate := localSet[member]; duplicate { + return nil, fmt.Errorf("duplicate local share-repair seat [%d]", member) + } + localSet[member] = struct{}{} + if _, participating := participants[member]; !participating { + return nil, fmt.Errorf("local seat [%d] is not in the repair authorization", member) + } + session, err := engine.BeginShareRepairSession( + authorization, + uint16(member), + ) + if err != nil { + // Begin may have created native state before a bridge-side decode or + // post-call validation failed. Finish is idempotent, including when + // Begin failed before creating a session. + _ = engine.FinishShareRepairSession(authorization, uint16(member)) + return nil, fmt.Errorf("begin native share-repair session for seat [%d]: %w", member, err) + } + if session == nil || session.ContextDigest != fmt.Sprintf("0x%x", authorizationDigest) || + session.ParticipantIdentifier != uint16(member) || + len(session.TransportPublicKey) != shareRepairEphemeralPublicKeyLength { + _ = engine.FinishShareRepairSession(authorization, uint16(member)) + return nil, fmt.Errorf("native share-repair session for seat [%d] is invalid", member) + } + rosterEndpoint, found := shareRepairRosterEndpoint( + transportRoster, + member, + ) + if !found || session.StoreFingerprint != rosterEndpoint.StoreFingerprint { + _ = engine.FinishShareRepairSession(authorization, uint16(member)) + return nil, fmt.Errorf( + "native share-repair session for seat [%d] does not match the signed store binding", + member, + ) + } + if _, err := btcec.ParsePubKey(session.TransportPublicKey); err != nil { + _ = engine.FinishShareRepairSession(authorization, uint16(member)) + return nil, fmt.Errorf("native share-repair session for seat [%d] returned an invalid public key: %w", member, err) + } + if !bytes.Equal(session.TransportPublicKey, rosterPublicKeys[member]) { + _ = engine.FinishShareRepairSession(authorization, uint16(member)) + return nil, fmt.Errorf( + "native share-repair session for seat [%d] does not match the signed transport roster", + member, + ) + } + runners = append(runners, &shareRepairRunner{ + member: member, + authorization: authorization, + transportRoster: transportRoster, + authorizationDigest: authorizationDigest, + contextWire: fmt.Sprintf("0x%x", authorizationDigest), + participants: participants, + helperSet: helperSet, + engine: engine, + bus: bus, + stream: bus.Subscribe(member), + ephemeralPublic: append([]byte(nil), session.TransportPublicKey...), + }) + } + bus.Start() + runContext, cancel := context.WithCancel(ctx) + defer cancel() + + outcomes := make(chan shareRepairRunnerOutcome, len(runners)) + for _, runner := range runners { + runner := runner + go func() { + result, err := runner.run(runContext) + outcomes <- shareRepairRunnerOutcome{member: runner.member, result: result, err: err} + }() + } + + var firstError error + for range runners { + outcome := <-outcomes + if outcome.err != nil && firstError == nil { + firstError = fmt.Errorf("share repair seat [%d] failed: %w", outcome.member, outcome.err) + cancel() + } + if outcome.result != nil { + if targetResult != nil { + firstError = fmt.Errorf("multiple local target results were returned") + } else { + targetResult = outcome.result + } + } + } + if firstError != nil { + return targetResult, firstError + } + if _, targetLocal := localSet[group.MemberIndex(authorization.TargetIdentifier)]; targetLocal && + targetResult == nil { + return nil, fmt.Errorf("local share-repair target returned no install result") + } + return targetResult, nil +} + +func (runner *shareRepairRunner) run( + ctx context.Context, +) (*NativeShareRepairInstallResult, error) { + announcement := shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: runner.member, + ContextDigest: runner.authorizationDigest, + EphemeralPublicKey: runner.ephemeralPublic, + } + // BroadcastChannel.Send owns context-lifetime retransmission. Publishing + // this announcement again at the application layer would register another + // independent retransmission handler on every repeat and turn a missing-peer + // timeout into quadratic traffic growth. + cancelAnnouncement := runner.bus.Broadcast(announcement) + defer cancelAnnouncement() + + _, err := runner.collectAnnouncements(ctx) + if err != nil { + return nil, err + } + if runner.member == group.MemberIndex(runner.authorization.TargetIdentifier) { + return runner.runTarget(ctx) + } + if _, helper := runner.helperSet[runner.member]; !helper { + return nil, fmt.Errorf("seat is neither target nor helper") + } + return nil, runner.runHelper(ctx, cancelAnnouncement) +} + +func (runner *shareRepairRunner) collectAnnouncements( + ctx context.Context, +) (map[group.MemberIndex][]byte, error) { + publicKeys := make(map[group.MemberIndex][]byte, len(runner.participants)) + selfPublic, err := btcec.ParsePubKey(runner.ephemeralPublic) + if err != nil { + return nil, fmt.Errorf("cannot parse local recovery ephemeral key: %w", err) + } + publicKeys[runner.member] = selfPublic.SerializeCompressed() + for len(publicKeys) < len(runner.participants) { + var message shareRepairMessage + select { + case <-ctx.Done(): + return nil, fmt.Errorf("collect recovery announcements: %w", ctx.Err()) + case message = <-runner.stream: + } + if message.ContextDigest != runner.authorizationDigest { + continue + } + // The transport enforces this exact authorization set for network + // traffic. Keep the same guard here for in-process and test buses so a + // nonparticipant cannot fill the bounded early-message queue. + if _, expected := runner.participants[message.Sender]; !expected { + continue + } + if message.Type != shareRepairAnnouncementMessage { + if err := runner.bufferPendingMessage(message); err != nil { + return nil, err + } + continue + } + parsed, err := btcec.ParsePubKey(message.EphemeralPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid recovery announcement from [%d]: %w", message.Sender, err) + } + parsedWire := parsed.SerializeCompressed() + expectedPublicKey, expected := shareRepairRosterPublicKey( + runner.transportRoster, + message.Sender, + ) + if !expected || !bytes.Equal(expectedPublicKey, parsedWire) { + return nil, fmt.Errorf( + "recovery seat [%d] announced a key outside the signed transport roster", + message.Sender, + ) + } + if existing, seen := publicKeys[message.Sender]; seen { + if !bytes.Equal(existing, parsedWire) { + return nil, fmt.Errorf("recovery seat [%d] equivocated its ephemeral key", message.Sender) + } + continue + } + publicKeys[message.Sender] = parsedWire + } + return publicKeys, nil +} + +func (runner *shareRepairRunner) bufferPendingMessage( + message shareRepairMessage, +) error { + if len(runner.pending) >= shareRepairMaximumPendingMessages { + return fmt.Errorf( + "share-repair pending-message limit exceeded before announcements completed", + ) + } + messageBytes := shareRepairMessageRetainedBytes(message) + if messageBytes > shareRepairMaximumSessionBytesPerSender- + runner.pendingBytesBySender[message.Sender] { + return fmt.Errorf( + "share-repair pending-byte limit exceeded for sender [%d] before announcements completed", + message.Sender, + ) + } + if messageBytes > shareRepairMaximumSessionBytes-runner.pendingBytes { + return fmt.Errorf( + "share-repair total pending-byte limit exceeded before announcements completed", + ) + } + if runner.pendingBytesBySender == nil { + runner.pendingBytesBySender = make(map[group.MemberIndex]int) + } + runner.pending = append(runner.pending, message) + runner.pendingBytes += messageBytes + runner.pendingBytesBySender[message.Sender] += messageBytes + return nil +} + +func shareRepairRosterPublicKey( + transportRoster *ShareRepairTransportRoster, + member group.MemberIndex, +) ([]byte, bool) { + if transportRoster == nil { + return nil, false + } + for _, endpoint := range transportRoster.ParticipantPublicKeys { + if endpoint.ParticipantIdentifier != uint16(member) { + continue + } + publicKey, err := parseCanonicalShareRepairTransportPublicKey( + endpoint.PublicKeyHex, + "transport roster public key", + ) + if err != nil { + return nil, false + } + return append([]byte(nil), publicKey[:]...), true + } + return nil, false +} + +func shareRepairRosterEndpoint( + transportRoster *ShareRepairTransportRoster, + member group.MemberIndex, +) (*ShareRepairTransportPublicKey, bool) { + if transportRoster == nil { + return nil, false + } + for index := range transportRoster.ParticipantPublicKeys { + if transportRoster.ParticipantPublicKeys[index].ParticipantIdentifier == uint16(member) { + return &transportRoster.ParticipantPublicKeys[index], true + } + } + return nil, false +} + +func (runner *shareRepairRunner) nextMessage( + ctx context.Context, +) (shareRepairMessage, error) { + if len(runner.pending) > 0 { + message := runner.pending[0] + messageBytes := shareRepairMessageRetainedBytes(message) + if messageBytes >= runner.pendingBytesBySender[message.Sender] { + delete(runner.pendingBytesBySender, message.Sender) + } else { + runner.pendingBytesBySender[message.Sender] -= messageBytes + } + if messageBytes >= runner.pendingBytes { + runner.pendingBytes = 0 + } else { + runner.pendingBytes -= messageBytes + } + runner.pending[0] = shareRepairMessage{} + runner.pending = runner.pending[1:] + if len(runner.pending) == 0 { + // Drop the backing array as soon as rendezvous traffic has drained so + // no payload references or stale accounting survive into later phases. + runner.pending = nil + runner.pendingBytes = 0 + runner.pendingBytesBySender = nil + } + return message, nil + } + select { + case <-ctx.Done(): + return shareRepairMessage{}, ctx.Err() + case message := <-runner.stream: + return message, nil + } +} + +func retainFirstShareRepairDelta( + deltas map[uint16]*NativeShareRepairEncryptedDelta, + message shareRepairMessage, + contextWire string, + recipientIdentifier uint16, +) { + senderIdentifier := uint16(message.Sender) + if deltas[senderIdentifier] != nil { + return + } + deltas[senderIdentifier] = &NativeShareRepairEncryptedDelta{ + ContextDigest: contextWire, + SenderIdentifier: senderIdentifier, + RecipientIdentifier: recipientIdentifier, + Payload: append([]byte(nil), message.Payload...), + } +} + +func retainFirstShareRepairSigma( + sigmas map[uint16]*NativeShareRepairEncryptedSigma, + message shareRepairMessage, + contextWire string, +) { + helperIdentifier := uint16(message.Sender) + if sigmas[helperIdentifier] != nil { + return + } + sigmas[helperIdentifier] = &NativeShareRepairEncryptedSigma{ + ContextDigest: contextWire, + HelperIdentifier: helperIdentifier, + Payload: append([]byte(nil), message.Payload...), + } +} + +func (runner *shareRepairRunner) runHelper( + ctx context.Context, + cancelAnnouncement context.CancelFunc, +) error { + part1, err := runner.engine.ShareRepairPart1( + runner.authorization, + uint16(runner.member), + runner.transportRoster, + ) + if err != nil { + return fmt.Errorf("native repair part1: %w", err) + } + if part1 == nil || part1.ContextDigest != runner.contextWire || + part1.HelperIdentifier != uint16(runner.member) || + part1.PublicKeyPackage == nil || + len(part1.PublicKeyPackage.VerifyingShares) != int(runner.authorization.ParticipantCount) || + part1.PublicKeyPackage.VerifyingKey == "" || + len(part1.Deltas) != len(runner.authorization.HelperIdentifiers) { + return fmt.Errorf("native repair part1 returned the wrong context or delta set") + } + publicPackage, err := json.Marshal(part1.PublicKeyPackage) + if err != nil { + return fmt.Errorf("encode repair public key package: %w", err) + } + if len(publicPackage) > shareRepairMaximumPublicPayload { + return fmt.Errorf("repair public key package exceeds the transport cap") + } + // A participant cannot cancel its announcement merely because it has heard + // every peer: another peer may still be missing this participant. The + // installed receipt proves all helpers crossed rendezvous, so retain the + // announcement with the helper's other pre-receipt messages until then. + preReceiptBroadcasts := make( + []context.CancelFunc, + 0, + len(runner.helperSet)+3, + ) + preReceiptBroadcasts = append(preReceiptBroadcasts, cancelAnnouncement) + cancelPreReceiptBroadcasts := func() { + for _, cancel := range preReceiptBroadcasts { + cancel() + } + preReceiptBroadcasts = nil + } + defer cancelPreReceiptBroadcasts() + preReceiptBroadcasts = append(preReceiptBroadcasts, runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: runner.member, + ContextDigest: runner.authorizationDigest, + Payload: publicPackage, + })) + for index, recipient := range runner.authorization.HelperIdentifiers { + delta := part1.Deltas[index] + if delta == nil || delta.ContextDigest != runner.contextWire || + delta.SenderIdentifier != uint16(runner.member) || + delta.RecipientIdentifier != recipient || + len(delta.Payload) != shareRepairEncryptedScalarPayloadLength { + return fmt.Errorf("native repair part1 delta [%d] is invalid", index) + } + preReceiptBroadcasts = append(preReceiptBroadcasts, runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: runner.member, + Recipient: group.MemberIndex(recipient), + ContextDigest: runner.authorizationDigest, + Payload: append([]byte(nil), delta.Payload...), + })) + } + + deltas := make(map[uint16]*NativeShareRepairEncryptedDelta, len(runner.helperSet)) + for len(deltas) < len(runner.helperSet) { + message, err := runner.nextMessage(ctx) + if err != nil { + return fmt.Errorf("collect recovery deltas: %w", err) + } + if message.Type != shareRepairDeltaMessage || + message.ContextDigest != runner.authorizationDigest || + message.Recipient != runner.member || + len(message.Payload) != shareRepairEncryptedScalarPayloadLength { + continue + } + if _, expected := runner.helperSet[message.Sender]; !expected { + continue + } + senderIdentifier := uint16(message.Sender) + if deltas[senderIdentifier] != nil { + // Native Part1 rows are deterministic for the exact signed bundle, but + // every ECIES envelope is freshly randomized. Retain one bounded + // candidate; byte inequality is not semantic equivocation. + continue + } + retainFirstShareRepairDelta( + deltas, + message, + runner.contextWire, + uint16(runner.member), + ) + } + ordered := make([]*NativeShareRepairEncryptedDelta, 0, len(deltas)) + for _, sender := range runner.authorization.HelperIdentifiers { + ordered = append(ordered, deltas[sender]) + } + part2, err := runner.engine.ShareRepairPart2( + runner.authorization, + uint16(runner.member), + ordered, + runner.transportRoster, + ) + if err != nil { + return fmt.Errorf("native repair part2: %w", err) + } + if part2 == nil || part2.ContextDigest != runner.contextWire || part2.Sigma == nil || + part2.Sigma.ContextDigest != runner.contextWire || + part2.Sigma.HelperIdentifier != uint16(runner.member) || + len(part2.Sigma.Payload) != shareRepairEncryptedScalarPayloadLength { + return fmt.Errorf("native repair part2 returned an invalid sigma") + } + preReceiptBroadcasts = append(preReceiptBroadcasts, runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairSigmaMessage, + Sender: runner.member, + Recipient: group.MemberIndex(runner.authorization.TargetIdentifier), + ContextDigest: runner.authorizationDigest, + Payload: append([]byte(nil), part2.Sigma.Payload...), + })) + return runner.waitForInstalledReceipt(ctx, cancelPreReceiptBroadcasts) +} + +func (runner *shareRepairRunner) runTarget( + ctx context.Context, +) (*NativeShareRepairInstallResult, error) { + sigmas := make(map[uint16]*NativeShareRepairEncryptedSigma, len(runner.helperSet)) + publicPackages := make(map[uint16][]byte, len(runner.helperSet)) + var publicKeyPackage *NativeFROSTPublicKeyPackage + var canonicalPublicKeyPackage []byte + for len(sigmas) < len(runner.helperSet) || len(publicPackages) < len(runner.helperSet) { + message, err := runner.nextMessage(ctx) + if err != nil { + return nil, fmt.Errorf("collect recovery sigmas: %w", err) + } + if message.ContextDigest != runner.authorizationDigest { + continue + } + if _, expected := runner.helperSet[message.Sender]; !expected { + continue + } + if message.Type == shareRepairPublicPackageMessage { + candidate := &NativeFROSTPublicKeyPackage{} + decoder := json.NewDecoder(bytes.NewReader(message.Payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(candidate); err != nil { + return nil, fmt.Errorf("decode repair public package from [%d]: %w", message.Sender, err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("repair public package from [%d] has trailing JSON", message.Sender) + } + if len(candidate.VerifyingShares) != int(runner.authorization.ParticipantCount) || + candidate.VerifyingKey == "" { + return nil, fmt.Errorf("repair public package from [%d] has the wrong shape", message.Sender) + } + canonical, err := json.Marshal(candidate) + if err != nil { + return nil, fmt.Errorf("canonicalize repair public package: %w", err) + } + if existing := publicPackages[uint16(message.Sender)]; existing != nil { + if !bytes.Equal(existing, canonical) { + return nil, fmt.Errorf("repair helper [%d] equivocated its public package", message.Sender) + } + continue + } + if canonicalPublicKeyPackage != nil && + !bytes.Equal(canonicalPublicKeyPackage, canonical) { + return nil, fmt.Errorf("repair helpers disagree on the public key package") + } + canonicalPublicKeyPackage = append([]byte(nil), canonical...) + publicPackages[uint16(message.Sender)] = canonical + publicKeyPackage = candidate + continue + } + if message.Type != shareRepairSigmaMessage || message.Recipient != runner.member || + len(message.Payload) != shareRepairEncryptedScalarPayloadLength { + continue + } + helperIdentifier := uint16(message.Sender) + if sigmas[helperIdentifier] != nil { + // Retries may carry a fresh ECIES encoding of the same deterministic + // bundle slot. Keep the first candidate and bound memory to one sigma + // per authorized helper. + continue + } + retainFirstShareRepairSigma(sigmas, message, runner.contextWire) + } + ordered := make([]*NativeShareRepairEncryptedSigma, 0, len(sigmas)) + for _, helper := range runner.authorization.HelperIdentifiers { + ordered = append(ordered, sigmas[helper]) + } + result, err := runner.engine.InstallRepairedShare( + runner.authorization, + publicKeyPackage, + ordered, + runner.transportRoster, + ) + if err != nil { + return nil, fmt.Errorf("native repaired-share install: %w", err) + } + if result == nil || result.Schema != ShareRepairInstallResultSchema || + result.SessionID != runner.authorization.SessionID || + result.KeyGroup != runner.authorization.KeyGroup || + result.TargetIdentifier != runner.authorization.TargetIdentifier || + result.RecoveryEpoch != runner.authorization.RecoveryEpoch || + result.AuthorizationDigest != runner.contextWire || + result.ActiveStoreFingerprint != runner.authorization.NewStoreFingerprint { + return nil, fmt.Errorf("native repaired-share install result does not match authorization") + } + if err := recordInstalledShareRepair(result); err != nil { + return nil, fmt.Errorf("arm repaired-seat activation guard: %w", err) + } + if err := runner.publishInstalledReceiptAndWaitForAcknowledgements( + ctx, + result, + ); err != nil { + return result, fmt.Errorf( + "repaired share was durably installed and anchor-acknowledged, but receipt acknowledgements are incomplete: %w", + err, + ) + } + return result, nil +} + +func (runner *shareRepairRunner) publishInstalledReceiptAndWaitForAcknowledgements( + ctx context.Context, + result *NativeShareRepairInstallResult, +) error { + receipt, err := json.Marshal(shareRepairInstalledWire{ + Schema: result.Schema, + SessionID: result.SessionID, + KeyGroup: result.KeyGroup, + TargetIdentifier: result.TargetIdentifier, + RecoveryEpoch: result.RecoveryEpoch, + AuthorizationDigest: result.AuthorizationDigest, + ActiveStoreFingerprint: result.ActiveStoreFingerprint, + Idempotent: result.Idempotent, + }) + if err != nil { + return fmt.Errorf("encode repaired-share installed receipt: %w", err) + } + receiptDigest := sha256.Sum256(receipt) + // Publish once. The network layer keeps this exact sequence number alive + // while the target waits below, until every authorized helper proves it saw + // the durable, anchor-acknowledged result or the maintenance deadline fires. + cancelReceipt := runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairInstalledMessage, + Sender: runner.member, + ContextDigest: runner.authorizationDigest, + Payload: receipt, + }) + defer cancelReceipt() + if err := runner.waitForInstalledAcknowledgements(ctx, receiptDigest); err != nil { + return fmt.Errorf("confirm repaired-share installed receipt: %w", err) + } + cancelReceipt() + // This final signal is only an early-release optimization for helpers. A + // helper already knows the install is durable from the validated receipt; + // if this signal is lost, it safely keeps its one acknowledgement + // retransmitter alive until the maintenance deadline instead of adding + // application-level repeats. + cancelCompletion := runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: runner.member, + ContextDigest: runner.authorizationDigest, + Payload: append([]byte(nil), receiptDigest[:]...), + }) + cancelCompletion() + return nil +} + +func (runner *shareRepairRunner) waitForInstalledAcknowledgements( + ctx context.Context, + receiptDigest [sha256.Size]byte, +) error { + acknowledged := make(map[group.MemberIndex]struct{}, len(runner.helperSet)) + for len(acknowledged) < len(runner.helperSet) { + message, err := runner.nextMessage(ctx) + if err != nil { + return fmt.Errorf("wait for repaired-share receipt acknowledgements: %w", err) + } + if message.Type != shareRepairInstalledAcknowledgementMessage || + message.ContextDigest != runner.authorizationDigest || + message.Recipient != runner.member { + continue + } + if _, expected := runner.helperSet[message.Sender]; !expected { + continue + } + if len(message.Payload) != sha256.Size || + !bytes.Equal(message.Payload, receiptDigest[:]) { + return fmt.Errorf( + "helper [%d] acknowledged a different installed receipt", + message.Sender, + ) + } + acknowledged[message.Sender] = struct{}{} + } + return nil +} + +func (runner *shareRepairRunner) waitForInstalledReceipt( + ctx context.Context, + cancelPreReceiptBroadcasts context.CancelFunc, +) error { + for { + message, err := runner.nextMessage(ctx) + if err != nil { + return fmt.Errorf("wait for repaired-share installed receipt: %w", err) + } + if message.Type != shareRepairInstalledMessage || + message.ContextDigest != runner.authorizationDigest || + message.Sender != group.MemberIndex(runner.authorization.TargetIdentifier) { + continue + } + receipt := &shareRepairInstalledWire{} + decoder := json.NewDecoder(bytes.NewReader(message.Payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(receipt); err != nil { + return fmt.Errorf("decode repaired-share installed receipt: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("repaired-share installed receipt has trailing JSON") + } + if receipt.Schema != ShareRepairInstallResultSchema || + receipt.SessionID != runner.authorization.SessionID || + receipt.KeyGroup != runner.authorization.KeyGroup || + receipt.TargetIdentifier != runner.authorization.TargetIdentifier || + receipt.RecoveryEpoch != runner.authorization.RecoveryEpoch || + receipt.AuthorizationDigest != runner.contextWire || + receipt.ActiveStoreFingerprint != runner.authorization.NewStoreFingerprint { + return fmt.Errorf("repaired-share installed receipt does not match authorization") + } + receiptDigest := sha256.Sum256(message.Payload) + cancelPreReceiptBroadcasts() + cancelAcknowledgement := runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairInstalledAcknowledgementMessage, + Sender: runner.member, + Recipient: group.MemberIndex(runner.authorization.TargetIdentifier), + ContextDigest: runner.authorizationDigest, + Payload: append([]byte(nil), receiptDigest[:]...), + }) + defer cancelAcknowledgement() + return runner.waitForShareRepairCompletion(ctx, receiptDigest) + } +} + +func (runner *shareRepairRunner) waitForShareRepairCompletion( + ctx context.Context, + receiptDigest [sha256.Size]byte, +) error { + for { + message, err := runner.nextMessage(ctx) + if err != nil { + // Receipt validation already proved the target durably installed and + // anchor-acknowledged the share. Waiting keeps this helper's single ACK + // retransmitter alive; exhausting the enclosing maintenance window does + // not invalidate that completed repair. + if ctx.Err() == context.DeadlineExceeded { + return nil + } + return fmt.Errorf("wait for share-repair completion: %w", err) + } + if message.Type != shareRepairCompletionMessage || + message.ContextDigest != runner.authorizationDigest || + message.Sender != group.MemberIndex(runner.authorization.TargetIdentifier) || + message.Recipient != 0 { + continue + } + if len(message.Payload) != sha256.Size || + !bytes.Equal(message.Payload, receiptDigest[:]) { + return fmt.Errorf("target completed a different installed receipt") + } + return nil + } +} diff --git a/pkg/frost/signing/share_repair_runner_frost_native_test.go b/pkg/frost/signing/share_repair_runner_frost_native_test.go new file mode 100644 index 0000000000..a7a2f682a9 --- /dev/null +++ b/pkg/frost/signing/share_repair_runner_frost_native_test.go @@ -0,0 +1,1551 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type testShareRepairEngine struct { + mutex sync.Mutex + beginCalls int + finishCalls int + installCalls int +} + +type testShareRepairSessionOverrideEngine struct { + *testShareRepairEngine + beginError error + finishError error + mutate func(*NativeShareRepairSession) +} + +type captureShareRepairPart2Engine struct { + *testShareRepairEngine + deltas []*NativeShareRepairEncryptedDelta +} + +func (engine *captureShareRepairPart2Engine) ShareRepairPart2( + _ *ShareRepairAuthorization, + _ uint16, + deltas []*NativeShareRepairEncryptedDelta, + _ *ShareRepairTransportRoster, +) (*NativeShareRepairPart2Result, error) { + engine.deltas = append([]*NativeShareRepairEncryptedDelta(nil), deltas...) + return nil, fmt.Errorf("stop after part2 input") +} + +type captureShareRepairInstallEngine struct { + *testShareRepairEngine + sigmas []*NativeShareRepairEncryptedSigma +} + +func (engine *captureShareRepairInstallEngine) InstallRepairedShare( + _ *ShareRepairAuthorization, + _ *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairEncryptedSigma, + _ *ShareRepairTransportRoster, +) (*NativeShareRepairInstallResult, error) { + engine.sigmas = append([]*NativeShareRepairEncryptedSigma(nil), sigmas...) + return nil, fmt.Errorf("stop after install input") +} + +func (engine *testShareRepairSessionOverrideEngine) BeginShareRepairSession( + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) (*NativeShareRepairSession, error) { + if engine.beginError != nil { + engine.mutex.Lock() + engine.beginCalls++ + engine.mutex.Unlock() + return nil, engine.beginError + } + session, err := engine.testShareRepairEngine.BeginShareRepairSession( + authorization, + participantIdentifier, + ) + if session != nil && engine.mutate != nil { + engine.mutate(session) + } + return session, err +} + +func (engine *testShareRepairSessionOverrideEngine) FinishShareRepairSession( + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) error { + _ = engine.testShareRepairEngine.FinishShareRepairSession( + authorization, + participantIdentifier, + ) + return engine.finishError +} + +const testShareRepairCiphertextLength = shareRepairEncryptedScalarPayloadLength + +func testShareRepairSecret(value byte) []byte { + return bytes.Repeat([]byte{value}, 32) +} + +func testShareRepairPublicKeyPackage() *NativeFROSTPublicKeyPackage { + return &NativeFROSTPublicKeyPackage{ + VerifyingShares: map[string]string{ + "1": "share-1", + "2": "share-2", + "3": "share-3", + }, + VerifyingKey: "group-verifying-key", + } +} + +func testShareRepairPublicKey(identifier uint16) []byte { + privateBytes := bytes.Repeat([]byte{byte(0x11 * identifier)}, 32) + _, publicKey := btcec.PrivKeyFromBytes(privateBytes) + return publicKey.SerializeCompressed() +} + +func testShareRepairCiphertext(kind byte, sender, recipient uint16) []byte { + result := bytes.Repeat([]byte{0xa5}, testShareRepairCiphertextLength) + result[0] = kind + result[1] = byte(sender >> 8) + result[2] = byte(sender) + result[3] = byte(recipient >> 8) + result[4] = byte(recipient) + return result +} + +func currentTestShareRepairAuthorization( + t *testing.T, +) (*ShareRepairAuthorization, ed25519.PrivateKey) { + t.Helper() + authorization, authority := testShareRepairAuthorization(t) + now := uint64(time.Now().Unix()) + authorization.IssuedAtUnix = now - 60 + // Preflight is intentionally allowed before the recovery not-before time. + authorization.NotBeforeUnix = now + 300 + authorization.ExpiresAtUnix = now + 3600 + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + authorization.SignatureHex = "0x" + hex.EncodeToString( + ed25519.Sign(authority, digest[:]), + ) + return authorization, authority +} + +func (engine *testShareRepairEngine) BeginShareRepairSession( + authorization *ShareRepairAuthorization, + participantIdentifier uint16, +) (*NativeShareRepairSession, error) { + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return nil, err + } + engine.mutex.Lock() + engine.beginCalls++ + engine.mutex.Unlock() + return &NativeShareRepairSession{ + ContextDigest: fmt.Sprintf("0x%x", digest), + ParticipantIdentifier: participantIdentifier, + StoreFingerprint: authorization.NewStoreFingerprint, + TransportPublicKey: testShareRepairPublicKey(participantIdentifier), + }, nil +} + +func (engine *testShareRepairEngine) FinishShareRepairSession( + _ *ShareRepairAuthorization, + _ uint16, +) error { + engine.mutex.Lock() + engine.finishCalls++ + engine.mutex.Unlock() + return nil +} + +func (engine *testShareRepairEngine) ShareRepairPart1( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + transportRoster *ShareRepairTransportRoster, +) (*NativeShareRepairPart1Result, error) { + if _, err := ComputeShareRepairTransportRosterDigest( + transportRoster, + authorization, + ); err != nil { + return nil, fmt.Errorf("invalid transport roster: %w", err) + } + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return nil, err + } + contextWire := fmt.Sprintf("0x%x", digest) + result := &NativeShareRepairPart1Result{ + ContextDigest: contextWire, + HelperIdentifier: helperIdentifier, + PublicKeyPackage: testShareRepairPublicKeyPackage(), + } + for index, recipient := range authorization.HelperIdentifiers { + endpoint := transportRoster.ParticipantPublicKeys[index] + publicKey, _ := hex.DecodeString(endpoint.PublicKeyHex) + if endpoint.ParticipantIdentifier != recipient || + !bytes.Equal(publicKey, testShareRepairPublicKey(recipient)) { + return nil, fmt.Errorf("wrong recipient key [%d]", index) + } + result.Deltas = append(result.Deltas, &NativeShareRepairEncryptedDelta{ + ContextDigest: contextWire, + SenderIdentifier: helperIdentifier, + RecipientIdentifier: recipient, + Payload: testShareRepairCiphertext(1, helperIdentifier, recipient), + }) + } + return result, nil +} + +func (engine *testShareRepairEngine) ShareRepairPart2( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairEncryptedDelta, + transportRoster *ShareRepairTransportRoster, +) (*NativeShareRepairPart2Result, error) { + if _, err := ComputeShareRepairTransportRosterDigest( + transportRoster, + authorization, + ); err != nil { + return nil, fmt.Errorf("invalid transport roster: %w", err) + } + if len(deltas) != len(authorization.HelperIdentifiers) { + return nil, fmt.Errorf("wrong delta count") + } + for index, sender := range authorization.HelperIdentifiers { + delta := deltas[index] + if delta == nil || delta.SenderIdentifier != sender || + delta.RecipientIdentifier != helperIdentifier || + !bytes.Equal(delta.Payload, testShareRepairCiphertext(1, sender, helperIdentifier)) { + return nil, fmt.Errorf("wrong delta [%d]", index) + } + } + targetPublicKey, _ := hex.DecodeString( + transportRoster.ParticipantPublicKeys[len(transportRoster.ParticipantPublicKeys)-1].PublicKeyHex, + ) + if !bytes.Equal( + targetPublicKey, + testShareRepairPublicKey(authorization.TargetIdentifier), + ) { + return nil, fmt.Errorf("wrong target public key") + } + digest, _ := ComputeShareRepairAuthorizationDigest(authorization) + contextWire := fmt.Sprintf("0x%x", digest) + return &NativeShareRepairPart2Result{ + ContextDigest: contextWire, + Sigma: &NativeShareRepairEncryptedSigma{ + ContextDigest: contextWire, + HelperIdentifier: helperIdentifier, + Payload: testShareRepairCiphertext( + 2, + helperIdentifier, + authorization.TargetIdentifier, + ), + }, + }, nil +} + +func (engine *testShareRepairEngine) InstallRepairedShare( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairEncryptedSigma, + transportRoster *ShareRepairTransportRoster, +) (*NativeShareRepairInstallResult, error) { + if _, err := ComputeShareRepairTransportRosterDigest( + transportRoster, + authorization, + ); err != nil { + return nil, fmt.Errorf("invalid transport roster: %w", err) + } + if publicKeyPackage == nil || publicKeyPackage.VerifyingKey != "group-verifying-key" || + len(publicKeyPackage.VerifyingShares) != 3 { + return nil, fmt.Errorf("wrong public key package") + } + if len(sigmas) != len(authorization.HelperIdentifiers) { + return nil, fmt.Errorf("wrong sigma count") + } + for index, helper := range authorization.HelperIdentifiers { + sigma := sigmas[index] + if sigma == nil || sigma.HelperIdentifier != helper || + !bytes.Equal( + sigma.Payload, + testShareRepairCiphertext(2, helper, authorization.TargetIdentifier), + ) { + return nil, fmt.Errorf("wrong sigma [%d]", index) + } + } + engine.mutex.Lock() + engine.installCalls++ + engine.mutex.Unlock() + digest, _ := ComputeShareRepairAuthorizationDigest(authorization) + return &NativeShareRepairInstallResult{ + Schema: ShareRepairInstallResultSchema, + SessionID: authorization.SessionID, + KeyGroup: authorization.KeyGroup, + TargetIdentifier: authorization.TargetIdentifier, + RecoveryEpoch: authorization.RecoveryEpoch, + AuthorizationDigest: fmt.Sprintf("0x%x", digest), + ActiveStoreFingerprint: authorization.NewStoreFingerprint, + }, nil +} + +type recordingShareRepairBus struct { + shareRepairBus + mutex sync.Mutex + messages []shareRepairMessage +} + +func (bus *recordingShareRepairBus) Broadcast( + message shareRepairMessage, +) context.CancelFunc { + copy := message + copy.EphemeralPublicKey = append([]byte(nil), message.EphemeralPublicKey...) + copy.Payload = append([]byte(nil), message.Payload...) + bus.mutex.Lock() + bus.messages = append(bus.messages, copy) + bus.mutex.Unlock() + return bus.shareRepairBus.Broadcast(message) +} + +type subscriptionBarrierShareRepairBus struct { + shareRepairBus + expected int + mutex sync.Mutex + count int + ready chan struct{} +} + +func (bus *subscriptionBarrierShareRepairBus) Subscribe( + member group.MemberIndex, +) <-chan shareRepairMessage { + stream := bus.shareRepairBus.Subscribe(member) + bus.mutex.Lock() + bus.count++ + if bus.count == bus.expected { + close(bus.ready) + } + bus.mutex.Unlock() + return stream +} + +func (bus *subscriptionBarrierShareRepairBus) Broadcast( + message shareRepairMessage, +) context.CancelFunc { + <-bus.ready + return bus.shareRepairBus.Broadcast(message) +} + +func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + engine := &testShareRepairEngine{} + bus := &recordingShareRepairBus{ + shareRepairBus: &subscriptionBarrierShareRepairBus{ + shareRepairBus: newInProcessShareRepairBus(128), + expected: 3, + ready: make(chan struct{}), + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + type outcome struct { + member group.MemberIndex + result *NativeShareRepairInstallResult + err error + } + outcomes := make(chan outcome, 3) + for _, member := range []group.MemberIndex{1, 2, 3} { + member := member + go func() { + result, err := runShareRepairOnBus( + ctx, + engine, + authorization, + transportRoster, + digest, + []group.MemberIndex{member}, + bus, + ) + outcomes <- outcome{member: member, result: result, err: err} + }() + } + targetResults := 0 + for i := 0; i < 3; i++ { + outcome := <-outcomes + if outcome.err != nil { + t.Fatalf("seat [%d] failed: %v", outcome.member, outcome.err) + } + if outcome.result != nil { + if outcome.member != 3 { + t.Fatalf("helper seat [%d] returned an install result", outcome.member) + } + targetResults++ + } + } + if targetResults != 1 { + t.Fatalf("expected one target install result, got [%d]", targetResults) + } + engine.mutex.Lock() + if engine.installCalls != 1 { + engine.mutex.Unlock() + t.Fatalf("expected one native install, got [%d]", engine.installCalls) + } + if engine.beginCalls != 3 || engine.finishCalls != 3 { + engine.mutex.Unlock() + t.Fatalf( + "expected three native session begin/finish calls, got [%d]/[%d]", + engine.beginCalls, + engine.finishCalls, + ) + } + engine.mutex.Unlock() + + bus.mutex.Lock() + defer bus.mutex.Unlock() + secretMessages := 0 + publicPackages := 0 + installedAcknowledgements := 0 + completions := 0 + for _, message := range bus.messages { + if message.Type == shareRepairAnnouncementMessage { + if len(message.Payload) != 0 || len(message.EphemeralPublicKey) != 33 { + t.Fatal("public recovery announcement has the wrong shape") + } + continue + } + if message.Type == shareRepairInstalledMessage { + if len(message.Payload) == 0 || message.Recipient != 0 || message.Sender != 3 { + t.Fatal("installed receipt has the wrong public shape") + } + continue + } + if message.Type == shareRepairPublicPackageMessage { + publicPackages++ + if message.Recipient != 0 || len(message.Payload) == 0 { + t.Fatal("public key package has the wrong public shape") + } + continue + } + if message.Type == shareRepairInstalledAcknowledgementMessage { + installedAcknowledgements++ + if message.Recipient != 3 || len(message.Payload) != sha256.Size || + (message.Sender != 1 && message.Sender != 2) { + t.Fatal("installed receipt acknowledgement has the wrong public shape") + } + continue + } + if message.Type == shareRepairCompletionMessage { + completions++ + if message.Recipient != 0 || message.Sender != 3 || + len(message.Payload) != sha256.Size { + t.Fatal("share-repair completion has the wrong public shape") + } + continue + } + secretMessages++ + if bytes.Contains(message.Payload, []byte("data_hex")) { + t.Fatal("repair scalar was sent as plaintext JSON") + } + for _, value := range []byte{11, 12, 21, 22, 101, 102} { + plaintextHex := []byte(hex.EncodeToString(testShareRepairSecret(value))) + if bytes.Contains(message.Payload, plaintextHex) { + t.Fatalf("repair scalar [%d] appears in the network payload", value) + } + } + } + if secretMessages != 6 { + t.Fatalf("expected four deltas and two sigmas, got [%d] secret messages", secretMessages) + } + if publicPackages != 2 { + t.Fatalf("expected one public package from each helper, got [%d]", publicPackages) + } + if installedAcknowledgements != 2 { + t.Fatalf( + "expected one installed receipt acknowledgement from each helper, got [%d]", + installedAcknowledgements, + ) + } + if completions != 1 { + t.Fatalf("expected one share-repair completion, got [%d]", completions) + } +} + +func TestRunShareRepairOnBusTimesOutWithoutExactParticipantSet(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, _ := ComputeShareRepairAuthorizationDigest(authorization) + bus := &recordingShareRepairBus{shareRepairBus: newInProcessShareRepairBus(16)} + engine := &testShareRepairEngine{} + ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond) + defer cancel() + _, err := runShareRepairOnBus( + ctx, + engine, + authorization, + transportRoster, + digest, + []group.MemberIndex{1}, + bus, + ) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("context deadline exceeded")) { + t.Fatalf("expected exact-set announcement timeout, got [%v]", err) + } + engine.mutex.Lock() + if engine.beginCalls != 1 || engine.finishCalls != 1 { + engine.mutex.Unlock() + t.Fatalf( + "native timeout cleanup was incomplete: begin [%d], finish [%d]", + engine.beginCalls, + engine.finishCalls, + ) + } + engine.mutex.Unlock() + bus.mutex.Lock() + defer bus.mutex.Unlock() + announcementCount := 0 + for _, message := range bus.messages { + if message.Type == shareRepairAnnouncementMessage { + announcementCount++ + } + } + if announcementCount != 1 { + t.Fatalf( + "application layer must publish one announcement, got [%d]", + announcementCount, + ) + } +} + +func TestPrepareShareRepairTransportRosterEntry(t *testing.T) { + authorization, authority := currentTestShareRepairAuthorization(t) + authorityPublicKey := authority.Public().(ed25519.PublicKey) + + t.Run("success before not-before", func(t *testing.T) { + engine := &testShareRepairEngine{} + entry, err := PrepareShareRepairTransportRosterEntry( + engine, + authorization, + authorityPublicKey, + 1, + ) + if err != nil { + t.Fatal(err) + } + if entry.ParticipantIdentifier != 1 || + entry.StoreFingerprint != authorization.NewStoreFingerprint || + entry.PublicKeyHex != hex.EncodeToString(testShareRepairPublicKey(1)) { + t.Fatalf("unexpected transport preflight entry: %+v", entry) + } + engine.mutex.Lock() + defer engine.mutex.Unlock() + if engine.beginCalls != 1 || engine.finishCalls != 1 { + t.Fatalf( + "transport preflight lifecycle was [%d]/[%d], expected 1/1", + engine.beginCalls, + engine.finishCalls, + ) + } + }) + + t.Run("begin error still cleans up", func(t *testing.T) { + engine := &testShareRepairSessionOverrideEngine{ + testShareRepairEngine: &testShareRepairEngine{}, + beginError: fmt.Errorf("response decode failed after native begin"), + } + if _, err := PrepareShareRepairTransportRosterEntry( + engine, + authorization, + authorityPublicKey, + 1, + ); err == nil { + t.Fatal("transport preflight accepted a failed native begin") + } + engine.mutex.Lock() + defer engine.mutex.Unlock() + if engine.beginCalls != 1 || engine.finishCalls != 1 { + t.Fatalf("failed begin cleanup was [%d]/[%d]", engine.beginCalls, engine.finishCalls) + } + }) + + for name, mutate := range map[string]func(*NativeShareRepairSession){ + "invalid public key": func(session *NativeShareRepairSession) { + session.TransportPublicKey = bytes.Repeat([]byte{0xff}, shareRepairEphemeralPublicKeyLength) + }, + "invalid store fingerprint": func(session *NativeShareRepairSession) { + session.StoreFingerprint = "0x00" + }, + } { + t.Run(name+" cleans up", func(t *testing.T) { + engine := &testShareRepairSessionOverrideEngine{ + testShareRepairEngine: &testShareRepairEngine{}, + mutate: mutate, + } + if _, err := PrepareShareRepairTransportRosterEntry( + engine, + authorization, + authorityPublicKey, + 1, + ); err == nil { + t.Fatal("transport preflight accepted an invalid native session") + } + engine.mutex.Lock() + defer engine.mutex.Unlock() + if engine.beginCalls != 1 || engine.finishCalls != 1 { + t.Fatalf("invalid session cleanup was [%d]/[%d]", engine.beginCalls, engine.finishCalls) + } + }) + } + + t.Run("finish error is reported", func(t *testing.T) { + engine := &testShareRepairSessionOverrideEngine{ + testShareRepairEngine: &testShareRepairEngine{}, + finishError: fmt.Errorf("native cleanup failed"), + } + if _, err := PrepareShareRepairTransportRosterEntry( + engine, + authorization, + authorityPublicKey, + 1, + ); err == nil || !bytes.Contains([]byte(err.Error()), []byte("cleanup failed")) { + t.Fatalf("transport preflight hid finish failure: %v", err) + } + }) + + t.Run("unauthorized seat is rejected before begin", func(t *testing.T) { + engine := &testShareRepairEngine{} + if _, err := PrepareShareRepairTransportRosterEntry( + engine, + authorization, + authorityPublicKey, + 99, + ); err == nil { + t.Fatal("transport preflight accepted an unauthorized seat") + } + engine.mutex.Lock() + defer engine.mutex.Unlock() + if engine.beginCalls != 0 || engine.finishCalls != 0 { + t.Fatal("unauthorized seat reached the native engine") + } + }) + + t.Run("native-invalid session id is rejected before begin", func(t *testing.T) { + candidate := *authorization + candidate.SessionID = "repair wallet" + engine := &testShareRepairEngine{} + _, err := PrepareShareRepairTransportRosterEntry( + engine, + &candidate, + authorityPublicKey, + 1, + ) + if err == nil || !strings.Contains(err.Error(), "session id is invalid") { + t.Fatalf("transport preflight did not enforce the native session ID contract: %v", err) + } + engine.mutex.Lock() + defer engine.mutex.Unlock() + if engine.beginCalls != 0 || engine.finishCalls != 0 { + t.Fatal("native-invalid session ID reached the native engine") + } + }) + + for name, timestamp := range map[string]func(*ShareRepairAuthorization){ + "before issued": func(candidate *ShareRepairAuthorization) { + candidate.IssuedAtUnix = uint64(time.Now().Unix()) + 60 + candidate.NotBeforeUnix = candidate.IssuedAtUnix + candidate.ExpiresAtUnix = candidate.IssuedAtUnix + 60 + }, + "expired": func(candidate *ShareRepairAuthorization) { + candidate.IssuedAtUnix = 1 + candidate.NotBeforeUnix = 1 + candidate.ExpiresAtUnix = 2 + }, + } { + t.Run(name, func(t *testing.T) { + candidate := *authorization + timestamp(&candidate) + digest, err := ComputeShareRepairAuthorizationDigest(&candidate) + if err != nil { + t.Fatal(err) + } + candidate.SignatureHex = "0x" + hex.EncodeToString( + ed25519.Sign(authority, digest[:]), + ) + engine := &testShareRepairEngine{} + if _, err := PrepareShareRepairTransportRosterEntry( + engine, + &candidate, + authorityPublicKey, + 1, + ); err == nil { + t.Fatalf("transport preflight accepted authorization %s", name) + } + }) + } +} + +func TestRunShareRepairRejectsNativeSessionOutsideSignedRoster(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + + tests := map[string]func(*NativeShareRepairSession){ + "public key": func(session *NativeShareRepairSession) { + session.TransportPublicKey = testShareRepairPublicKey(2) + }, + "store fingerprint": func(session *NativeShareRepairSession) { + session.StoreFingerprint = testShareRepairHex32(0x99) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + base := &testShareRepairEngine{} + engine := &testShareRepairSessionOverrideEngine{ + testShareRepairEngine: base, + mutate: mutate, + } + _, err := runShareRepairOnBus( + context.Background(), + engine, + authorization, + transportRoster, + digest, + []group.MemberIndex{1}, + newInProcessShareRepairBus(4), + ) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("signed")) { + t.Fatalf("native %s mismatch was accepted: %v", name, err) + } + base.mutex.Lock() + defer base.mutex.Unlock() + if base.beginCalls != 1 || base.finishCalls != 1 { + t.Fatalf("mismatch lifecycle was [%d]/[%d]", base.beginCalls, base.finishCalls) + } + }) + } +} + +type manualShareRepairBus struct { + broadcasts chan shareRepairMessage +} + +func (*manualShareRepairBus) Subscribe(group.MemberIndex) <-chan shareRepairMessage { + return nil +} + +func (*manualShareRepairBus) Start() {} + +func (bus *manualShareRepairBus) Broadcast( + message shareRepairMessage, +) context.CancelFunc { + bus.broadcasts <- message + return func() {} +} + +type cancelTrackingShareRepairBroadcast struct { + message shareRepairMessage + canceled chan struct{} + once sync.Once +} + +type cancelTrackingShareRepairBus struct { + broadcasts chan *cancelTrackingShareRepairBroadcast +} + +func (*cancelTrackingShareRepairBus) Subscribe( + group.MemberIndex, +) <-chan shareRepairMessage { + return nil +} + +func (*cancelTrackingShareRepairBus) Start() {} + +func (bus *cancelTrackingShareRepairBus) Broadcast( + message shareRepairMessage, +) context.CancelFunc { + broadcast := &cancelTrackingShareRepairBroadcast{ + message: message, + canceled: make(chan struct{}), + } + bus.broadcasts <- broadcast + return func() { broadcast.once.Do(func() { close(broadcast.canceled) }) } +} + +func assertShareRepairStillWaiting( + t *testing.T, + result <-chan error, + stage string, +) { + t.Helper() + select { + case err := <-result: + t.Fatalf("protocol returned before %s: [%v]", stage, err) + case <-time.After(25 * time.Millisecond): + } +} + +func TestShareRepairAnnouncementRemainsLivePastLocalRendezvous(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + bus := &cancelTrackingShareRepairBus{ + broadcasts: make(chan *cancelTrackingShareRepairBroadcast, 8), + } + stream := make(chan shareRepairMessage, 4) + runner := &shareRepairRunner{ + member: 1, + authorization: authorization, + transportRoster: transportRoster, + authorizationDigest: digest, + contextWire: fmt.Sprintf("0x%x", digest), + participants: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + 3: {}, + }, + helperSet: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + engine: &testShareRepairEngine{}, + bus: bus, + stream: stream, + ephemeralPublic: testShareRepairPublicKey(1), + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan error, 1) + go func() { + _, err := runner.run(ctx) + completed <- err + }() + + announcement := <-bus.broadcasts + if announcement.message.Type != shareRepairAnnouncementMessage { + t.Fatalf("runner first published the wrong message: %+v", announcement.message) + } + for _, peer := range []group.MemberIndex{2, 3} { + stream <- shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: peer, + ContextDigest: digest, + EphemeralPublicKey: testShareRepairPublicKey(uint16(peer)), + } + } + + for { + broadcast := <-bus.broadcasts + if broadcast.message.Type == shareRepairPublicPackageMessage { + break + } + } + select { + case <-announcement.canceled: + t.Fatal("runner canceled its announcement at only a local rendezvous") + default: + } + cancel() + select { + case err := <-completed: + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("context canceled")) { + t.Fatalf("runner did not stop on cancellation: %v", err) + } + case <-time.After(time.Second): + t.Fatal("runner did not stop after cancellation") + } + select { + case <-announcement.canceled: + default: + t.Fatal("runner did not cancel its announcement when the protocol ended") + } +} + +func TestShareRepairTargetRetainsReceiptUntilExactHelperAcknowledgements(t *testing.T) { + authorization, _ := testShareRepairAuthorization(t) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + bus := &manualShareRepairBus{broadcasts: make(chan shareRepairMessage, 4)} + stream := make(chan shareRepairMessage, 8) + runner := &shareRepairRunner{ + member: group.MemberIndex(authorization.TargetIdentifier), + authorization: authorization, + authorizationDigest: digest, + contextWire: fmt.Sprintf("0x%x", digest), + helperSet: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + bus: bus, + stream: stream, + } + installResult := &NativeShareRepairInstallResult{ + Schema: ShareRepairInstallResultSchema, + SessionID: authorization.SessionID, + KeyGroup: authorization.KeyGroup, + TargetIdentifier: authorization.TargetIdentifier, + RecoveryEpoch: authorization.RecoveryEpoch, + AuthorizationDigest: runner.contextWire, + ActiveStoreFingerprint: authorization.NewStoreFingerprint, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + completed := make(chan error, 1) + go func() { + completed <- runner.publishInstalledReceiptAndWaitForAcknowledgements( + ctx, + installResult, + ) + }() + + var receipt shareRepairMessage + select { + case receipt = <-bus.broadcasts: + case <-ctx.Done(): + t.Fatal("target did not publish the installed receipt") + } + if receipt.Type != shareRepairInstalledMessage || receipt.Sender != runner.member { + t.Fatalf("target published the wrong installed receipt: %+v", receipt) + } + receiptDigest := sha256.Sum256(receipt.Payload) + assertShareRepairStillWaiting(t, completed, "any helper acknowledgement") + + stream <- shareRepairMessage{ + Type: shareRepairInstalledAcknowledgementMessage, + Sender: 2, + Recipient: runner.member, + ContextDigest: [32]byte{0xff}, + Payload: append([]byte(nil), receiptDigest[:]...), + } + stream <- shareRepairMessage{ + Type: shareRepairInstalledAcknowledgementMessage, + Sender: 1, + Recipient: runner.member, + ContextDigest: digest, + Payload: append([]byte(nil), receiptDigest[:]...), + } + assertShareRepairStillWaiting(t, completed, "the exact helper set") + + stream <- shareRepairMessage{ + Type: shareRepairInstalledAcknowledgementMessage, + Sender: 2, + Recipient: runner.member, + ContextDigest: digest, + Payload: append([]byte(nil), receiptDigest[:]...), + } + select { + case err := <-completed: + if err != nil { + t.Fatalf("target rejected the exact acknowledgement set: %v", err) + } + case <-ctx.Done(): + t.Fatal("target did not finish after the exact acknowledgement set") + } + select { + case completion := <-bus.broadcasts: + if completion.Type != shareRepairCompletionMessage || + completion.Sender != runner.member || completion.Recipient != 0 || + completion.ContextDigest != digest || + !bytes.Equal(completion.Payload, receiptDigest[:]) { + t.Fatalf("target published the wrong completion: %+v", completion) + } + case <-ctx.Done(): + t.Fatal("target did not publish the share-repair completion") + } + select { + case duplicate := <-bus.broadcasts: + t.Fatalf("target published an extra receipt or completion: %+v", duplicate) + default: + } +} + +func TestShareRepairHelperRetainsAcknowledgementUntilTargetCompletion(t *testing.T) { + authorization, _ := testShareRepairAuthorization(t) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + bus := &manualShareRepairBus{broadcasts: make(chan shareRepairMessage, 2)} + stream := make(chan shareRepairMessage, 4) + runner := &shareRepairRunner{ + member: 1, + authorization: authorization, + authorizationDigest: digest, + contextWire: fmt.Sprintf("0x%x", digest), + bus: bus, + stream: stream, + } + receipt, err := json.Marshal(shareRepairInstalledWire{ + Schema: ShareRepairInstallResultSchema, + SessionID: authorization.SessionID, + KeyGroup: authorization.KeyGroup, + TargetIdentifier: authorization.TargetIdentifier, + RecoveryEpoch: authorization.RecoveryEpoch, + AuthorizationDigest: runner.contextWire, + ActiveStoreFingerprint: authorization.NewStoreFingerprint, + }) + if err != nil { + t.Fatal(err) + } + receiptDigest := sha256.Sum256(receipt) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + completed := make(chan error, 1) + preReceiptCanceled := make(chan struct{}) + var cancelPreReceiptOnce sync.Once + go func() { + completed <- runner.waitForInstalledReceipt(ctx, func() { + cancelPreReceiptOnce.Do(func() { close(preReceiptCanceled) }) + }) + }() + stream <- shareRepairMessage{ + Type: shareRepairInstalledMessage, + Sender: group.MemberIndex(authorization.TargetIdentifier), + ContextDigest: digest, + Payload: receipt, + } + + select { + case acknowledgement := <-bus.broadcasts: + if acknowledgement.Type != shareRepairInstalledAcknowledgementMessage || + acknowledgement.Sender != runner.member || + acknowledgement.Recipient != group.MemberIndex(authorization.TargetIdentifier) || + acknowledgement.ContextDigest != digest || + !bytes.Equal(acknowledgement.Payload, receiptDigest[:]) { + t.Fatalf("helper published the wrong acknowledgement: %+v", acknowledgement) + } + case <-ctx.Done(): + t.Fatal("helper did not acknowledge the installed receipt") + } + select { + case <-preReceiptCanceled: + default: + t.Fatal("helper retained pre-receipt retransmitters after validating the receipt") + } + assertShareRepairStillWaiting(t, completed, "target completion") + + stream <- shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: group.MemberIndex(authorization.TargetIdentifier), + ContextDigest: [32]byte{0xff}, + Payload: append([]byte(nil), receiptDigest[:]...), + } + assertShareRepairStillWaiting(t, completed, "matching target completion") + stream <- shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: group.MemberIndex(authorization.TargetIdentifier), + ContextDigest: digest, + Payload: append([]byte(nil), receiptDigest[:]...), + } + select { + case err := <-completed: + if err != nil { + t.Fatalf("helper rejected the matching target completion: %v", err) + } + case <-ctx.Done(): + t.Fatal("helper did not finish after target completion") + } +} + +func TestShareRepairHelperRelayDeadlineAfterReceiptIsSuccessful(t *testing.T) { + stream := make(chan shareRepairMessage) + runner := &shareRepairRunner{stream: stream} + ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond) + defer cancel() + completed := make(chan error, 1) + go func() { + completed <- runner.waitForShareRepairCompletion(ctx, [sha256.Size]byte{0x01}) + }() + assertShareRepairStillWaiting(t, completed, "the acknowledgement relay deadline") + select { + case err := <-completed: + if err != nil { + t.Fatalf("validated receipt became a failure at the relay deadline: %v", err) + } + case <-time.After(time.Second): + t.Fatal("helper did not finish at the acknowledgement relay deadline") + } + + canceledContext, cancelImmediately := context.WithCancel(context.Background()) + cancelImmediately() + if err := runner.waitForShareRepairCompletion( + canceledContext, + [sha256.Size]byte{0x01}, + ); err == nil || !bytes.Contains([]byte(err.Error()), []byte("context canceled")) { + t.Fatalf("external cancellation was not reported: %v", err) + } +} + +func TestShareRepairRunnerRejectsPendingMessageFlood(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + stream := make(chan shareRepairMessage, shareRepairMaximumPendingMessages+1) + for index := 0; index <= shareRepairMaximumPendingMessages; index++ { + stream <- shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: 2, + Recipient: 1, + ContextDigest: digest, + Payload: []byte{byte(index)}, + } + } + runner := &shareRepairRunner{ + member: 1, + authorizationDigest: digest, + transportRoster: transportRoster, + participants: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + stream: stream, + ephemeralPublic: testShareRepairPublicKey(1), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err = runner.collectAnnouncements(ctx) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("pending-message limit")) { + t.Fatalf("expected pending-message flood rejection, got [%v]", err) + } +} + +func TestShareRepairRunnerRejectsPendingByteFloodBeforeCountLimit(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + stream := make(chan shareRepairMessage, 3) + stream <- shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 2, + ContextDigest: digest, + Payload: bytes.Repeat([]byte{0x41}, shareRepairMaximumPublicPayload), + } + stream <- shareRepairMessage{ + Type: shareRepairInstalledMessage, + Sender: 2, + ContextDigest: digest, + Payload: bytes.Repeat([]byte{0x42}, shareRepairMaximumSecretPayload), + } + stream <- shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: 2, + Recipient: 1, + ContextDigest: digest, + Payload: testShareRepairCiphertext(1, 2, 1), + } + runner := &shareRepairRunner{ + member: 1, + authorizationDigest: digest, + transportRoster: transportRoster, + participants: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + stream: stream, + ephemeralPublic: testShareRepairPublicKey(1), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err = runner.collectAnnouncements(ctx) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("pending-byte limit")) { + t.Fatalf("expected pending-byte flood rejection, got [%v]", err) + } + if len(runner.pending) != 2 || runner.pendingBytes != shareRepairMaximumSessionBytesPerSender { + t.Fatalf( + "pending-byte rejection retained [%d] messages and [%d] bytes", + len(runner.pending), + runner.pendingBytes, + ) + } +} + +func TestShareRepairRunnerPendingQuotaDoesNotBlockOtherSender(t *testing.T) { + runner := &shareRepairRunner{} + for _, message := range []shareRepairMessage{ + { + Type: shareRepairPublicPackageMessage, + Sender: 2, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x41}, shareRepairMaximumPublicPayload), + }, + { + Type: shareRepairInstalledMessage, + Sender: 2, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x42}, shareRepairMaximumSecretPayload), + }, + } { + if err := runner.bufferPendingMessage(message); err != nil { + t.Fatal(err) + } + } + otherSender := shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: 3, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x43}, sha256.Size), + } + if err := runner.bufferPendingMessage(otherSender); err != nil { + t.Fatalf("another sender was blocked by the first sender's quota: %v", err) + } + if err := runner.bufferPendingMessage(shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: 2, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x44}, sha256.Size), + }); err == nil || !bytes.Contains([]byte(err.Error()), []byte("for sender [2]")) { + t.Fatalf("over-quota sender was not rejected independently: %v", err) + } + if len(runner.pending) != 3 || runner.pendingBytesBySender[3] != sha256.Size { + t.Fatal("other sender's pending message was not retained") + } +} + +func TestShareRepairRunnerEnforcesTotalPendingByteBudget(t *testing.T) { + runner := &shareRepairRunner{} + for rawSender := 1; rawSender <= 102; rawSender++ { + sender := group.MemberIndex(rawSender) + for _, message := range []shareRepairMessage{ + { + Type: shareRepairPublicPackageMessage, + Sender: sender, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{byte(sender)}, shareRepairMaximumPublicPayload), + }, + { + Type: shareRepairInstalledMessage, + Sender: sender, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{byte(sender)}, shareRepairMaximumSecretPayload), + }, + } { + if err := runner.bufferPendingMessage(message); err != nil { + t.Fatal(err) + } + } + } + remaining := shareRepairMaximumSessionBytes - runner.pendingBytes + if err := runner.bufferPendingMessage(shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 103, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x67}, remaining), + }); err != nil { + t.Fatal(err) + } + if runner.pendingBytes != shareRepairMaximumSessionBytes { + t.Fatalf("runner retained [%d] total pending bytes", runner.pendingBytes) + } + if err := runner.bufferPendingMessage(shareRepairMessage{ + Type: shareRepairCompletionMessage, + Sender: 104, + ContextDigest: [32]byte{0x44}, + Payload: bytes.Repeat([]byte{0x68}, sha256.Size), + }); err == nil || !bytes.Contains([]byte(err.Error()), []byte("total pending-byte")) { + t.Fatalf("runner did not enforce its total pending-byte budget: %v", err) + } +} + +func TestShareRepairRunnerRetainsMaximumHonestEarlyTraffic(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + stream := make(chan shareRepairMessage, 3) + stream <- shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 2, + ContextDigest: digest, + Payload: bytes.Repeat([]byte{0x41}, shareRepairMaximumPublicPayload), + } + stream <- shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: 2, + Recipient: 1, + ContextDigest: digest, + Payload: testShareRepairCiphertext(1, 2, 1), + } + stream <- shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: 2, + ContextDigest: digest, + EphemeralPublicKey: testShareRepairPublicKey(2), + } + runner := &shareRepairRunner{ + member: 1, + authorizationDigest: digest, + transportRoster: transportRoster, + participants: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + stream: stream, + ephemeralPublic: testShareRepairPublicKey(1), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := runner.collectAnnouncements(ctx); err != nil { + t.Fatalf("maximum honest early traffic was rejected: %v", err) + } + expectedBytes := shareRepairMaximumPublicPayload + shareRepairEncryptedScalarPayloadLength + if len(runner.pending) != 2 || runner.pendingBytes != expectedBytes || + runner.pendingBytesBySender[2] != expectedBytes { + t.Fatalf("honest early traffic accounting is %+v", runner) + } + if _, err := runner.nextMessage(ctx); err != nil { + t.Fatal(err) + } + if runner.pendingBytes != shareRepairEncryptedScalarPayloadLength || len(runner.pending) != 1 { + t.Fatal("pending accounting did not decrement after the first message") + } + if _, err := runner.nextMessage(ctx); err != nil { + t.Fatal(err) + } + if runner.pending != nil || runner.pendingBytes != 0 || runner.pendingBytesBySender != nil { + t.Fatal("pending payload references or accounting survived the final dequeue") + } +} + +func TestShareRepairRunnerFirstWinsRandomizedDeltaEncoding(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + first := testShareRepairCiphertext(1, 2, 1) + alternate := append([]byte(nil), first...) + alternate[len(alternate)-1] ^= 0xff + stream := make(chan shareRepairMessage, 3) + for _, message := range []shareRepairMessage{ + { + Type: shareRepairDeltaMessage, + Sender: 2, + Recipient: 1, + ContextDigest: digest, + Payload: first, + }, + { + Type: shareRepairDeltaMessage, + Sender: 2, + Recipient: 1, + ContextDigest: digest, + Payload: alternate, + }, + { + Type: shareRepairDeltaMessage, + Sender: 1, + Recipient: 1, + ContextDigest: digest, + Payload: testShareRepairCiphertext(1, 1, 1), + }, + } { + stream <- message + } + engine := &captureShareRepairPart2Engine{testShareRepairEngine: &testShareRepairEngine{}} + runner := &shareRepairRunner{ + member: 1, + authorization: authorization, + transportRoster: transportRoster, + authorizationDigest: digest, + contextWire: fmt.Sprintf("0x%x", digest), + helperSet: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + engine: engine, + bus: &manualShareRepairBus{broadcasts: make(chan shareRepairMessage, 8)}, + stream: stream, + } + err = runner.runHelper(context.Background(), func() {}) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("stop after part2 input")) { + t.Fatalf("helper did not reach native Part2: %v", err) + } + if len(engine.deltas) != 2 || engine.deltas[1].SenderIdentifier != 2 || + !bytes.Equal(engine.deltas[1].Payload, first) { + t.Fatalf("helper did not retain exactly the first delta: %+v", engine.deltas) + } +} + +func TestShareRepairRunnerFirstWinsRandomizedSigmaEncoding(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + publicPackage, err := json.Marshal(testShareRepairPublicKeyPackage()) + if err != nil { + t.Fatal(err) + } + first := testShareRepairCiphertext(2, 1, authorization.TargetIdentifier) + alternate := append([]byte(nil), first...) + alternate[len(alternate)-1] ^= 0xff + stream := make(chan shareRepairMessage, 5) + for _, message := range []shareRepairMessage{ + {Type: shareRepairPublicPackageMessage, Sender: 1, ContextDigest: digest, Payload: publicPackage}, + {Type: shareRepairPublicPackageMessage, Sender: 2, ContextDigest: digest, Payload: publicPackage}, + {Type: shareRepairSigmaMessage, Sender: 1, Recipient: 3, ContextDigest: digest, Payload: first}, + {Type: shareRepairSigmaMessage, Sender: 1, Recipient: 3, ContextDigest: digest, Payload: alternate}, + {Type: shareRepairSigmaMessage, Sender: 2, Recipient: 3, ContextDigest: digest, Payload: testShareRepairCiphertext(2, 2, 3)}, + } { + stream <- message + } + engine := &captureShareRepairInstallEngine{testShareRepairEngine: &testShareRepairEngine{}} + runner := &shareRepairRunner{ + member: 3, + authorization: authorization, + transportRoster: transportRoster, + authorizationDigest: digest, + contextWire: fmt.Sprintf("0x%x", digest), + helperSet: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + engine: engine, + stream: stream, + } + _, err = runner.runTarget(context.Background()) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("stop after install input")) { + t.Fatalf("target did not reach native install: %v", err) + } + if len(engine.sigmas) != 2 || engine.sigmas[0].HelperIdentifier != 1 || + !bytes.Equal(engine.sigmas[0].Payload, first) { + t.Fatalf("target did not retain exactly the first sigma: %+v", engine.sigmas) + } +} + +func TestShareRepairRunnerStillRejectsPublicPackageEquivocation(t *testing.T) { + authorization, _ := testShareRepairAuthorization(t) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + first, err := json.Marshal(testShareRepairPublicKeyPackage()) + if err != nil { + t.Fatal(err) + } + changedPackage := testShareRepairPublicKeyPackage() + changedPackage.VerifyingKey = "different-group-verifying-key" + changed, err := json.Marshal(changedPackage) + if err != nil { + t.Fatal(err) + } + stream := make(chan shareRepairMessage, 2) + stream <- shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 1, + ContextDigest: digest, + Payload: first, + } + stream <- shareRepairMessage{ + Type: shareRepairPublicPackageMessage, + Sender: 1, + ContextDigest: digest, + Payload: changed, + } + runner := &shareRepairRunner{ + member: 3, + authorization: authorization, + authorizationDigest: digest, + contextWire: fmt.Sprintf("0x%x", digest), + helperSet: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + stream: stream, + } + _, err = runner.runTarget(context.Background()) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("equivocated its public package")) { + t.Fatalf("target accepted public-package equivocation: %v", err) + } +} + +func TestShareRepairRunnerDropsNonparticipantFramesBeforePendingBuffer(t *testing.T) { + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + stream := make(chan shareRepairMessage, shareRepairMaximumPendingMessages+2) + for index := 0; index <= shareRepairMaximumPendingMessages; index++ { + stream <- shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: 3, + Recipient: 1, + ContextDigest: digest, + Payload: []byte{byte(index >> 8), byte(index)}, + } + } + stream <- shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: 2, + ContextDigest: digest, + EphemeralPublicKey: testShareRepairPublicKey(2), + } + runner := &shareRepairRunner{ + member: 1, + authorizationDigest: digest, + transportRoster: transportRoster, + participants: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + stream: stream, + ephemeralPublic: testShareRepairPublicKey(1), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + publicKeys, err := runner.collectAnnouncements(ctx) + if err != nil { + t.Fatalf("nonparticipant frames aborted rendezvous: %v", err) + } + if len(publicKeys) != 2 || len(runner.pending) != 0 { + t.Fatalf( + "nonparticipant frames reached protocol state: keys [%d], pending [%d]", + len(publicKeys), + len(runner.pending), + ) + } +} diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 407f6a24ea..93dcae0c4b 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -30,9 +30,9 @@ import ( ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v5" + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v6" frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" - frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" + frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v4\x00" frostActivationHandshakeReconciliationTimeout = frostRetainedGroupMaximumReconciliationDuration frostActivationHandshakeRequestTimeout = 5 * time.Second frostActivationHandshakeQuickCheckTimeout = 2 * time.Second @@ -168,6 +168,7 @@ type frostActivationHandshakeState struct { BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` SigningPolicyHash string `json:"signingPolicyHash"` DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + ShareRepairActivationRegistryRoot string `json:"shareRepairActivationRegistryRoot"` CompleteRouterAddress string `json:"completeRouterAddress"` AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` Threshold uint64 `json:"threshold"` @@ -310,6 +311,13 @@ func newFrostActivationHandshakeExporter( "FROST activation handshake durable session store is not bound to the signed manifest", ) } + if !frostShareRepairActivationReady( + manifest.ShareRepairActivationRegistryRoot, + ) { + return nil, fmt.Errorf( + "FROST share-repair activation state is not ready for the signed manifest", + ) + } privateKey, publicKeyDER, err := loadFrostActivationAttestationKey(privateKeyPath) if err != nil { return nil, err @@ -1094,11 +1102,14 @@ func (fahe *frostActivationHandshakeExporter) attest( ) } state := frostActivationHandshakeState{ - ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), - ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), - BitcoinOutboxProtocolID: frostActivationHex32(fahe.manifest.BitcoinOutboxProtocolID), - SigningPolicyHash: frostActivationHex32(fahe.manifest.SigningPolicyHash), - DurableSessionStoreFingerprint: frostActivationHex32(durableSessionStoreFingerprint), + ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), + ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), + BitcoinOutboxProtocolID: frostActivationHex32(fahe.manifest.BitcoinOutboxProtocolID), + SigningPolicyHash: frostActivationHex32(fahe.manifest.SigningPolicyHash), + DurableSessionStoreFingerprint: frostActivationHex32(durableSessionStoreFingerprint), + ShareRepairActivationRegistryRoot: frostActivationHex32( + currentFrostShareRepairActivationRegistryRoot(), + ), CompleteRouterAddress: frostActivationHex20(fahe.manifest.CompleteRouterAddress), AuthorizationRegistryAddress: frostActivationHex20(fahe.manifest.AuthorizationRegistryAddress), Threshold: fahe.manifest.Threshold, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index eab98d2aa8..2b24733066 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -565,6 +565,12 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { t.Fatal(err) } manifest := testFrostActivationRuntimeManifest(sha256.Sum256(publicKeyDER)) + if err := prepareFrostShareRepairActivationForTest( + testFrostDurableSessionStoreIdentity().Fingerprint, + ); err != nil { + t.Fatal(err) + } + t.Cleanup(resetFrostShareRepairActivationForTest) point := frostActivationEthereumPoint{ BlockNumber: 123, BlockHash: frostActivationHex32([32]byte{0x44}), @@ -692,7 +698,8 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "frostWalletGroupInventory", "healthy", "maximumGroupSize", "nonceShareGateEnforced", "interactiveSigningReady", "nativeSignerState", "protocolID", "quarantineFailClosed", "quarantineJournal", "reservationProtocolID", - "retainedGroupInventoryProtocolID", "signingPolicyHash", "threshold", + "retainedGroupInventoryProtocolID", "shareRepairActivationRegistryRoot", + "signingPolicyHash", "threshold", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.FrostWalletGroupInventory, []string{ "complete", "groupSizeViolationCount", "inventoryRoot", "maximumActualGroupSize", @@ -1665,6 +1672,12 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { } publicKeyDER, _ := x509.MarshalPKIXPublicKey(publicKey) manifest := testFrostActivationRuntimeManifest(sha256.Sum256(publicKeyDER)) + if err := prepareFrostShareRepairActivationForTest( + testFrostDurableSessionStoreIdentity().Fingerprint, + ); err != nil { + t.Fatal(err) + } + t.Cleanup(resetFrostShareRepairActivationForTest) point := frostActivationEthereumPoint{ BlockNumber: 7, BlockHash: frostActivationHex32([32]byte{0x11}), @@ -2382,6 +2395,12 @@ func startTestFrostActivationHandshakeExporter( t.Fatal(err) } manifest := testFrostActivationRuntimeManifest(sha256.Sum256(publicKeyDER)) + if err := prepareFrostShareRepairActivationForTest( + testFrostDurableSessionStoreIdentity().Fingerprint, + ); err != nil { + t.Fatal(err) + } + t.Cleanup(resetFrostShareRepairActivationForTest) journal := testFrostRetainedGroupJournal(t, manifest, point) source, ok := journal.source.(*testFrostRetainedGroupHistorySource) if !ok { diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 5487325137..a071cb6b4b 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1465,31 +1465,32 @@ func frostPreSignWriteCommitmentUint64( } type FrostPreSignActivationRuntimeManifest struct { - ManifestHash [32]byte - ActivationAuthorityKeyHash [32]byte - VerifierOperatorFingerprint [32]byte - HandshakeOperatorFingerprint [32]byte - DomainChainID [32]byte - GenesisBlockHash [32]byte - ProfileHash [32]byte - ImplementationSetHash [32]byte - LinkedLibraryDescriptorSetHash [32]byte - EndpointIdentitySetHash [32]byte - Deployments []FrostPreSignDeploymentEvidence - SignerProtocolID [32]byte - ReservationProtocolID [32]byte - BitcoinOutboxProtocolID [32]byte - SigningPolicyHash [32]byte - DurableSessionStoreFingerprint string - CompleteRouterAddress [20]byte - AuthorizationRegistryAddress [20]byte - AttestationSignerKeyHash [32]byte - Threshold uint64 - MaximumGroupSize uint64 - RetainedGroupInventoryProtocolID [32]byte - CanonicalJournal FrostRetainedGroupCanonicalJournalManifest - QuarantineJournal FrostRetainedGroupQuarantineJournalManifest - NativeSignerAnchor FrostNativeSignerAnchorManifest + ManifestHash [32]byte + ActivationAuthorityKeyHash [32]byte + VerifierOperatorFingerprint [32]byte + HandshakeOperatorFingerprint [32]byte + DomainChainID [32]byte + GenesisBlockHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + LinkedLibraryDescriptorSetHash [32]byte + EndpointIdentitySetHash [32]byte + Deployments []FrostPreSignDeploymentEvidence + SignerProtocolID [32]byte + ReservationProtocolID [32]byte + BitcoinOutboxProtocolID [32]byte + SigningPolicyHash [32]byte + DurableSessionStoreFingerprint string + ShareRepairActivationRegistryRoot [32]byte + CompleteRouterAddress [20]byte + AuthorizationRegistryAddress [20]byte + AttestationSignerKeyHash [32]byte + Threshold uint64 + MaximumGroupSize uint64 + RetainedGroupInventoryProtocolID [32]byte + CanonicalJournal FrostRetainedGroupCanonicalJournalManifest + QuarantineJournal FrostRetainedGroupQuarantineJournalManifest + NativeSignerAnchor FrostNativeSignerAnchorManifest // ActivationAuthorityPublicKey is the raw Ed25519 key from the already // verified activation envelope. Its SPKI hash is pinned by // NativeSignerAnchor.Identity.OfflineAuthorityHash. Runtime trust diff --git a/pkg/tbtc/frost_share_repair_activation_default.go b/pkg/tbtc/frost_share_repair_activation_default.go new file mode 100644 index 0000000000..a8c9636cb1 --- /dev/null +++ b/pkg/tbtc/frost_share_repair_activation_default.go @@ -0,0 +1,32 @@ +//go:build !frost_native + +package tbtc + +import ( + "fmt" + "strings" +) + +func installFrostShareRepairActivationRegistry( + registryPath string, + manifest FrostPreSignActivationRuntimeManifest, + _ *frostDurableSessionStoreBinding, +) error { + if manifest.ShareRepairActivationRegistryRoot != [32]byte{} || + strings.TrimSpace(registryPath) != "" { + return fmt.Errorf("share-repair activation requires the frost_native build") + } + return nil +} + +func currentFrostShareRepairActivationRegistryRoot() [32]byte { + return [32]byte{} +} + +func frostShareRepairActivationReady(expectedRoot [32]byte) bool { + return expectedRoot == [32]byte{} +} + +func prepareFrostShareRepairActivationForTest([32]byte) error { return nil } + +func resetFrostShareRepairActivationForTest() {} diff --git a/pkg/tbtc/frost_share_repair_activation_frost_native.go b/pkg/tbtc/frost_share_repair_activation_frost_native.go new file mode 100644 index 0000000000..e12dc3a2ad --- /dev/null +++ b/pkg/tbtc/frost_share_repair_activation_frost_native.go @@ -0,0 +1,93 @@ +//go:build frost_native + +package tbtc + +import ( + "crypto/ed25519" + "fmt" + "strings" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func installFrostShareRepairActivationRegistry( + registryPath string, + manifest FrostPreSignActivationRuntimeManifest, + storeBinding *frostDurableSessionStoreBinding, +) error { + expectedRoot := manifest.ShareRepairActivationRegistryRoot + registryPath = strings.TrimSpace(registryPath) + if storeBinding == nil { + return fmt.Errorf("share-repair activation store binding is absent") + } + storeFingerprint, err := storeBinding.verify() + if err != nil { + return fmt.Errorf("cannot verify share-repair activation store binding: %w", err) + } + inventory, err := frostsigning.ReadNativeTBTCSignerRetainedKeyPackageInventory() + if err != nil { + return fmt.Errorf("cannot read native share-repair activation inventory: %w", err) + } + if inventory == nil || inventory.StoreFingerprint != storeFingerprint { + return fmt.Errorf("native share-repair inventory differs from the bound durable store") + } + if err := frostsigning.ConfigureShareRepairActivationGuard(inventory); err != nil { + return fmt.Errorf("cannot configure repaired-seat activation guard: %w", err) + } + if expectedRoot == [32]byte{} { + if registryPath != "" { + return fmt.Errorf( + "registry path is configured but the signed manifest declares no registry", + ) + } + if !frostsigning.ShareRepairActivationReady(expectedRoot) { + return fmt.Errorf( + "native signer contains repaired seats pending an authority-signed activation registry", + ) + } + return nil + } + if registryPath == "" { + return fmt.Errorf( + "signed manifest declares a share-repair registry but its secure file is absent", + ) + } + payload, err := readSecureFrostActivationFile(registryPath, 8*1024*1024) + if err != nil { + return fmt.Errorf("cannot read secure share-repair registry: %w", err) + } + if err := frostsigning.InstallShareRepairActivationRegistry( + payload, + ed25519.PublicKey(manifest.ActivationAuthorityPublicKey[:]), + expectedRoot, + storeFingerprint, + ); err != nil { + return err + } + if !frostsigning.ShareRepairActivationReady(expectedRoot) { + return fmt.Errorf("share-repair activation registry did not satisfy native recovery facts") + } + return nil +} + +func currentFrostShareRepairActivationRegistryRoot() [32]byte { + return frostsigning.CurrentShareRepairActivationRegistryRoot() +} + +func frostShareRepairActivationReady(expectedRoot [32]byte) bool { + return frostsigning.ShareRepairActivationReady(expectedRoot) +} + +func prepareFrostShareRepairActivationForTest(storeFingerprint [32]byte) error { + frostsigning.ResetShareRepairActivationStateForTest() + return frostsigning.ConfigureShareRepairActivationGuard( + &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Schema: frostsigning.NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: storeFingerprint, + }, + ) +} + +func resetFrostShareRepairActivationForTest() { + frostsigning.ResetShareRepairActivationStateForTest() +} diff --git a/pkg/tbtc/frost_share_repair_maintenance_default.go b/pkg/tbtc/frost_share_repair_maintenance_default.go new file mode 100644 index 0000000000..2509890dd6 --- /dev/null +++ b/pkg/tbtc/frost_share_repair_maintenance_default.go @@ -0,0 +1,25 @@ +//go:build !frost_native + +package tbtc + +import ( + "context" + "fmt" + "time" + + "github.com/keep-network/keep-core/pkg/chain" +) + +func runFrostShareRepairMaintenance( + _ context.Context, + authorizationPath string, + _ time.Duration, + _ FrostPreSignActivationRuntimeManifest, + _ *node, + _ chain.Address, +) (bool, error) { + if authorizationPath == "" { + return false, nil + } + return true, fmt.Errorf("share-repair maintenance requires the frost_native build") +} diff --git a/pkg/tbtc/frost_share_repair_maintenance_frost_native.go b/pkg/tbtc/frost_share_repair_maintenance_frost_native.go new file mode 100644 index 0000000000..5417a0aedb --- /dev/null +++ b/pkg/tbtc/frost_share_repair_maintenance_frost_native.go @@ -0,0 +1,169 @@ +//go:build frost_native + +package tbtc + +import ( + "context" + "crypto/ed25519" + "encoding/hex" + "fmt" + "slices" + "time" + + "github.com/keep-network/keep-core/pkg/chain" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const defaultFrostShareRepairMaintenanceTimeout = 10 * time.Minute + +func runFrostShareRepairMaintenance( + ctx context.Context, + authorizationPath string, + timeout time.Duration, + manifest FrostPreSignActivationRuntimeManifest, + node *node, + operatorAddress chain.Address, +) (bool, error) { + if authorizationPath == "" { + return false, nil + } + if ctx == nil || node == nil || node.walletRegistry == nil || + node.netProvider == nil || manifest.ActivationAuthorityPublicKey == [32]byte{} { + return true, fmt.Errorf("share-repair maintenance dependencies are incomplete") + } + if timeout == 0 { + timeout = defaultFrostShareRepairMaintenanceTimeout + } + if timeout < 10*time.Second || timeout > time.Hour { + return true, fmt.Errorf("share-repair maintenance timeout must be from 10s through 1h") + } + + payload, err := readSecureFrostActivationFile(authorizationPath, 256*1024) + if err != nil { + return true, fmt.Errorf("cannot read secure share-repair recovery bundle: %w", err) + } + bundle, err := frostsigning.DecodeShareRepairRecoveryBundle(payload) + if err != nil { + return true, err + } + authorization := &bundle.Authorization + digest, err := frostsigning.ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return true, err + } + walletIDBytes, err := hex.DecodeString(authorization.WalletID[2:]) + if err != nil || len(walletIDBytes) != 32 { + return true, fmt.Errorf("share-repair authorization wallet ID is invalid") + } + var walletID [32]byte + copy(walletID[:], walletIDBytes) + if err := validateFrostKeyGroupForWallet(authorization.KeyGroup, walletID); err != nil { + return true, fmt.Errorf("share-repair authorization key group is invalid: %w", err) + } + + wallet, found := node.walletRegistry.getWalletByID(walletID) + if !found { + return true, fmt.Errorf("authorized share-repair wallet is not active in the local registry") + } + if len(wallet.signingGroupOperators) != int(authorization.ParticipantCount) { + return true, fmt.Errorf("authorized participant count differs from the local wallet") + } + signers := node.walletRegistry.getSigners(wallet.publicKey) + if len(signers) == 0 { + return true, fmt.Errorf("authorized share-repair wallet has no local signers") + } + participants := make(map[group.MemberIndex]struct{}, len(authorization.HelperIdentifiers)+1) + for _, helper := range authorization.HelperIdentifiers { + participants[group.MemberIndex(helper)] = struct{}{} + } + participants[group.MemberIndex(authorization.TargetIdentifier)] = struct{}{} + localMemberIndexes := make([]group.MemberIndex, 0, len(signers)) + seenLocal := make(map[group.MemberIndex]struct{}, len(signers)) + for _, signer := range signers { + material, ok := nativeSignerMaterialFromSigner(signer) + if !ok { + return true, fmt.Errorf("share-repair wallet contains non-native signer material") + } + keyGroup, err := frostsigning.KeyGroupIDFromSignerMaterial(material) + if err != nil || keyGroup != authorization.KeyGroup { + return true, fmt.Errorf("local signer material differs from the authorized key group") + } + member := signer.signingGroupMemberIndex + if member == 0 || int(member) > len(wallet.signingGroupOperators) || + wallet.signingGroupOperators[int(member)-1] != operatorAddress { + return true, fmt.Errorf("local share-repair seat is not owned by this operator") + } + if _, duplicate := seenLocal[member]; duplicate { + return true, fmt.Errorf("local share-repair wallet contains a duplicate seat") + } + seenLocal[member] = struct{}{} + if _, participating := participants[member]; participating { + localMemberIndexes = append(localMemberIndexes, member) + } + } + if len(localMemberIndexes) == 0 { + return true, fmt.Errorf("this operator controls no seat named by the repair authorization") + } + slices.Sort(localMemberIndexes) + + membershipValidator := group.NewMembershipValidator( + logger, + wallet.signingGroupOperators, + node.chain.Signing(), + ) + rosterDigest, err := frostsigning.ComputeShareRepairTransportRosterDigest( + &bundle.TransportRoster, + authorization, + ) + if err != nil { + return true, err + } + channelName := fmt.Sprintf( + "%s-frost-share-repair-%x-%x", + ProtocolName, + digest, + rosterDigest, + ) + channel, err := node.netProvider.BroadcastChannelFor(channelName) + if err != nil { + return true, fmt.Errorf("cannot open share-repair broadcast channel: %w", err) + } + if err := channel.SetFilter(membershipValidator.IsInGroup); err != nil { + return true, fmt.Errorf("cannot authenticate share-repair broadcast channel: %w", err) + } + engine, ok := frostsigning.CurrentNativeTBTCSignerEngine().(frostsigning.NativeTBTCSignerShareRepairEngine) + if !ok || engine == nil { + return true, fmt.Errorf("registered native signer does not support share repair") + } + + repairContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + result, err := frostsigning.RunShareRepair( + repairContext, + logger, + channel, + membershipValidator, + engine, + authorization, + &bundle.TransportRoster, + ed25519.PublicKey(manifest.ActivationAuthorityPublicKey[:]), + localMemberIndexes, + ) + if err != nil { + return true, err + } + if result == nil { + logger.Infof( + "FROST share-repair helper maintenance completed for authorization [0x%x]", + digest, + ) + } else { + logger.Infof( + "FROST share-repair target seat [%d] durably installed and anchor-acknowledged authorization [0x%x]; production remains disabled pending old-store tombstone and signed activation", + result.TargetIdentifier, + digest, + ) + } + return true, nil +} diff --git a/pkg/tbtc/frost_share_repair_transport_preflight_default.go b/pkg/tbtc/frost_share_repair_transport_preflight_default.go new file mode 100644 index 0000000000..5d4349aefc --- /dev/null +++ b/pkg/tbtc/frost_share_repair_transport_preflight_default.go @@ -0,0 +1,22 @@ +//go:build !frost_native + +package tbtc + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/chain" +) + +func runFrostShareRepairTransportPreflight( + authorizationPath string, + outputPath string, + _ FrostPreSignActivationRuntimeManifest, + _ *node, + _ chain.Address, +) (bool, error) { + if authorizationPath == "" && outputPath == "" { + return false, nil + } + return true, fmt.Errorf("share-repair transport preflight requires the frost_native build") +} diff --git a/pkg/tbtc/frost_share_repair_transport_preflight_frost_native.go b/pkg/tbtc/frost_share_repair_transport_preflight_frost_native.go new file mode 100644 index 0000000000..f5193d30c1 --- /dev/null +++ b/pkg/tbtc/frost_share_repair_transport_preflight_frost_native.go @@ -0,0 +1,160 @@ +//go:build frost_native + +package tbtc + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + + "github.com/keep-network/keep-core/pkg/chain" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const frostShareRepairTransportPreflightMaximumBytes int64 = 256 * 1024 + +// runFrostShareRepairTransportPreflight is the non-networked first half of the +// recovery ceremony. It runs only after manifest verification, state-anchor +// reconciliation, and installation of the native output barrier. The emitted +// artifact is public but immutable/owner-only so the offline authority can +// authenticate its operator source and bind the exact seat, store, and native +// transport key in the signed roster. +func runFrostShareRepairTransportPreflight( + authorizationPath string, + outputPath string, + manifest FrostPreSignActivationRuntimeManifest, + node *node, + operatorAddress chain.Address, +) (bool, error) { + if authorizationPath == "" && outputPath == "" { + return false, nil + } + if authorizationPath == "" || outputPath == "" { + return true, fmt.Errorf( + "share-repair transport preflight input and output paths must be configured together", + ) + } + if node == nil || node.walletRegistry == nil || + manifest.ActivationAuthorityPublicKey == [32]byte{} { + return true, fmt.Errorf("share-repair transport preflight dependencies are incomplete") + } + + payload, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + authorizationPath, + frostShareRepairTransportPreflightMaximumBytes, + ) + if err != nil { + return true, fmt.Errorf("cannot read share-repair preflight authorization: %w", err) + } + authorization, err := frostsigning.DecodeShareRepairAuthorization(payload) + if err != nil { + return true, err + } + digest, err := frostsigning.ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return true, err + } + walletIDBytes, err := hex.DecodeString(authorization.WalletID[2:]) + if err != nil || len(walletIDBytes) != 32 { + return true, fmt.Errorf("share-repair preflight wallet ID is invalid") + } + var walletID [32]byte + copy(walletID[:], walletIDBytes) + if err := validateFrostKeyGroupForWallet(authorization.KeyGroup, walletID); err != nil { + return true, fmt.Errorf("share-repair preflight key group is invalid: %w", err) + } + + wallet, found := node.walletRegistry.getWalletByID(walletID) + if !found { + return true, fmt.Errorf("authorized share-repair wallet is not active in the local registry") + } + if len(wallet.signingGroupOperators) != int(authorization.ParticipantCount) { + return true, fmt.Errorf("authorized participant count differs from the local wallet") + } + signers := node.walletRegistry.getSigners(wallet.publicKey) + if len(signers) == 0 { + return true, fmt.Errorf("authorized share-repair wallet has no local signers") + } + participants := make(map[group.MemberIndex]struct{}, len(authorization.HelperIdentifiers)+1) + for _, helper := range authorization.HelperIdentifiers { + participants[group.MemberIndex(helper)] = struct{}{} + } + participants[group.MemberIndex(authorization.TargetIdentifier)] = struct{}{} + localMemberIndexes := make([]group.MemberIndex, 0, len(signers)) + seenLocal := make(map[group.MemberIndex]struct{}, len(signers)) + for _, signer := range signers { + material, ok := nativeSignerMaterialFromSigner(signer) + if !ok { + return true, fmt.Errorf("share-repair wallet contains non-native signer material") + } + keyGroup, err := frostsigning.KeyGroupIDFromSignerMaterial(material) + if err != nil || keyGroup != authorization.KeyGroup { + return true, fmt.Errorf("local signer material differs from the authorized key group") + } + member := signer.signingGroupMemberIndex + if member == 0 || int(member) > len(wallet.signingGroupOperators) || + wallet.signingGroupOperators[int(member)-1] != operatorAddress { + return true, fmt.Errorf("local share-repair seat is not owned by this operator") + } + if _, duplicate := seenLocal[member]; duplicate { + return true, fmt.Errorf("local share-repair wallet contains a duplicate seat") + } + seenLocal[member] = struct{}{} + if _, participating := participants[member]; participating { + localMemberIndexes = append(localMemberIndexes, member) + } + } + if len(localMemberIndexes) == 0 { + return true, fmt.Errorf("this operator controls no seat named by the repair authorization") + } + slices.Sort(localMemberIndexes) + + engine, ok := frostsigning.CurrentNativeTBTCSignerEngine().(frostsigning.NativeTBTCSignerShareRepairEngine) + if !ok || engine == nil { + return true, fmt.Errorf("registered native signer does not support share repair") + } + entries := make( + []frostsigning.ShareRepairTransportPublicKey, + 0, + len(localMemberIndexes), + ) + for _, member := range localMemberIndexes { + entry, err := frostsigning.PrepareShareRepairTransportRosterEntry( + engine, + authorization, + ed25519.PublicKey(manifest.ActivationAuthorityPublicKey[:]), + uint16(member), + ) + if err != nil { + return true, fmt.Errorf( + "prepare native share-repair transport entry for seat [%d]: %w", + member, + err, + ) + } + entries = append(entries, *entry) + } + artifact, err := json.Marshal(frostsigning.ShareRepairTransportPreflight{ + Schema: frostsigning.ShareRepairTransportPreflightSchema, + AuthorizationDigest: "0x" + hex.EncodeToString(digest[:]), + ParticipantPublicKeys: entries, + }) + if err != nil { + return true, fmt.Errorf("encode share-repair transport preflight: %w", err) + } + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + outputPath, + artifact, + ); err != nil { + return true, fmt.Errorf("publish share-repair transport preflight: %w", err) + } + logger.Infof( + "published FROST share-repair transport preflight for authorization [0x%x] with [%d] local seat(s); remove preflight paths before restart", + digest, + len(entries), + ) + return true, nil +} diff --git a/pkg/tbtc/frost_share_repair_transport_preflight_frost_native_test.go b/pkg/tbtc/frost_share_repair_transport_preflight_frost_native_test.go new file mode 100644 index 0000000000..12e87e4feb --- /dev/null +++ b/pkg/tbtc/frost_share_repair_transport_preflight_frost_native_test.go @@ -0,0 +1,451 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/chain" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestRunFrostShareRepairTransportPreflightDisabled(t *testing.T) { + requested, err := runFrostShareRepairTransportPreflight( + "", + "", + FrostPreSignActivationRuntimeManifest{}, + nil, + "", + ) + if err != nil { + t.Fatalf("disabled preflight returned an error: [%v]", err) + } + if requested { + t.Fatal("disabled preflight was reported as requested") + } +} + +func TestRunFrostShareRepairTransportPreflightRequiresPairedPaths(t *testing.T) { + testCases := []struct { + name string + authorizationPath string + outputPath string + }{ + { + name: "input only", + authorizationPath: "/tmp/share-repair-authorization.json", + }, + { + name: "output only", + outputPath: "/tmp/share-repair-transport-preflight.json", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + requested, err := runFrostShareRepairTransportPreflight( + testCase.authorizationPath, + testCase.outputPath, + FrostPreSignActivationRuntimeManifest{}, + nil, + "", + ) + if !requested { + t.Fatal("partially configured preflight was not reported as requested") + } + if err == nil || !strings.Contains(err.Error(), "must be configured together") { + t.Fatalf("unexpected paired-path validation result: [%v]", err) + } + }) + } +} + +func TestRunFrostShareRepairTransportPreflightRejectsMalformedArtifact(t *testing.T) { + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + authorizationPath := filepath.Join(directory, "authorization.json") + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + authorizationPath, + []byte("{"), + ); err != nil { + t.Fatalf("write malformed authorization: [%v]", err) + } + + requested, err := runFrostShareRepairTransportPreflight( + authorizationPath, + filepath.Join(directory, "preflight.json"), + FrostPreSignActivationRuntimeManifest{ + ActivationAuthorityPublicKey: [32]byte{1}, + }, + &node{walletRegistry: &walletRegistry{}}, + "operator-1", + ) + if !requested { + t.Fatal("malformed preflight was not reported as requested") + } + if err == nil || !strings.Contains(err.Error(), "cannot decode share-repair authorization") { + t.Fatalf("unexpected malformed-artifact validation result: [%v]", err) + } +} + +func TestRunFrostShareRepairTransportPreflightRejectsInvalidAuthorizationSignature( + t *testing.T, +) { + fixture := newShareRepairTransportPreflightTestFixture(t) + fixture.authorization.SignatureHex = "0x" + strings.Repeat("00", ed25519.SignatureSize) + authorizationPath, outputPath := fixture.writeAuthorization(t) + registerShareRepairTransportPreflightTestEngine(t, fixture.engine) + + requested, err := runFrostShareRepairTransportPreflight( + authorizationPath, + outputPath, + fixture.manifest, + fixture.node, + fixture.operator, + ) + if !requested { + t.Fatal("invalidly signed preflight was not reported as requested") + } + if err == nil || !strings.Contains(err.Error(), "authorization signature is invalid") { + t.Fatalf("unexpected signature validation result: [%v]", err) + } + if fixture.engine.beginCalls != 0 || fixture.engine.finishCalls != 0 { + t.Fatalf( + "native session started before signature validation: begin=[%d] finish=[%d]", + fixture.engine.beginCalls, + fixture.engine.finishCalls, + ) + } +} + +func TestRunFrostShareRepairTransportPreflightPublishesOwnerOnlyNoReplaceArtifact( + t *testing.T, +) { + fixture := newShareRepairTransportPreflightTestFixture(t) + authorizationPath, outputPath := fixture.writeAuthorization(t) + registerShareRepairTransportPreflightTestEngine(t, fixture.engine) + + requested, err := runFrostShareRepairTransportPreflight( + authorizationPath, + outputPath, + fixture.manifest, + fixture.node, + fixture.operator, + ) + if err != nil { + t.Fatalf("run transport preflight: [%v]", err) + } + if !requested { + t.Fatal("configured preflight was not reported as requested") + } + if fixture.engine.beginCalls != 1 || fixture.engine.finishCalls != 1 { + t.Fatalf( + "unexpected native session lifecycle: begin=[%d] finish=[%d]", + fixture.engine.beginCalls, + fixture.engine.finishCalls, + ) + } + + info, err := os.Lstat(outputPath) + if err != nil { + t.Fatalf("stat preflight output: [%v]", err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || + info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("preflight output is not an owner-only regular file: [%v]", info.Mode()) + } + + artifactBytes, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + outputPath, + frostShareRepairTransportPreflightMaximumBytes, + ) + if err != nil { + t.Fatalf("read preflight output: [%v]", err) + } + artifact := &frostsigning.ShareRepairTransportPreflight{} + decoder := json.NewDecoder(bytes.NewReader(artifactBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(artifact); err != nil { + t.Fatalf("decode preflight output: [%v]", err) + } + digest, err := frostsigning.ComputeShareRepairAuthorizationDigest( + fixture.authorization, + ) + if err != nil { + t.Fatal(err) + } + if artifact.Schema != frostsigning.ShareRepairTransportPreflightSchema || + artifact.AuthorizationDigest != "0x"+hex.EncodeToString(digest[:]) || + len(artifact.ParticipantPublicKeys) != 1 { + t.Fatalf("unexpected preflight artifact: [%+v]", artifact) + } + entry := artifact.ParticipantPublicKeys[0] + if entry.ParticipantIdentifier != 1 || + entry.StoreFingerprint != fixture.engine.storeFingerprint || + entry.PublicKeyHex != hex.EncodeToString(fixture.engine.publicKey) { + t.Fatalf("unexpected preflight transport entry: [%+v]", entry) + } + + original := append([]byte(nil), artifactBytes...) + requested, err = runFrostShareRepairTransportPreflight( + authorizationPath, + outputPath, + fixture.manifest, + fixture.node, + fixture.operator, + ) + if !requested { + t.Fatal("repeated configured preflight was not reported as requested") + } + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("existing output was replaced or returned an unexpected error: [%v]", err) + } + after, readErr := ReadFrostNativeSignerAnchorProvisioningArtifact( + outputPath, + frostShareRepairTransportPreflightMaximumBytes, + ) + if readErr != nil { + t.Fatalf("read preflight output after rejected replacement: [%v]", readErr) + } + if !bytes.Equal(after, original) { + t.Fatal("rejected replacement changed the existing preflight artifact") + } +} + +type shareRepairTransportPreflightTestFixture struct { + authorization *frostsigning.ShareRepairAuthorization + manifest FrostPreSignActivationRuntimeManifest + node *node + operator chain.Address + engine *shareRepairTransportPreflightTestEngine + directory string +} + +func newShareRepairTransportPreflightTestFixture( + t *testing.T, +) *shareRepairTransportPreflightTestFixture { + t.Helper() + _, keyGroup := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x09}, 32)) + keyGroupHex := hex.EncodeToString(keyGroup.SerializeCompressed()) + walletIDBytes := keyGroup.X().FillBytes(make([]byte, 32)) + var walletID [32]byte + copy(walletID[:], walletIDBytes) + + authority := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x42}, ed25519.SeedSize), + ) + now := uint64(time.Now().Unix()) + authorization := &frostsigning.ShareRepairAuthorization{ + Schema: frostsigning.ShareRepairAuthorizationSchema, + SessionID: "preflight-test-repair", + WalletID: "0x" + hex.EncodeToString(walletID[:]), + KeyGroup: keyGroupHex, + PublicKeyPackageCommitment: preflightTestHex32(0x31), + TargetIdentifier: 3, + HelperIdentifiers: []uint16{1, 2}, + Threshold: 2, + ParticipantCount: 3, + OldStoreFingerprint: preflightTestHex32(0x51), + NewStoreFingerprint: preflightTestHex32(0x52), + RecoveryEpoch: 1, + IssuedAtUnix: now - 60, + NotBeforeUnix: now + 600, + ExpiresAtUnix: now + 3600, + Nonce: preflightTestHex32(0x61), + } + digest, err := frostsigning.ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatalf("compute share-repair authorization digest: [%v]", err) + } + authorization.SignatureHex = "0x" + hex.EncodeToString( + ed25519.Sign(authority, digest[:]), + ) + + materialPayload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: keyGroupHex, + TaprootOutputKey: hex.EncodeToString(walletID[:]), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + }) + if err != nil { + t.Fatal(err) + } + operator := chain.Address("operator-1") + wallet := wallet{ + publicKey: keyGroup.ToECDSA(), + signingGroupOperators: []chain.Address{ + operator, + "operator-2", + "operator-3", + }, + } + localSigner := &signer{ + wallet: wallet, + signingGroupMemberIndex: group.MemberIndex(1), + signerMaterial: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: materialPayload, + }, + } + registry := &walletRegistry{ + walletCache: map[string]*walletCacheValue{ + getWalletStorageKey(wallet.publicKey): { + walletID: walletID, + signers: []*signer{localSigner}, + }, + }, + } + + _, transportPublicKey := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x23}, 32)) + engine := &shareRepairTransportPreflightTestEngine{ + storeFingerprint: preflightTestHex32(0x71), + publicKey: transportPublicKey.SerializeCompressed(), + } + manifest := FrostPreSignActivationRuntimeManifest{} + copy(manifest.ActivationAuthorityPublicKey[:], authority.Public().(ed25519.PublicKey)) + + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + + return &shareRepairTransportPreflightTestFixture{ + authorization: authorization, + manifest: manifest, + node: &node{walletRegistry: registry}, + operator: operator, + engine: engine, + directory: directory, + } +} + +func (fixture *shareRepairTransportPreflightTestFixture) writeAuthorization( + t *testing.T, +) (string, string) { + t.Helper() + payload, err := json.Marshal(fixture.authorization) + if err != nil { + t.Fatal(err) + } + authorizationPath := filepath.Join(fixture.directory, "authorization.json") + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + authorizationPath, + payload, + ); err != nil { + t.Fatalf("write authorization: [%v]", err) + } + return authorizationPath, filepath.Join(fixture.directory, "preflight.json") +} + +func preflightTestHex32(value byte) string { + return "0x" + hex.EncodeToString(bytes.Repeat([]byte{value}, 32)) +} + +type shareRepairTransportPreflightTestEngine struct { + storeFingerprint string + publicKey []byte + beginCalls int + finishCalls int +} + +func (*shareRepairTransportPreflightTestEngine) BuildTaprootTx( + string, + []frostsigning.NativeTBTCSignerTxInput, + []frostsigning.NativeTBTCSignerTxOutput, + *string, +) (*frostsigning.NativeTBTCSignerTxResult, error) { + return nil, errors.New("not implemented") +} + +func (*shareRepairTransportPreflightTestEngine) VerifySignatureShare( + string, + []byte, + []byte, + uint16, + *[32]byte, +) (frostsigning.NativeShareVerificationVerdict, error) { + return frostsigning.NativeShareVerdictIndeterminate, errors.New("not implemented") +} + +func (engine *shareRepairTransportPreflightTestEngine) BeginShareRepairSession( + authorization *frostsigning.ShareRepairAuthorization, + participantIdentifier uint16, +) (*frostsigning.NativeShareRepairSession, error) { + engine.beginCalls++ + digest, err := frostsigning.ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + return nil, err + } + return &frostsigning.NativeShareRepairSession{ + ContextDigest: "0x" + hex.EncodeToString(digest[:]), + ParticipantIdentifier: participantIdentifier, + StoreFingerprint: engine.storeFingerprint, + TransportPublicKey: append([]byte(nil), engine.publicKey...), + }, nil +} + +func (engine *shareRepairTransportPreflightTestEngine) FinishShareRepairSession( + *frostsigning.ShareRepairAuthorization, + uint16, +) error { + engine.finishCalls++ + return nil +} + +func (*shareRepairTransportPreflightTestEngine) ShareRepairPart1( + *frostsigning.ShareRepairAuthorization, + uint16, + *frostsigning.ShareRepairTransportRoster, +) (*frostsigning.NativeShareRepairPart1Result, error) { + return nil, errors.New("not implemented") +} + +func (*shareRepairTransportPreflightTestEngine) ShareRepairPart2( + *frostsigning.ShareRepairAuthorization, + uint16, + []*frostsigning.NativeShareRepairEncryptedDelta, + *frostsigning.ShareRepairTransportRoster, +) (*frostsigning.NativeShareRepairPart2Result, error) { + return nil, errors.New("not implemented") +} + +func (*shareRepairTransportPreflightTestEngine) InstallRepairedShare( + *frostsigning.ShareRepairAuthorization, + *frostsigning.NativeFROSTPublicKeyPackage, + []*frostsigning.NativeShareRepairEncryptedSigma, + *frostsigning.ShareRepairTransportRoster, +) (*frostsigning.NativeShareRepairInstallResult, error) { + return nil, errors.New("not implemented") +} + +func registerShareRepairTransportPreflightTestEngine( + t *testing.T, + engine frostsigning.NativeTBTCSignerEngine, +) { + t.Helper() + previous := frostsigning.CurrentNativeTBTCSignerEngine() + frostsigning.UnregisterNativeTBTCSignerEngine() + if err := frostsigning.RegisterNativeTBTCSignerEngine(engine); err != nil { + t.Fatalf("register native signer engine: [%v]", err) + } + t.Cleanup(func() { + frostsigning.UnregisterNativeTBTCSignerEngine() + if previous != nil { + if err := frostsigning.RegisterNativeTBTCSignerEngine(previous); err != nil { + t.Errorf("restore native signer engine: [%v]", err) + } + } + }) +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index d9393b96bb..b621f1d54d 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -214,6 +214,26 @@ func newNode( if err != nil { return nil, fmt.Errorf("cannot get node's operator address: [%v]", err) } + preflightInputConfigured := + config.FrostShareRepairTransportPreflightAuthorizationPath != "" + preflightOutputConfigured := + config.FrostShareRepairTransportPreflightOutputPath != "" + if preflightInputConfigured != preflightOutputConfigured { + return nil, fmt.Errorf( + "FROST share-repair transport preflight input and output paths must be configured together", + ) + } + if preflightInputConfigured && config.FrostShareRepairAuthorizationPath != "" { + return nil, fmt.Errorf( + "FROST share-repair transport preflight and recovery bundle modes are mutually exclusive", + ) + } + if (preflightInputConfigured || config.FrostShareRepairAuthorizationPath != "") && + !config.EnableFrostPreSignAuthorization { + return nil, fmt.Errorf( + "FROST share-repair maintenance requires the production activation and state-anchor barrier", + ) + } if shouldRunLegacyECDSA(config) { // TODO: This chicken and egg problem should be solved when @@ -706,6 +726,51 @@ func newNode( err, ) } + preflightRequested, err := runFrostShareRepairTransportPreflight( + config.FrostShareRepairTransportPreflightAuthorizationPath, + config.FrostShareRepairTransportPreflightOutputPath, + runtimeManifest, + node, + operatorAddress, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "FROST share-repair transport preflight failed: [%w]", + err, + ) + } + if preflightRequested { + _ = outbox.close() + return nil, ErrFrostShareRepairMaintenanceComplete + } + maintenanceRequested, err := runFrostShareRepairMaintenance( + context.Background(), + config.FrostShareRepairAuthorizationPath, + config.FrostShareRepairMaintenanceTimeout, + runtimeManifest, + node, + operatorAddress, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf("FROST share-repair maintenance failed: [%w]", err) + } + if maintenanceRequested { + _ = outbox.close() + return nil, ErrFrostShareRepairMaintenanceComplete + } + if err := installFrostShareRepairActivationRegistry( + config.FrostShareRepairActivationRegistryPath, + runtimeManifest, + storeBinding, + ); err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot install FROST share-repair activation registry: [%w]", + err, + ) + } inventoryBinding, err := newFrostNativeSignerInventoryBinding( storeBinding, anchorBinding, @@ -788,7 +853,12 @@ func newNode( } journal.orphanedDKGReconciler = orphanedDKGReconciler readiness, err := newFrostProductionSignerReadiness( - currentFrostInteractiveSigningReadiness, + func() bool { + return currentFrostInteractiveSigningReadiness() && + frostShareRepairActivationReady( + runtimeManifest.ShareRepairActivationRegistryRoot, + ) + }, journal, inventoryBinding, ) diff --git a/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry.go b/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry.go index 205107d437..849d3d4211 100644 --- a/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry.go +++ b/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry.go @@ -101,8 +101,19 @@ func newRoastTransitionExchangeForRequest( if template.Channel == nil || template.MembershipValidator == nil { return nil } - bus, err := signing.NewBroadcastChannelRunnerBus( - ctx, logger, template.Channel, template.MembershipValidator, + if err := signing.ValidateLocalShareRepairSeatActivation( + keyGroupID, + template.MemberIndex, + ); err != nil { + logger.Warnf("roast transition: recovered-seat activation: [%v]", err) + return nil + } + bus, err := signing.NewActivationBoundBroadcastChannelRunnerBus( + ctx, + logger, + template.Channel, + template.MembershipValidator, + keyGroupID, ) if err != nil { logger.Warnf("roast transition: build transport bus: [%v]", err) diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index c7e4457c21..90a8422504 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -25,6 +25,14 @@ import ( var logger = log.Logger("keep-tbtc") +// ErrFrostShareRepairMaintenanceComplete is returned after the explicitly +// configured one-shot recovery protocol finishes. The start command treats it +// as a clean maintenance exit so operators must remove the authorization path +// and deliberately restart into either pending-cutover or production mode. +var ErrFrostShareRepairMaintenanceComplete = errors.New( + "FROST share-repair one-shot operation completed; remove the configured preparation or recovery path and restart", +) + // ProtocolName denotes the name of the protocol defined by this package. const ProtocolName = "tbtc" @@ -135,6 +143,30 @@ type Config struct { // commitments are verified at a finalized Ethereum block during startup. // Production chain adapters reject activation without this file. FrostPreSignActivationManifestPath string + // FrostShareRepairActivationRegistryPath points at the owner-only public + // cutover registry pinned by the signed activation manifest. It is required + // exactly when that manifest declares a non-zero share-repair registry root. + FrostShareRepairActivationRegistryPath string + // FrostShareRepairTransportPreflightAuthorizationPath selects the first, + // non-networked recovery ceremony step. It contains the signed base + // authorization. Each locally owned named seat asks Rust for its + // authorization/store-bound public transport key and exits after publishing + // an immutable owner-only preflight artifact. + FrostShareRepairTransportPreflightAuthorizationPath string + // FrostShareRepairTransportPreflightOutputPath is the canonical absolute, + // initially absent path for the immutable public preflight artifact. It is + // required exactly with FrostShareRepairTransportPreflightAuthorizationPath. + FrostShareRepairTransportPreflightOutputPath string + // FrostShareRepairAuthorizationPath selects one-shot disaster-recovery mode + // using an owner-only recovery bundle containing both the signed base + // authorization and the separately signed exact native-key/store roster. + // Every helper and the target exits cleanly after the bounded protocol; this + // path must be removed before restart. Normal startup never runs recovery. + FrostShareRepairAuthorizationPath string + // FrostShareRepairMaintenanceTimeout bounds the one-shot authenticated + // network protocol. Zero selects ten minutes; non-zero values must be from + // ten seconds through one hour. + FrostShareRepairMaintenanceTimeout time.Duration // FrostPreSignActivationEnvelopeSignerKeyHash is the lowercase 0x-prefixed // SHA-256 digest of the DER SubjectPublicKeyInfo for the Ed25519 activation // authority. It authenticates the signed production manifest independently @@ -285,7 +317,7 @@ func Initialize( config, ) if err != nil { - return fmt.Errorf("cannot set up TBTC node: [%v]", err) + return fmt.Errorf("cannot set up TBTC node: [%w]", err) } node.frostGroupParameters = frostGroupParameters