From bb3378100685f52e69560a5072bc1a662d1de3bb Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 13:51:34 -0400 Subject: [PATCH 1/7] feat(tbtc): add guarded FROST share-repair recovery --- ci/frost-signer-pin.env | 2 +- cmd/start.go | 5 + .../development/frost-readiness-manifest.adoc | 3 + docs/development/frost-share-repair.adoc | 189 ++++ .../tbtc_frost_pre_sign_authorization.go | 69 +- ...tbtc_frost_retained_group_manifest_test.go | 15 + ...e_tbtc_signer_registration_frost_native.go | 460 +++++++++ ...c_signer_registration_frost_native_test.go | 180 ++++ .../signing/native_tbtc_signer_abi_version.go | 5 +- .../native_tbtc_signer_abi_version_test.go | 7 +- .../signing/native_tbtc_signer_inventory.go | 171 +++- .../native_tbtc_signer_inventory_test.go | 93 ++ ..._interactive_signing_drive_frost_native.go | 17 +- ...runner_bus_activation_frost_native_test.go | 210 ++++ .../roast_runner_bus_net_frost_native.go | 145 ++- .../signing/share_repair_bus_frost_native.go | 325 ++++++ .../share_repair_bus_frost_native_test.go | 112 +++ .../signing/share_repair_frost_native.go | 944 ++++++++++++++++++ .../signing/share_repair_frost_native_test.go | 334 +++++++ .../share_repair_runner_frost_native.go | 707 +++++++++++++ .../share_repair_runner_frost_native_test.go | 315 ++++++ pkg/tbtc/frost_activation_handshake.go | 25 +- pkg/tbtc/frost_activation_handshake_test.go | 21 +- pkg/tbtc/frost_pre_sign_authorization.go | 51 +- .../frost_share_repair_activation_default.go | 32 + ...st_share_repair_activation_frost_native.go | 93 ++ .../frost_share_repair_maintenance_default.go | 25 + ...t_share_repair_maintenance_frost_native.go | 155 +++ pkg/tbtc/node.go | 40 +- ...ion_controller_frost_native_roast_retry.go | 15 +- pkg/tbtc/tbtc.go | 23 +- 31 files changed, 4686 insertions(+), 102 deletions(-) create mode 100644 docs/development/frost-share-repair.adoc create mode 100644 pkg/frost/signing/roast_runner_bus_activation_frost_native_test.go create mode 100644 pkg/frost/signing/share_repair_bus_frost_native.go create mode 100644 pkg/frost/signing/share_repair_bus_frost_native_test.go create mode 100644 pkg/frost/signing/share_repair_frost_native.go create mode 100644 pkg/frost/signing/share_repair_frost_native_test.go create mode 100644 pkg/frost/signing/share_repair_runner_frost_native.go create mode 100644 pkg/frost/signing/share_repair_runner_frost_native_test.go create mode 100644 pkg/tbtc/frost_share_repair_activation_default.go create mode 100644 pkg/tbtc/frost_share_repair_activation_frost_native.go create mode 100644 pkg/tbtc/frost_share_repair_maintenance_default.go create mode 100644 pkg/tbtc/frost_share_repair_maintenance_frost_native.go diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 91e4eae83f..6173c0b5a9 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=f593d4b4c9c4e4d5103780de4ed2d2a5e2e38e69 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..e73306bf94 --- /dev/null +++ b/docs/development/frost-share-repair.adoc @@ -0,0 +1,189 @@ += 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 three 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; +* 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 or signing share crosses the Rust ABI. Helper deltas and +sigmas are encrypted to per-run ephemeral keys, are endpoint/context bound, +and are zeroed after use. + +== 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, and uses ABI 4.5 or newer. +. 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. + +== One-shot maintenance phase + +Place the signed authorization 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-authorization.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 +digest. Only locally controlled seats named by the authorization participate. + +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. + +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 authorization while it remains valid. Partial network + deltas and sigmas are not durable. + +| 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 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, confidentiality, exact-set, activation, and restart guard +go test -tags=frost_native ./pkg/frost/signing \ + -run 'TestShareRepair|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, and a partially upgraded fleet. Capture the +v2 inventory commitment, anchor acknowledgement, old-stream tombstone, +activation registry root, and fleet handshake attestations as the incident +record. Never capture scalar payloads, key packages, 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..de2a345473 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,18 @@ typedef TbtcSignerResult (*tbtc_retire_distributed_dkg_key_packages_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 +196,33 @@ static TbtcSignerResult tbtc_signer_retire_distributed_dkg_key_packages(const ui return retire(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 +372,13 @@ 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_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 +421,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 +503,59 @@ type buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse struct { RetiredKeyPackageCount uint16 `json:"retired_key_package_count"` } +type buildTaggedTBTCSignerShareRepairPart1Request struct { + Authorization *ShareRepairAuthorization `json:"authorization"` + HelperIdentifier uint16 `json:"helper_identifier"` +} + +type buildTaggedTBTCSignerShareRepairDelta struct { + ContextDigest string `json:"context_digest"` + SenderIdentifier uint16 `json:"sender_identifier"` + RecipientIdentifier uint16 `json:"recipient_identifier"` + DataHex json.RawMessage `json:"data_hex"` +} + +type buildTaggedTBTCSignerShareRepairPart1Response struct { + ContextDigest string `json:"context_digest"` + HelperIdentifier uint16 `json:"helper_identifier"` + PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` + Deltas []buildTaggedTBTCSignerShareRepairDelta `json:"deltas"` +} + +type buildTaggedTBTCSignerShareRepairPart2Request struct { + Authorization *ShareRepairAuthorization `json:"authorization"` + HelperIdentifier uint16 `json:"helper_identifier"` + Deltas []buildTaggedTBTCSignerShareRepairDelta `json:"deltas"` +} + +type buildTaggedTBTCSignerShareRepairSigma struct { + ContextDigest string `json:"context_digest"` + HelperIdentifier uint16 `json:"helper_identifier"` + DataHex json.RawMessage `json:"data_hex"` +} + +type buildTaggedTBTCSignerShareRepairPart2Response struct { + ContextDigest string `json:"context_digest"` + Sigma *buildTaggedTBTCSignerShareRepairSigma `json:"sigma"` +} + +type buildTaggedTBTCSignerInstallRepairedShareRequest struct { + Authorization *ShareRepairAuthorization `json:"authorization"` + PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` + Sigmas []buildTaggedTBTCSignerShareRepairSigma `json:"sigmas"` +} + +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 +817,71 @@ func (bttse *buildTaggedTBTCSignerEngine) RetireDistributedDKGKeyPackages( ) } +func (bttse *buildTaggedTBTCSignerEngine) ShareRepairPart1( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, +) (*NativeShareRepairPart1Result, error) { + requestPayload, err := buildTaggedTBTCSignerShareRepairPart1RequestPayload( + authorization, + helperIdentifier, + ) + if err != nil { + return nil, err + } + responsePayload, err := callBuildTaggedTBTCSignerShareRepairPart1(requestPayload) + if err != nil { + return nil, err + } + defer zeroBytes(responsePayload) + return decodeBuildTaggedTBTCSignerShareRepairPart1Response(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) ShareRepairPart2( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairDelta, +) (*NativeShareRepairPart2Result, error) { + requestPayload, err := buildTaggedTBTCSignerShareRepairPart2RequestPayload( + authorization, + helperIdentifier, + deltas, + ) + if err != nil { + return nil, err + } + defer zeroBytes(requestPayload) + responsePayload, err := callBuildTaggedTBTCSignerShareRepairPart2(requestPayload) + if err != nil { + return nil, err + } + defer zeroBytes(responsePayload) + return decodeBuildTaggedTBTCSignerShareRepairPart2Response(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) InstallRepairedShare( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairSigma, +) (*NativeShareRepairInstallResult, error) { + requestPayload, err := buildTaggedTBTCSignerInstallRepairedShareRequestPayload( + authorization, + publicKeyPackage, + sigmas, + ) + if err != nil { + return nil, err + } + defer zeroBytes(requestPayload) + responsePayload, err := callBuildTaggedTBTCSignerInstallRepairedShare(requestPayload) + if err != nil { + return nil, err + } + return decodeBuildTaggedTBTCSignerInstallRepairedShareResponse( + responsePayload, + authorization, + ) +} + func (bttse *buildTaggedTBTCSignerEngine) NewSigningPackage( message []byte, commitments []nativeFROSTCommitment, @@ -1117,6 +1282,261 @@ func buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequestPayload( ) } +func buildTaggedTBTCSignerShareRepairPart1RequestPayload( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, +) ([]byte, error) { + const op = "ShareRepairPart1" + if _, err := ComputeShareRepairAuthorizationDigest(authorization); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerShareRepairPart1Request{ + Authorization: authorization, + HelperIdentifier: helperIdentifier, + }, + ) +} + +func buildTaggedTBTCSignerShareRepairDeltaPayloads( + op string, + deltas []*NativeShareRepairDelta, +) ([]buildTaggedTBTCSignerShareRepairDelta, error) { + result := make([]buildTaggedTBTCSignerShareRepairDelta, len(deltas)) + for index, delta := range deltas { + if delta == nil || len(delta.Data) != 32 { + for prior := 0; prior < index; prior++ { + zeroBytes(result[prior].DataHex) + } + return nil, buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("repair delta [%d] is nil or not 32 bytes", index), + ) + } + dataHex, err := encodeShareRepairSecretHexJSON(delta.Data) + if err != nil { + for prior := 0; prior < index; prior++ { + zeroBytes(result[prior].DataHex) + } + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + result[index] = buildTaggedTBTCSignerShareRepairDelta{ + ContextDigest: delta.ContextDigest, + SenderIdentifier: delta.SenderIdentifier, + RecipientIdentifier: delta.RecipientIdentifier, + DataHex: dataHex, + } + } + return result, nil +} + +func buildTaggedTBTCSignerShareRepairPart2RequestPayload( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairDelta, +) ([]byte, error) { + const op = "ShareRepairPart2" + if _, err := ComputeShareRepairAuthorizationDigest(authorization); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + wireDeltas, err := buildTaggedTBTCSignerShareRepairDeltaPayloads(op, deltas) + if err != nil { + return nil, err + } + defer func() { + for index := range wireDeltas { + zeroBytes(wireDeltas[index].DataHex) + } + }() + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerShareRepairPart2Request{ + Authorization: authorization, + HelperIdentifier: helperIdentifier, + Deltas: wireDeltas, + }, + ) +} + +func buildTaggedTBTCSignerShareRepairSigmaPayloads( + op string, + sigmas []*NativeShareRepairSigma, +) ([]buildTaggedTBTCSignerShareRepairSigma, error) { + result := make([]buildTaggedTBTCSignerShareRepairSigma, len(sigmas)) + for index, sigma := range sigmas { + if sigma == nil || len(sigma.Data) != 32 { + for prior := 0; prior < index; prior++ { + zeroBytes(result[prior].DataHex) + } + return nil, buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("repair sigma [%d] is nil or not 32 bytes", index), + ) + } + dataHex, err := encodeShareRepairSecretHexJSON(sigma.Data) + if err != nil { + for prior := 0; prior < index; prior++ { + zeroBytes(result[prior].DataHex) + } + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + result[index] = buildTaggedTBTCSignerShareRepairSigma{ + ContextDigest: sigma.ContextDigest, + HelperIdentifier: sigma.HelperIdentifier, + DataHex: dataHex, + } + } + return result, nil +} + +func buildTaggedTBTCSignerInstallRepairedShareRequestPayload( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairSigma, +) ([]byte, error) { + const op = "InstallRepairedShare" + if _, err := ComputeShareRepairAuthorizationDigest(authorization); err != nil { + return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + } + if publicKeyPackage == nil { + return nil, buildTaggedTBTCSignerOperationError(op, "public key package is nil") + } + wireSigmas, err := buildTaggedTBTCSignerShareRepairSigmaPayloads(op, sigmas) + if err != nil { + return nil, err + } + defer func() { + for index := range wireSigmas { + zeroBytes(wireSigmas[index].DataHex) + } + }() + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerInstallRepairedShareRequest{ + Authorization: authorization, + PublicKeyPackage: &buildTaggedTBTCSignerNativeFROSTPublicKeyPackage{ + VerifyingShares: publicKeyPackage.VerifyingShares, + VerifyingKey: publicKeyPackage.VerifyingKey, + }, + Sigmas: wireSigmas, + }, + ) +} + +func decodeBuildTaggedTBTCSignerShareRepairPart1Response( + responsePayload []byte, +) (*NativeShareRepairPart1Result, error) { + const op = "ShareRepairPart1" + response := &buildTaggedTBTCSignerShareRepairPart1Response{} + if err := json.Unmarshal(responsePayload, response); err != nil { + for index := range response.Deltas { + zeroBytes(response.Deltas[index].DataHex) + } + return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("cannot decode response: %v", err)) + } + defer func() { + for index := range response.Deltas { + zeroBytes(response.Deltas[index].DataHex) + } + }() + if response.PublicKeyPackage == nil || + len(response.PublicKeyPackage.VerifyingShares) == 0 || + response.PublicKeyPackage.VerifyingKey == "" { + return nil, buildTaggedTBTCSignerOperationError(op, "response public key package is absent") + } + deltas := make([]*NativeShareRepairDelta, len(response.Deltas)) + for index, delta := range response.Deltas { + data, err := decodeShareRepairSecretHexJSON(delta.DataHex) + if err != nil || len(data) != 32 { + for prior := 0; prior < index; prior++ { + zeroBytes(deltas[prior].Data) + } + return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("invalid delta [%d]", index)) + } + deltas[index] = &NativeShareRepairDelta{ + ContextDigest: delta.ContextDigest, + SenderIdentifier: delta.SenderIdentifier, + RecipientIdentifier: delta.RecipientIdentifier, + Data: data, + } + } + 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, +) (*NativeShareRepairPart2Result, error) { + const op = "ShareRepairPart2" + response := &buildTaggedTBTCSignerShareRepairPart2Response{} + if err := json.Unmarshal(responsePayload, response); err != nil { + if response.Sigma != nil { + zeroBytes(response.Sigma.DataHex) + } + return nil, buildTaggedTBTCSignerOperationError(op, "cannot decode response sigma") + } + if response.Sigma == nil { + return nil, buildTaggedTBTCSignerOperationError(op, "cannot decode response sigma") + } + defer zeroBytes(response.Sigma.DataHex) + data, err := decodeShareRepairSecretHexJSON(response.Sigma.DataHex) + if err != nil || len(data) != 32 { + return nil, buildTaggedTBTCSignerOperationError(op, "response sigma is invalid") + } + return &NativeShareRepairPart2Result{ + ContextDigest: response.ContextDigest, + Sigma: &NativeShareRepairSigma{ + ContextDigest: response.Sigma.ContextDigest, + HelperIdentifier: response.Sigma.HelperIdentifier, + Data: data, + }, + }, 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 +2350,42 @@ func callBuildTaggedTBTCSignerRetireDistributedDKGKeyPackages( ) } +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 +2495,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..36e341f9ae 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 4.5 library is missing one or more share-repair symbols") + } +} + func buildTaggedTBTCSignerTestIdentifier(memberIndex byte) string { identifier := make([]byte, 32) identifier[0] = memberIndex @@ -331,6 +349,168 @@ func TestBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesPayloadAndResponse( } } +func TestBuildTaggedTBTCSignerShareRepairPayloadsAndResponses(t *testing.T) { + authorization, _ := testShareRepairAuthorization(t) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + contextDigest := fmt.Sprintf("0x%x", digest) + secret := bytes.Repeat([]byte{0xab}, 32) + defer zeroBytes(secret) + delta := &NativeShareRepairDelta{ + ContextDigest: contextDigest, + SenderIdentifier: 1, + RecipientIdentifier: 2, + Data: secret, + } + + part2Payload, err := buildTaggedTBTCSignerShareRepairPart2RequestPayload( + authorization, + 2, + []*NativeShareRepairDelta{delta}, + ) + if err != nil { + t.Fatal(err) + } + defer zeroBytes(part2Payload) + 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) != 1 { + t.Fatal("share-repair Part2 bridge request lost its authorization or endpoint") + } + decodedDelta, err := decodeShareRepairSecretHexJSON( + part2Request.Deltas[0].DataHex, + ) + zeroBytes(part2Request.Deltas[0].DataHex) + if err != nil || !bytes.Equal(decodedDelta, secret) { + zeroBytes(decodedDelta) + t.Fatalf("share-repair Part2 bridge secret did not round trip: %v", err) + } + zeroBytes(decodedDelta) + + secretWire, err := encodeShareRepairSecretHexJSON(secret) + if err != nil { + t.Fatal(err) + } + part1Wire := buildTaggedTBTCSignerShareRepairPart1Response{ + ContextDigest: contextDigest, + HelperIdentifier: 1, + PublicKeyPackage: &buildTaggedTBTCSignerNativeFROSTPublicKeyPackage{ + VerifyingShares: map[string]string{"1": "share-1"}, + VerifyingKey: "group-key", + }, + Deltas: []buildTaggedTBTCSignerShareRepairDelta{{ + ContextDigest: contextDigest, + SenderIdentifier: 1, + RecipientIdentifier: 2, + DataHex: secretWire, + }}, + } + part1Payload, err := json.Marshal(part1Wire) + zeroBytes(secretWire) + if err != nil { + t.Fatal(err) + } + defer zeroBytes(part1Payload) + part1, err := decodeBuildTaggedTBTCSignerShareRepairPart1Response(part1Payload) + if err != nil { + t.Fatal(err) + } + if len(part1.Deltas) != 1 || !bytes.Equal(part1.Deltas[0].Data, secret) { + t.Fatal("share-repair Part1 bridge response lost its secret delta") + } + zeroBytes(part1.Deltas[0].Data) + + secretWire, err = encodeShareRepairSecretHexJSON(secret) + if err != nil { + t.Fatal(err) + } + part2ResponsePayload, err := json.Marshal( + buildTaggedTBTCSignerShareRepairPart2Response{ + ContextDigest: contextDigest, + Sigma: &buildTaggedTBTCSignerShareRepairSigma{ + ContextDigest: contextDigest, + HelperIdentifier: 1, + DataHex: secretWire, + }, + }, + ) + zeroBytes(secretWire) + if err != nil { + t.Fatal(err) + } + defer zeroBytes(part2ResponsePayload) + part2, err := decodeBuildTaggedTBTCSignerShareRepairPart2Response( + part2ResponsePayload, + ) + if err != nil { + t.Fatal(err) + } + if part2.Sigma == nil || !bytes.Equal(part2.Sigma.Data, secret) { + t.Fatal("share-repair Part2 bridge response lost its secret sigma") + } + + installPayload, err := buildTaggedTBTCSignerInstallRepairedShareRequestPayload( + authorization, + &NativeFROSTPublicKeyPackage{ + VerifyingShares: map[string]string{"1": "share-1"}, + VerifyingKey: "group-key", + }, + []*NativeShareRepairSigma{part2.Sigma}, + ) + zeroBytes(part2.Sigma.Data) + if err != nil { + t.Fatal(err) + } + defer zeroBytes(installPayload) + 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) != 1 { + t.Fatal("share-repair install bridge request lost its bound inputs") + } + decodedSigma, err := decodeShareRepairSecretHexJSON( + installRequest.Sigmas[0].DataHex, + ) + zeroBytes(installRequest.Sigmas[0].DataHex) + if err != nil || !bytes.Equal(decodedSigma, secret) { + zeroBytes(decodedSigma) + t.Fatalf("share-repair install bridge secret did not round trip: %v", err) + } + zeroBytes(decodedSigma) + + 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 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..2803d8525a 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -41,7 +41,10 @@ const ( // // 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 + // + // Minor 5 adds the three context-bound share-repair symbols. A DR-capable + // host must reject ABI 4.4 before it can reach a missing dlsym entry. + requiredTBTCSignerABIMinMinor uint32 = 5 ) // 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..e2114d0e4f 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -95,9 +95,9 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { // 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 + // retirement. Minor 5 adds share repair. The matching library version is compatible; ABI 4.4 and a // different major are not. - if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 4 { + if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 5 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, @@ -119,6 +119,9 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 3); err == nil { t.Fatal("ABI 4.3 without distributed-DKG retirement must be incompatible") } + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 4); err == nil { + t.Fatal("ABI 4.4 without share-repair symbols 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..367fb6e9db --- /dev/null +++ b/pkg/frost/signing/share_repair_bus_frost_native.go @@ -0,0 +1,325 @@ +//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 +) + +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(shareRepairMessage) +} + +type shareRepairBusSubscriber struct { + member group.MemberIndex + stream chan shareRepairMessage + mutex sync.Mutex + seen map[[32]byte]struct{} +} + +func (subscriber *shareRepairBusSubscriber) deliver( + message shareRepairMessage, + seenBound int, +) { + if message.Recipient != 0 && message.Recipient != subscriber.member { + return + } + hash := message.contentHash() + delivered := message + delivered.EphemeralPublicKey = append([]byte(nil), message.EphemeralPublicKey...) + delivered.Payload = append([]byte(nil), message.Payload...) + subscriber.mutex.Lock() + defer subscriber.mutex.Unlock() + if _, exists := subscriber.seen[hash]; exists { + return + } + select { + case subscriber.stream <- delivered: + if seenBound > 0 && len(subscriber.seen) >= seenBound { + subscriber.seen = make(map[[32]byte]struct{}) + } + subscriber.seen[hash] = struct{}{} + 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{}), + } + bus.mutex.Lock() + bus.subscribers = append(bus.subscribers, subscriber) + bus.mutex.Unlock() + return subscriber.stream +} + +func (*inProcessShareRepairBus) Start() {} + +func (bus *inProcessShareRepairBus) Broadcast(message shareRepairMessage) { + bus.mutex.Lock() + subscribers := append([]*shareRepairBusSubscriber(nil), bus.subscribers...) + bus.mutex.Unlock() + for _, subscriber := range subscribers { + subscriber.deliver(message, 4096) + } +} + +const shareRepairTransportType = "frost/share_repair/v1" + +const ( + shareRepairEphemeralPublicKeyLength = 33 + shareRepairMaximumSecretPayload = 4 * 1024 + shareRepairMaximumPublicPayload = 256 * 1024 +) + +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 value.Type < shareRepairAnnouncementMessage || value.Type > shareRepairPublicPackageMessage || + value.Sender == 0 || value.ContextDigest == [32]byte{} || + len(value.Payload) > shareRepairMaximumPublicPayload { + return nil, fmt.Errorf("share-repair transport message is invalid") + } + switch value.Type { + case shareRepairAnnouncementMessage: + if value.Recipient != 0 || + len(value.EphemeralPublicKey) != shareRepairEphemeralPublicKeyLength || + len(value.Payload) != 0 { + return nil, fmt.Errorf("share-repair announcement shape is invalid") + } + case shareRepairInstalledMessage: + if value.Recipient != 0 || len(value.EphemeralPublicKey) != 0 || + len(value.Payload) == 0 || len(value.Payload) > shareRepairMaximumSecretPayload { + return nil, fmt.Errorf("share-repair installed receipt shape is invalid") + } + case shareRepairPublicPackageMessage: + if value.Recipient != 0 || len(value.EphemeralPublicKey) != 0 || len(value.Payload) == 0 { + return nil, fmt.Errorf("share-repair public-package message shape is invalid") + } + default: + if value.Recipient == 0 || len(value.EphemeralPublicKey) != 0 || + len(value.Payload) == 0 || len(value.Payload) > shareRepairMaximumSecretPayload { + return nil, fmt.Errorf("share-repair secret message shape is invalid") + } + } + 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 > shareRepairPublicPackageMessage || + 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 || + len(data)-(43+ephemeralLength) > shareRepairMaximumPublicPayload { + return fmt.Errorf("share-repair ephemeral key is truncated") + } + 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:]...), + } + switch messageType { + case shareRepairAnnouncementMessage: + if rawRecipient != 0 || + ephemeralLength != shareRepairEphemeralPublicKeyLength || + len(value.Payload) != 0 { + return fmt.Errorf("share-repair announcement shape is invalid") + } + case shareRepairInstalledMessage: + if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) == 0 || + len(value.Payload) > shareRepairMaximumSecretPayload { + return fmt.Errorf("share-repair installed receipt shape is invalid") + } + case shareRepairPublicPackageMessage: + if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) == 0 { + return fmt.Errorf("share-repair public-package message shape is invalid") + } + default: + if rawRecipient == 0 || ephemeralLength != 0 || len(value.Payload) == 0 || + len(value.Payload) > shareRepairMaximumSecretPayload { + return fmt.Errorf("share-repair secret message shape is invalid") + } + } + message.message = value + return nil +} + +type broadcastChannelShareRepairBus struct { + ctx context.Context + logger log.StandardLogger + channel net.BroadcastChannel + membershipValidator *group.MembershipValidator + mutex sync.Mutex + subscribers []*shareRepairBusSubscriber + startOnce sync.Once +} + +func newBroadcastChannelShareRepairBus( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, +) (shareRepairBus, error) { + if ctx == nil || channel == nil || membershipValidator == nil { + return nil, fmt.Errorf("share-repair bus dependencies are incomplete") + } + 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, + }, nil +} + +func (bus *broadcastChannelShareRepairBus) Subscribe( + member group.MemberIndex, +) <-chan shareRepairMessage { + subscriber := &shareRepairBusSubscriber{ + member: member, + stream: make(chan shareRepairMessage, 1024), + seen: make(map[[32]byte]struct{}), + } + 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) { + 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) { + // 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( + bus.ctx, + &shareRepairTransportMessage{message: message}, + ); err != nil { + bus.logger.Warnf("share-repair bus send failed: [%v]", err) + } +} + +func (bus *broadcastChannelShareRepairBus) handleMessage(message net.Message) { + wire, ok := message.Payload().(*shareRepairTransportMessage) + if !ok { + 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..ef568f81c2 --- /dev/null +++ b/pkg/frost/signing/share_repair_bus_frost_native_test.go @@ -0,0 +1,112 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "testing" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +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 TestShareRepairBusAuthenticatesSenderAndSuppressesReplay(t *testing.T) { + fixture := newRunnerBusAuthFixture(t, 8) + channel := &immediateRecvBroadcastChannel{} + busInterface, err := newBroadcastChannelShareRepairBus( + context.Background(), + &testutils.MockLogger{}, + channel, + fixture.validator, + ) + 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: + } + + 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: + } +} 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..29ef8a89fa --- /dev/null +++ b/pkg/frost/signing/share_repair_frost_native.go @@ -0,0 +1,944 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const ( + ShareRepairAuthorizationSchema = "tbtc-frost-share-repair-authorization/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" + shareRepairActivationLeaseDomain = "tbtc-frost-share-repair-activation/v1\x00" + shareRepairActivationRegistryDomain = "tbtc-frost-share-repair-activation-registry/v1\x00" + shareRepairMaximumAuthorizationAge = 24 * time.Hour + shareRepairMaximumActivationRegistry = 4096 +) + +// 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"` +} + +// NativeShareRepairDelta and NativeShareRepairSigma contain secret scalars. +// Callers must zero Data as soon as the next native phase has copied it. +type NativeShareRepairDelta struct { + ContextDigest string + SenderIdentifier uint16 + RecipientIdentifier uint16 + Data []byte +} + +type NativeShareRepairSigma struct { + ContextDigest string + HelperIdentifier uint16 + Data []byte +} + +type NativeShareRepairPart1Result struct { + ContextDigest string + HelperIdentifier uint16 + PublicKeyPackage *NativeFROSTPublicKeyPackage + Deltas []*NativeShareRepairDelta +} + +type NativeShareRepairPart2Result struct { + ContextDigest string + Sigma *NativeShareRepairSigma +} + +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 { + ShareRepairPart1( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + ) (*NativeShareRepairPart1Result, error) + ShareRepairPart2( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairDelta, + ) (*NativeShareRepairPart2Result, error) + InstallRepairedShare( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairSigma, + ) (*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 +} + +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 +} + +// encodeShareRepairSecretHexJSON and decodeShareRepairSecretHexJSON keep repair +// scalars in mutable byte slices. Using ordinary Go strings here would leave +// immutable plaintext hex copies behind after the FFI or ECIES operation. +func encodeShareRepairSecretHexJSON(data []byte) (json.RawMessage, error) { + if len(data) != 32 { + return nil, fmt.Errorf("share-repair secret scalar must be 32 bytes") + } + result := make([]byte, 66) + result[0] = '"' + hex.Encode(result[1:65], data) + result[65] = '"' + return json.RawMessage(result), nil +} + +func decodeShareRepairSecretHexJSON(data json.RawMessage) ([]byte, error) { + if len(data) != 66 || data[0] != '"' || data[65] != '"' { + return nil, fmt.Errorf("share-repair secret scalar is not canonical JSON hex") + } + for _, value := range data[1:65] { + if value >= 'A' && value <= 'F' { + return nil, fmt.Errorf("share-repair secret scalar is not lowercase hex") + } + } + result := make([]byte, 32) + decoded, err := hex.Decode(result, data[1:65]) + if err != nil || decoded != len(result) { + zeroBytes(result) + return nil, fmt.Errorf("share-repair secret scalar is invalid") + } + return result, 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 +} + +// 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 strings.TrimSpace(authorization.SessionID) == "" || + authorization.SessionID != strings.TrimSpace(authorization.SessionID) || + len(authorization.SessionID) > 256 { + 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 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 +} + +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..dc1e353b5a --- /dev/null +++ b/pkg/frost/signing/share_repair_frost_native_test.go @@ -0,0 +1,334 @@ +//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 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 TestShareRepairSecretHexJSONUsesCanonicalMutableBytes(t *testing.T) { + secret := bytes.Repeat([]byte{0xab}, 32) + wire, err := encodeShareRepairSecretHexJSON(secret) + if err != nil { + t.Fatal(err) + } + if len(wire) != 66 || wire[0] != '"' || wire[65] != '"' || + bytes.Contains(wire, []byte("AB")) { + t.Fatalf("unexpected canonical secret wire: %q", wire) + } + decoded, err := decodeShareRepairSecretHexJSON(wire) + if err != nil || !bytes.Equal(decoded, secret) { + t.Fatalf("canonical secret wire did not round trip: %v", err) + } + zeroBytes(decoded) + upper := append(json.RawMessage(nil), wire...) + upper[1] = 'A' + if _, err := decodeShareRepairSecretHexJSON(upper); err == nil { + t.Fatal("uppercase secret hex was accepted") + } + zeroBytes(wire) + if !bytes.Equal(wire, make([]byte, len(wire))) { + t.Fatal("secret JSON wire could not be scrubbed") + } +} + +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..77f4db16cd --- /dev/null +++ b/pkg/frost/signing/share_repair_runner_frost_native.go @@ -0,0 +1,707 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type shareRepairSecretWire struct { + ContextDigest string `json:"context_digest"` + SenderIdentifier uint16 `json:"sender_identifier,omitempty"` + RecipientIdentifier uint16 `json:"recipient_identifier,omitempty"` + HelperIdentifier uint16 `json:"helper_identifier,omitempty"` + DataHex json.RawMessage `json:"data_hex"` +} + +const shareRepairMaximumPendingMessages = 1024 + +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 + authorizationDigest [32]byte + contextWire string + participants map[group.MemberIndex]struct{} + helperSet map[group.MemberIndex]struct{} + engine NativeTBTCSignerShareRepairEngine + bus shareRepairBus + stream <-chan shareRepairMessage + ephemeralPrivate *ephemeral.PrivateKey + ephemeralPublic []byte + pending []shareRepairMessage +} + +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, + 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) + } + bus, err := newBroadcastChannelShareRepairBus( + ctx, + logger, + channel, + membershipValidator, + ) + if err != nil { + return nil, err + } + return runShareRepairOnBus( + ctx, + engine, + authorization, + validated.digest, + localMemberIndexes, + bus, + ) +} + +func runShareRepairOnBus( + ctx context.Context, + engine NativeTBTCSignerShareRepairEngine, + authorization *ShareRepairAuthorization, + authorizationDigest [32]byte, + localMemberIndexes []group.MemberIndex, + bus shareRepairBus, +) (*NativeShareRepairInstallResult, error) { + if ctx == nil || engine == nil || authorization == nil || + bus == nil || len(localMemberIndexes) == 0 { + return nil, fmt.Errorf("share repair runner dependencies are incomplete") + } + participants := make(map[group.MemberIndex]struct{}, len(authorization.HelperIdentifiers)+1) + helperSet := make(map[group.MemberIndex]struct{}, len(authorization.HelperIdentifiers)) + for _, helper := range authorization.HelperIdentifiers { + member := group.MemberIndex(helper) + participants[member] = struct{}{} + helperSet[member] = struct{}{} + } + participants[group.MemberIndex(authorization.TargetIdentifier)] = struct{}{} + + localSet := make(map[group.MemberIndex]struct{}, len(localMemberIndexes)) + runners := make([]*shareRepairRunner, 0, len(localMemberIndexes)) + started := false + defer func() { + if !started { + for _, runner := range runners { + runner.ephemeralPrivate.Zero() + } + } + }() + 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) + } + keyPair, err := ephemeral.GenerateKeyPair() + if err != nil { + return nil, fmt.Errorf("cannot generate recovery ephemeral key for seat [%d]: %w", member, err) + } + runners = append(runners, &shareRepairRunner{ + member: member, + authorization: authorization, + authorizationDigest: authorizationDigest, + contextWire: fmt.Sprintf("0x%x", authorizationDigest), + participants: participants, + helperSet: helperSet, + engine: engine, + bus: bus, + stream: bus.Subscribe(member), + ephemeralPrivate: keyPair.PrivateKey, + ephemeralPublic: keyPair.PublicKey.Marshal(), + }) + } + bus.Start() + started = true + runContext, cancel := context.WithCancel(ctx) + defer cancel() + + outcomes := make(chan shareRepairRunnerOutcome, len(runners)) + for _, runner := range runners { + runner := runner + go func() { + defer runner.ephemeralPrivate.Zero() + result, err := runner.run(runContext) + outcomes <- shareRepairRunnerOutcome{member: runner.member, result: result, err: err} + }() + } + + var targetResult *NativeShareRepairInstallResult + 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, + } + runner.bus.Broadcast(announcement) + stopAnnouncements := make(chan struct{}) + announcementsStopped := make(chan struct{}) + go func() { + defer close(announcementsStopped) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-stopAnnouncements: + return + case <-ticker.C: + runner.bus.Broadcast(announcement) + } + } + }() + defer func() { + close(stopAnnouncements) + <-announcementsStopped + }() + + publicKeys, err := runner.collectAnnouncements(ctx) + if err != nil { + return nil, err + } + if runner.member == group.MemberIndex(runner.authorization.TargetIdentifier) { + return runner.runTarget(ctx, publicKeys) + } + if _, helper := runner.helperSet[runner.member]; !helper { + return nil, fmt.Errorf("seat is neither target nor helper") + } + return nil, runner.runHelper(ctx, publicKeys) +} + +func (runner *shareRepairRunner) collectAnnouncements( + ctx context.Context, +) (map[group.MemberIndex]*ephemeral.PublicKey, error) { + publicKeys := make(map[group.MemberIndex]*ephemeral.PublicKey, len(runner.participants)) + selfPublic, err := ephemeral.UnmarshalPublicKey(runner.ephemeralPublic) + if err != nil { + return nil, fmt.Errorf("cannot parse local recovery ephemeral key: %w", err) + } + publicKeys[runner.member] = selfPublic + 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 + } + if message.Type != shareRepairAnnouncementMessage { + if len(runner.pending) >= shareRepairMaximumPendingMessages { + return nil, fmt.Errorf( + "share-repair pending-message limit exceeded before announcements completed", + ) + } + runner.pending = append(runner.pending, message) + continue + } + if _, expected := runner.participants[message.Sender]; !expected { + continue + } + parsed, err := ephemeral.UnmarshalPublicKey(message.EphemeralPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid recovery announcement from [%d]: %w", message.Sender, err) + } + if existing, seen := publicKeys[message.Sender]; seen { + if !bytes.Equal(existing.Marshal(), parsed.Marshal()) { + return nil, fmt.Errorf("recovery seat [%d] equivocated its ephemeral key", message.Sender) + } + continue + } + publicKeys[message.Sender] = parsed + } + return publicKeys, nil +} + +func (runner *shareRepairRunner) nextMessage( + ctx context.Context, +) (shareRepairMessage, error) { + if len(runner.pending) > 0 { + message := runner.pending[0] + runner.pending[0] = shareRepairMessage{} + runner.pending = runner.pending[1:] + return message, nil + } + select { + case <-ctx.Done(): + return shareRepairMessage{}, ctx.Err() + case message := <-runner.stream: + return message, nil + } +} + +func encodeShareRepairSealedSecret( + secret shareRepairSecretWire, + recipient *ephemeral.PublicKey, +) ([]byte, error) { + defer zeroBytes(secret.DataHex) + plaintext, err := json.Marshal(secret) + if err != nil { + return nil, err + } + defer zeroBytes(plaintext) + sealed, err := sealRound2Share(plaintext, recipient) + if err != nil { + return nil, err + } + return json.Marshal(sealed) +} + +func decodeShareRepairSealedSecret( + payload []byte, + privateKey *ephemeral.PrivateKey, +) (*shareRepairSecretWire, error) { + sealed := &sealedRound2Share{} + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(sealed); err != nil { + return nil, fmt.Errorf("cannot decode sealed recovery envelope: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("sealed recovery envelope has trailing JSON") + } + plaintext, err := openRound2Share(sealed, privateKey) + if err != nil { + return nil, err + } + defer zeroBytes(plaintext) + secret := &shareRepairSecretWire{} + decoder = json.NewDecoder(bytes.NewReader(plaintext)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(secret); err != nil { + zeroBytes(secret.DataHex) + return nil, fmt.Errorf("cannot decode recovery secret: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + zeroBytes(secret.DataHex) + return nil, fmt.Errorf("recovery secret has trailing JSON") + } + return secret, nil +} + +func (runner *shareRepairRunner) runHelper( + ctx context.Context, + publicKeys map[group.MemberIndex]*ephemeral.PublicKey, +) error { + part1, err := runner.engine.ShareRepairPart1( + runner.authorization, + uint16(runner.member), + ) + if err != nil { + return fmt.Errorf("native repair part1: %w", err) + } + if part1 != nil { + defer func() { + for _, delta := range part1.Deltas { + if delta != nil { + zeroBytes(delta.Data) + } + } + }() + } + 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") + } + 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.Data) != 32 { + return fmt.Errorf("native repair part1 delta [%d] is invalid", index) + } + recipientKey := publicKeys[group.MemberIndex(recipient)] + if recipientKey == nil { + return fmt.Errorf("missing recovery ephemeral key for seat [%d]", recipient) + } + dataHex, err := encodeShareRepairSecretHexJSON(delta.Data) + if err != nil { + return fmt.Errorf("encode repair delta for [%d]: %w", recipient, err) + } + sealed, err := encodeShareRepairSealedSecret( + shareRepairSecretWire{ + ContextDigest: delta.ContextDigest, + SenderIdentifier: delta.SenderIdentifier, + RecipientIdentifier: delta.RecipientIdentifier, + DataHex: dataHex, + }, + recipientKey, + ) + if err != nil { + return fmt.Errorf("seal repair delta for [%d]: %w", recipient, err) + } + zeroBytes(delta.Data) + runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairDeltaMessage, + Sender: runner.member, + Recipient: group.MemberIndex(recipient), + ContextDigest: runner.authorizationDigest, + Payload: sealed, + }) + } + + deltas := make(map[uint16]*NativeShareRepairDelta, len(runner.helperSet)) + defer func() { + for _, delta := range deltas { + zeroBytes(delta.Data) + } + }() + 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 { + continue + } + if _, expected := runner.helperSet[message.Sender]; !expected { + continue + } + secret, err := decodeShareRepairSealedSecret(message.Payload, runner.ephemeralPrivate) + if err != nil { + return fmt.Errorf("open repair delta from [%d]: %w", message.Sender, err) + } + data, err := decodeShareRepairSecretHexJSON(secret.DataHex) + zeroBytes(secret.DataHex) + if err != nil || len(data) != 32 || secret.ContextDigest != runner.contextWire || + secret.SenderIdentifier != uint16(message.Sender) || + secret.RecipientIdentifier != uint16(runner.member) || + secret.HelperIdentifier != 0 { + zeroBytes(data) + return fmt.Errorf("repair delta from [%d] has invalid bindings", message.Sender) + } + if existing := deltas[secret.SenderIdentifier]; existing != nil { + if !bytes.Equal(existing.Data, data) { + zeroBytes(data) + return fmt.Errorf("repair helper [%d] equivocated its delta", message.Sender) + } + zeroBytes(data) + continue + } + deltas[secret.SenderIdentifier] = &NativeShareRepairDelta{ + ContextDigest: secret.ContextDigest, + SenderIdentifier: secret.SenderIdentifier, + RecipientIdentifier: secret.RecipientIdentifier, + Data: data, + } + } + ordered := make([]*NativeShareRepairDelta, 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, + ) + if err != nil { + return fmt.Errorf("native repair part2: %w", err) + } + for _, delta := range deltas { + zeroBytes(delta.Data) + } + if part2 != nil && part2.Sigma != nil { + defer zeroBytes(part2.Sigma.Data) + } + if part2 == nil || part2.ContextDigest != runner.contextWire || part2.Sigma == nil || + part2.Sigma.ContextDigest != runner.contextWire || + part2.Sigma.HelperIdentifier != uint16(runner.member) || len(part2.Sigma.Data) != 32 { + return fmt.Errorf("native repair part2 returned an invalid sigma") + } + targetKey := publicKeys[group.MemberIndex(runner.authorization.TargetIdentifier)] + if targetKey == nil { + return fmt.Errorf("missing recovery ephemeral key for target seat") + } + dataHex, err := encodeShareRepairSecretHexJSON(part2.Sigma.Data) + if err != nil { + return fmt.Errorf("encode repair sigma: %w", err) + } + sealed, err := encodeShareRepairSealedSecret( + shareRepairSecretWire{ + ContextDigest: part2.Sigma.ContextDigest, + HelperIdentifier: part2.Sigma.HelperIdentifier, + DataHex: dataHex, + }, + targetKey, + ) + if err != nil { + return fmt.Errorf("seal repair sigma: %w", err) + } + zeroBytes(part2.Sigma.Data) + runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairSigmaMessage, + Sender: runner.member, + Recipient: group.MemberIndex(runner.authorization.TargetIdentifier), + ContextDigest: runner.authorizationDigest, + Payload: sealed, + }) + return runner.waitForInstalledReceipt(ctx) +} + +func (runner *shareRepairRunner) runTarget( + ctx context.Context, + _ map[group.MemberIndex]*ephemeral.PublicKey, +) (*NativeShareRepairInstallResult, error) { + sigmas := make(map[uint16]*NativeShareRepairSigma, len(runner.helperSet)) + publicPackages := make(map[uint16][]byte, len(runner.helperSet)) + var publicKeyPackage *NativeFROSTPublicKeyPackage + var canonicalPublicKeyPackage []byte + defer func() { + for _, sigma := range sigmas { + zeroBytes(sigma.Data) + } + }() + 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 { + continue + } + secret, err := decodeShareRepairSealedSecret(message.Payload, runner.ephemeralPrivate) + if err != nil { + return nil, fmt.Errorf("open repair sigma from [%d]: %w", message.Sender, err) + } + data, err := decodeShareRepairSecretHexJSON(secret.DataHex) + zeroBytes(secret.DataHex) + if err != nil || len(data) != 32 || secret.ContextDigest != runner.contextWire || + secret.HelperIdentifier != uint16(message.Sender) || + secret.SenderIdentifier != 0 || secret.RecipientIdentifier != 0 { + zeroBytes(data) + return nil, fmt.Errorf("repair sigma from [%d] has invalid bindings", message.Sender) + } + if existing := sigmas[secret.HelperIdentifier]; existing != nil { + if !bytes.Equal(existing.Data, data) { + zeroBytes(data) + return nil, fmt.Errorf("repair helper [%d] equivocated its sigma", message.Sender) + } + zeroBytes(data) + continue + } + sigmas[secret.HelperIdentifier] = &NativeShareRepairSigma{ + ContextDigest: secret.ContextDigest, + HelperIdentifier: secret.HelperIdentifier, + Data: data, + } + } + ordered := make([]*NativeShareRepairSigma, 0, len(sigmas)) + for _, helper := range runner.authorization.HelperIdentifiers { + ordered = append(ordered, sigmas[helper]) + } + result, err := runner.engine.InstallRepairedShare( + runner.authorization, + publicKeyPackage, + ordered, + ) + if err != nil { + return nil, fmt.Errorf("native repaired-share install: %w", err) + } + for _, sigma := range sigmas { + zeroBytes(sigma.Data) + } + 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) + } + 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 nil, fmt.Errorf("encode repaired-share installed receipt: %w", err) + } + runner.bus.Broadcast(shareRepairMessage{ + Type: shareRepairInstalledMessage, + Sender: runner.member, + ContextDigest: runner.authorizationDigest, + Payload: receipt, + }) + return result, nil +} + +func (runner *shareRepairRunner) waitForInstalledReceipt(ctx context.Context) 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") + } + 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..d3a53e1635 --- /dev/null +++ b/pkg/frost/signing/share_repair_runner_frost_native_test.go @@ -0,0 +1,315 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type testShareRepairEngine struct { + mutex sync.Mutex + secretBuffers [][]byte + installCalls int +} + +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 (engine *testShareRepairEngine) rememberSecret(value []byte) { + engine.mutex.Lock() + defer engine.mutex.Unlock() + engine.secretBuffers = append(engine.secretBuffers, value) +} + +func (engine *testShareRepairEngine) ShareRepairPart1( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, +) (*NativeShareRepairPart1Result, error) { + 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 _, recipient := range authorization.HelperIdentifiers { + secret := testShareRepairSecret(byte(helperIdentifier*10 + recipient)) + engine.rememberSecret(secret) + result.Deltas = append(result.Deltas, &NativeShareRepairDelta{ + ContextDigest: contextWire, + SenderIdentifier: helperIdentifier, + RecipientIdentifier: recipient, + Data: secret, + }) + } + return result, nil +} + +func (engine *testShareRepairEngine) ShareRepairPart2( + authorization *ShareRepairAuthorization, + helperIdentifier uint16, + deltas []*NativeShareRepairDelta, +) (*NativeShareRepairPart2Result, error) { + 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.Data, testShareRepairSecret(byte(sender*10+helperIdentifier))) { + return nil, fmt.Errorf("wrong delta [%d]", index) + } + } + digest, _ := ComputeShareRepairAuthorizationDigest(authorization) + secret := testShareRepairSecret(byte(100 + helperIdentifier)) + engine.rememberSecret(secret) + contextWire := fmt.Sprintf("0x%x", digest) + return &NativeShareRepairPart2Result{ + ContextDigest: contextWire, + Sigma: &NativeShareRepairSigma{ + ContextDigest: contextWire, + HelperIdentifier: helperIdentifier, + Data: secret, + }, + }, nil +} + +func (engine *testShareRepairEngine) InstallRepairedShare( + authorization *ShareRepairAuthorization, + publicKeyPackage *NativeFROSTPublicKeyPackage, + sigmas []*NativeShareRepairSigma, +) (*NativeShareRepairInstallResult, error) { + 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.Data, testShareRepairSecret(byte(100+helper))) { + 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) { + 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() + bus.shareRepairBus.Broadcast(message) +} + +func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, _ := testShareRepairAuthorization(t) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + engine := &testShareRepairEngine{} + bus := &recordingShareRepairBus{shareRepairBus: newInProcessShareRepairBus(128)} + 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, + 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) + } + for index, secret := range engine.secretBuffers { + if !bytes.Equal(secret, make([]byte, len(secret))) { + engine.mutex.Unlock() + t.Fatalf("native secret buffer [%d] was not scrubbed", index) + } + } + engine.mutex.Unlock() + + bus.mutex.Lock() + defer bus.mutex.Unlock() + secretMessages := 0 + publicPackages := 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 + } + 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) + } +} + +func TestRunShareRepairOnBusTimesOutWithoutExactParticipantSet(t *testing.T) { + resetShareRepairActivationRegistryForTest() + t.Cleanup(resetShareRepairActivationRegistryForTest) + authorization, _ := testShareRepairAuthorization(t) + digest, _ := ComputeShareRepairAuthorizationDigest(authorization) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := runShareRepairOnBus( + ctx, + &testShareRepairEngine{}, + authorization, + digest, + []group.MemberIndex{1}, + newInProcessShareRepairBus(16), + ) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("context deadline exceeded")) { + t.Fatalf("expected exact-set announcement timeout, got [%v]", err) + } +} + +func TestShareRepairRunnerRejectsPendingMessageFlood(t *testing.T) { + authorization, _ := testShareRepairAuthorization(t) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + keyPair, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + defer keyPair.PrivateKey.Zero() + + 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, + participants: map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + }, + stream: stream, + ephemeralPublic: keyPair.PublicKey.Marshal(), + } + 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) + } +} 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..c5e296d486 --- /dev/null +++ b/pkg/tbtc/frost_share_repair_maintenance_frost_native.go @@ -0,0 +1,155 @@ +//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 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 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(), + ) + channelName := fmt.Sprintf("%s-frost-share-repair-%x", ProtocolName, digest) + 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, + 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/node.go b/pkg/tbtc/node.go index d9393b96bb..a7ea9327a3 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -214,6 +214,12 @@ func newNode( if err != nil { return nil, fmt.Errorf("cannot get node's operator address: [%v]", err) } + if 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 +712,33 @@ func newNode( err, ) } + 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 +821,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..0c077e1d7e 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 maintenance completed; remove the authorization path and restart", +) + // ProtocolName denotes the name of the protocol defined by this package. const ProtocolName = "tbtc" @@ -135,6 +143,19 @@ 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 + // FrostShareRepairAuthorizationPath selects one-shot disaster-recovery mode + // using an owner-only, offline-authority-signed authorization. Every helper + // and the target exits cleanly after the bounded protocol; this path must be + // removed before restart. Normal production 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 +306,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 From 60eecf6ba53af068fb0254e5b0d4feea3c107642 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 14:15:43 -0400 Subject: [PATCH 2/7] ci(frost): advance share-repair signer pin --- ci/frost-signer-pin.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 6173c0b5a9..65491c4f3e 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=f593d4b4c9c4e4d5103780de4ed2d2a5e2e38e69 +FROST_SIGNER_MIRROR_REF=98357a9926e44e8f6477b852a7a55e43dfb96fd4 From 08999f3445bf68cb8fdaa4af0409113e311ee163 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 15:49:00 -0400 Subject: [PATCH 3/7] fix(frost): make share-repair completion reliable --- docs/development/frost-share-repair.adoc | 16 + .../signing/share_repair_bus_frost_native.go | 53 +- .../share_repair_bus_frost_native_test.go | 122 +++++ .../share_repair_runner_frost_native.go | 182 +++++-- .../share_repair_runner_frost_native_test.go | 454 +++++++++++++++++- 5 files changed, 776 insertions(+), 51 deletions(-) diff --git a/docs/development/frost-share-repair.adoc b/docs/development/frost-share-repair.adoc index e73306bf94..c0ac34686a 100644 --- a/docs/development/frost-share-repair.adoc +++ b/docs/development/frost-share-repair.adoc @@ -73,6 +73,16 @@ 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 @@ -140,6 +150,12 @@ recovered seat's legacy, missing, or stale lease frames. | 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. diff --git a/pkg/frost/signing/share_repair_bus_frost_native.go b/pkg/frost/signing/share_repair_bus_frost_native.go index 367fb6e9db..a8b6ae858e 100644 --- a/pkg/frost/signing/share_repair_bus_frost_native.go +++ b/pkg/frost/signing/share_repair_bus_frost_native.go @@ -17,11 +17,13 @@ import ( type shareRepairMessageType uint8 const ( - shareRepairAnnouncementMessage shareRepairMessageType = 1 - shareRepairDeltaMessage shareRepairMessageType = 2 - shareRepairSigmaMessage shareRepairMessageType = 3 - shareRepairInstalledMessage shareRepairMessageType = 4 - shareRepairPublicPackageMessage shareRepairMessageType = 5 + shareRepairAnnouncementMessage shareRepairMessageType = 1 + shareRepairDeltaMessage shareRepairMessageType = 2 + shareRepairSigmaMessage shareRepairMessageType = 3 + shareRepairInstalledMessage shareRepairMessageType = 4 + shareRepairPublicPackageMessage shareRepairMessageType = 5 + shareRepairInstalledAcknowledgementMessage shareRepairMessageType = 6 + shareRepairCompletionMessage shareRepairMessageType = 7 ) type shareRepairMessage struct { @@ -47,7 +49,9 @@ func (message shareRepairMessage) contentHash() [32]byte { type shareRepairBus interface { Subscribe(group.MemberIndex) <-chan shareRepairMessage Start() - Broadcast(shareRepairMessage) + // 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 { @@ -112,13 +116,16 @@ func (bus *inProcessShareRepairBus) Subscribe(member group.MemberIndex) <-chan s func (*inProcessShareRepairBus) Start() {} -func (bus *inProcessShareRepairBus) Broadcast(message shareRepairMessage) { +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" @@ -139,7 +146,8 @@ func (*shareRepairTransportMessage) Type() string { return shareRepairTransportT // ephemeral-key-length(2) || ephemeral-key || payload. func (message *shareRepairTransportMessage) Marshal() ([]byte, error) { value := message.message - if value.Type < shareRepairAnnouncementMessage || value.Type > shareRepairPublicPackageMessage || + if value.Type < shareRepairAnnouncementMessage || + value.Type > shareRepairCompletionMessage || value.Sender == 0 || value.ContextDigest == [32]byte{} || len(value.Payload) > shareRepairMaximumPublicPayload { return nil, fmt.Errorf("share-repair transport message is invalid") @@ -160,6 +168,16 @@ func (message *shareRepairTransportMessage) Marshal() ([]byte, error) { if value.Recipient != 0 || len(value.EphemeralPublicKey) != 0 || len(value.Payload) == 0 { return nil, fmt.Errorf("share-repair public-package message shape is invalid") } + case shareRepairInstalledAcknowledgementMessage: + if value.Recipient == 0 || len(value.EphemeralPublicKey) != 0 || + len(value.Payload) != sha256.Size { + return nil, fmt.Errorf("share-repair installed acknowledgement shape is invalid") + } + case shareRepairCompletionMessage: + if value.Recipient != 0 || len(value.EphemeralPublicKey) != 0 || + len(value.Payload) != sha256.Size { + return nil, fmt.Errorf("share-repair completion shape is invalid") + } default: if value.Recipient == 0 || len(value.EphemeralPublicKey) != 0 || len(value.Payload) == 0 || len(value.Payload) > shareRepairMaximumSecretPayload { @@ -185,7 +203,8 @@ func (message *shareRepairTransportMessage) Unmarshal(data []byte) error { messageType := shareRepairMessageType(data[0]) rawSender := binary.BigEndian.Uint32(data[1:5]) rawRecipient := binary.BigEndian.Uint32(data[5:9]) - if messageType < shareRepairAnnouncementMessage || messageType > shareRepairPublicPackageMessage || + if messageType < shareRepairAnnouncementMessage || + messageType > shareRepairCompletionMessage || rawSender == 0 || rawSender > uint32(group.MaxMemberIndex) || rawRecipient > uint32(group.MaxMemberIndex) { return fmt.Errorf("share-repair transport header is invalid") @@ -224,6 +243,14 @@ func (message *shareRepairTransportMessage) Unmarshal(data []byte) error { if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) == 0 { return fmt.Errorf("share-repair public-package message shape is invalid") } + case shareRepairInstalledAcknowledgementMessage: + if rawRecipient == 0 || ephemeralLength != 0 || len(value.Payload) != sha256.Size { + return fmt.Errorf("share-repair installed acknowledgement shape is invalid") + } + case shareRepairCompletionMessage: + if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) != sha256.Size { + return fmt.Errorf("share-repair completion shape is invalid") + } default: if rawRecipient == 0 || ephemeralLength != 0 || len(value.Payload) == 0 || len(value.Payload) > shareRepairMaximumSecretPayload { @@ -294,16 +321,20 @@ func (bus *broadcastChannelShareRepairBus) deliver(message shareRepairMessage) { } } -func (bus *broadcastChannelShareRepairBus) Broadcast(message shareRepairMessage) { +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( - bus.ctx, + 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) { diff --git a/pkg/frost/signing/share_repair_bus_frost_native_test.go b/pkg/frost/signing/share_repair_bus_frost_native_test.go index ef568f81c2..f609d10ba6 100644 --- a/pkg/frost/signing/share_repair_bus_frost_native_test.go +++ b/pkg/frost/signing/share_repair_bus_frost_native_test.go @@ -5,12 +5,28 @@ package signing import ( "bytes" "context" + "crypto/sha256" "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 (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, @@ -65,6 +81,112 @@ func TestShareRepairTransportRejectsMalformedFrames(t *testing.T) { } } +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, + ) + 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{} diff --git a/pkg/frost/signing/share_repair_runner_frost_native.go b/pkg/frost/signing/share_repair_runner_frost_native.go index 77f4db16cd..d46f5a4d89 100644 --- a/pkg/frost/signing/share_repair_runner_frost_native.go +++ b/pkg/frost/signing/share_repair_runner_frost_native.go @@ -6,10 +6,10 @@ import ( "bytes" "context" "crypto/ed25519" + "crypto/sha256" "encoding/json" "fmt" "io" - "time" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/crypto/ephemeral" @@ -212,28 +212,12 @@ func (runner *shareRepairRunner) run( ContextDigest: runner.authorizationDigest, EphemeralPublicKey: runner.ephemeralPublic, } - runner.bus.Broadcast(announcement) - stopAnnouncements := make(chan struct{}) - announcementsStopped := make(chan struct{}) - go func() { - defer close(announcementsStopped) - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-stopAnnouncements: - return - case <-ticker.C: - runner.bus.Broadcast(announcement) - } - } - }() - defer func() { - close(stopAnnouncements) - <-announcementsStopped - }() + // 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() publicKeys, err := runner.collectAnnouncements(ctx) if err != nil { @@ -245,7 +229,7 @@ func (runner *shareRepairRunner) run( if _, helper := runner.helperSet[runner.member]; !helper { return nil, fmt.Errorf("seat is neither target nor helper") } - return nil, runner.runHelper(ctx, publicKeys) + return nil, runner.runHelper(ctx, publicKeys, cancelAnnouncement) } func (runner *shareRepairRunner) collectAnnouncements( @@ -363,6 +347,7 @@ func decodeShareRepairSealedSecret( func (runner *shareRepairRunner) runHelper( ctx context.Context, publicKeys map[group.MemberIndex]*ephemeral.PublicKey, + cancelAnnouncement context.CancelFunc, ) error { part1, err := runner.engine.ShareRepairPart1( runner.authorization, @@ -395,12 +380,29 @@ func (runner *shareRepairRunner) runHelper( if len(publicPackage) > shareRepairMaximumPublicPayload { return fmt.Errorf("repair public key package exceeds the transport cap") } - runner.bus.Broadcast(shareRepairMessage{ + // 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 || @@ -429,13 +431,13 @@ func (runner *shareRepairRunner) runHelper( return fmt.Errorf("seal repair delta for [%d]: %w", recipient, err) } zeroBytes(delta.Data) - runner.bus.Broadcast(shareRepairMessage{ + preReceiptBroadcasts = append(preReceiptBroadcasts, runner.bus.Broadcast(shareRepairMessage{ Type: shareRepairDeltaMessage, Sender: runner.member, Recipient: group.MemberIndex(recipient), ContextDigest: runner.authorizationDigest, Payload: sealed, - }) + })) } deltas := make(map[uint16]*NativeShareRepairDelta, len(runner.helperSet)) @@ -528,14 +530,14 @@ func (runner *shareRepairRunner) runHelper( return fmt.Errorf("seal repair sigma: %w", err) } zeroBytes(part2.Sigma.Data) - runner.bus.Broadcast(shareRepairMessage{ + preReceiptBroadcasts = append(preReceiptBroadcasts, runner.bus.Broadcast(shareRepairMessage{ Type: shareRepairSigmaMessage, Sender: runner.member, Recipient: group.MemberIndex(runner.authorization.TargetIdentifier), ContextDigest: runner.authorizationDigest, Payload: sealed, - }) - return runner.waitForInstalledReceipt(ctx) + })) + return runner.waitForInstalledReceipt(ctx, cancelPreReceiptBroadcasts) } func (runner *shareRepairRunner) runTarget( @@ -651,6 +653,22 @@ func (runner *shareRepairRunner) runTarget( 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, @@ -662,18 +680,72 @@ func (runner *shareRepairRunner) runTarget( Idempotent: result.Idempotent, }) if err != nil { - return nil, fmt.Errorf("encode repaired-share installed receipt: %w", err) + return fmt.Errorf("encode repaired-share installed receipt: %w", err) } - runner.bus.Broadcast(shareRepairMessage{ + 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, }) - return result, nil + 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) waitForInstalledReceipt(ctx context.Context) error { +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 { @@ -702,6 +774,46 @@ func (runner *shareRepairRunner) waitForInstalledReceipt(ctx context.Context) er 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 index d3a53e1635..8ec416de64 100644 --- a/pkg/frost/signing/share_repair_runner_frost_native_test.go +++ b/pkg/frost/signing/share_repair_runner_frost_native_test.go @@ -5,7 +5,9 @@ package signing import ( "bytes" "context" + "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "sync" "testing" @@ -139,14 +141,44 @@ type recordingShareRepairBus struct { messages []shareRepairMessage } -func (bus *recordingShareRepairBus) Broadcast(message 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() - bus.shareRepairBus.Broadcast(message) + 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) { @@ -158,7 +190,13 @@ func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { t.Fatal(err) } engine := &testShareRepairEngine{} - bus := &recordingShareRepairBus{shareRepairBus: newInProcessShareRepairBus(128)} + bus := &recordingShareRepairBus{ + shareRepairBus: &subscriptionBarrierShareRepairBus{ + shareRepairBus: newInProcessShareRepairBus(128), + expected: 3, + ready: make(chan struct{}), + }, + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -215,6 +253,8 @@ func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { 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 { @@ -235,6 +275,22 @@ func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { } 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") @@ -252,6 +308,15 @@ func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { 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) { @@ -259,7 +324,8 @@ func TestRunShareRepairOnBusTimesOutWithoutExactParticipantSet(t *testing.T) { t.Cleanup(resetShareRepairActivationRegistryForTest) authorization, _ := testShareRepairAuthorization(t) digest, _ := ComputeShareRepairAuthorizationDigest(authorization) - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + bus := &recordingShareRepairBus{shareRepairBus: newInProcessShareRepairBus(16)} + ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond) defer cancel() _, err := runShareRepairOnBus( ctx, @@ -267,11 +333,389 @@ func TestRunShareRepairOnBusTimesOutWithoutExactParticipantSet(t *testing.T) { authorization, digest, []group.MemberIndex{1}, - newInProcessShareRepairBus(16), + bus, ) if err == nil || !bytes.Contains([]byte(err.Error()), []byte("context deadline exceeded")) { t.Fatalf("expected exact-set announcement timeout, got [%v]", err) } + 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, + ) + } +} + +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, _ := testShareRepairAuthorization(t) + digest, err := ComputeShareRepairAuthorizationDigest(authorization) + if err != nil { + t.Fatal(err) + } + localKeyPair, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + defer localKeyPair.PrivateKey.Zero() + bus := &cancelTrackingShareRepairBus{ + broadcasts: make(chan *cancelTrackingShareRepairBroadcast, 8), + } + stream := make(chan shareRepairMessage, 4) + runner := &shareRepairRunner{ + member: 1, + authorization: authorization, + 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, + ephemeralPrivate: localKeyPair.PrivateKey, + ephemeralPublic: localKeyPair.PublicKey.Marshal(), + } + 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} { + peerKeyPair, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + stream <- shareRepairMessage{ + Type: shareRepairAnnouncementMessage, + Sender: peer, + ContextDigest: digest, + EphemeralPublicKey: peerKeyPair.PublicKey.Marshal(), + } + peerKeyPair.PrivateKey.Zero() + } + + 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) { From b53c3911d0b41908e442f4e0f545285dbeadb0fe Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 23:02:33 -0400 Subject: [PATCH 4/7] fix(frost): enforce native share-repair custody --- .github/workflows/frost-cgo-integration.yml | 5 + ci/frost-signer-pin.env | 2 +- docs/development/frost-share-repair.adoc | 106 +++- ...e_tbtc_signer_registration_frost_native.go | 547 ++++++++++++++---- ...c_signer_registration_frost_native_test.go | 275 +++++++-- .../signing/native_tbtc_signer_abi_version.go | 28 +- .../native_tbtc_signer_abi_version_test.go | 30 +- .../signing/share_repair_bus_frost_native.go | 39 +- .../share_repair_bus_frost_native_test.go | 60 ++ .../signing/share_repair_frost_native.go | 322 +++++++++-- .../signing/share_repair_frost_native_test.go | 214 ++++++- .../share_repair_runner_frost_native.go | 500 +++++++++------- .../share_repair_runner_frost_native_test.go | 490 ++++++++++++++-- ...t_share_repair_maintenance_frost_native.go | 20 +- ...hare_repair_transport_preflight_default.go | 22 + ...repair_transport_preflight_frost_native.go | 160 +++++ ...r_transport_preflight_frost_native_test.go | 451 +++++++++++++++ pkg/tbtc/node.go | 34 +- pkg/tbtc/tbtc.go | 19 +- 19 files changed, 2747 insertions(+), 577 deletions(-) create mode 100644 pkg/tbtc/frost_share_repair_transport_preflight_default.go create mode 100644 pkg/tbtc/frost_share_repair_transport_preflight_frost_native.go create mode 100644 pkg/tbtc/frost_share_repair_transport_preflight_frost_native_test.go 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 65491c4f3e..637fad9562 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=98357a9926e44e8f6477b852a7a55e43dfb96fd4 +FROST_SIGNER_MIRROR_REF=35259de9aeb2daa25429a2630f1e8290240e500f diff --git a/docs/development/frost-share-repair.adoc b/docs/development/frost-share-repair.adoc index c0ac34686a..e0aae2d173 100644 --- a/docs/development/frost-share-repair.adoc +++ b/docs/development/frost-share-repair.adoc @@ -6,11 +6,14 @@ 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 three independent safety boundaries: +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 @@ -18,9 +21,15 @@ The repair primitive is wrapped by three independent safety boundaries: pinned in the production activation manifest and exchanged on every message from a recovered seat. -No raw key package or signing share crosses the Rust ABI. Helper deltas and -sigmas are encrypted to per-run ephemeral keys, are endpoint/context bound, -and are zeroed after use. +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 @@ -39,7 +48,8 @@ Do not start recovery until all of these are true: . 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, and uses ABI 4.5 or newer. + 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. @@ -47,24 +57,79 @@ Do not start recovery until all of these are true: 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 authorization in an owner-only regular file on exactly the +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-authorization.json" +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 -digest. Only locally controlled seats named by the authorization participate. +`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. All participating processes exit successfully when maintenance completes. This is intentional. Remove `FrostShareRepairAuthorizationPath` before every @@ -137,8 +202,10 @@ recovered seat's legacy, missing, or stale lease frames. | Crash point | Required response | Before target install -| Retry the same signed authorization while it remains valid. Partial network - deltas and sigmas are not durable. +| 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 @@ -187,9 +254,10 @@ cargo test share_repair_ -- --nocapture cargo test share_repair_production_scale_51_of_100_launch_gate \ -- --ignored --nocapture -# Go authorization, confidentiality, exact-set, activation, and restart guard +# Go authorization, native preflight/roster, confidentiality, exact-set, +# activation, and restart guard go test -tags=frost_native ./pkg/frost/signing \ - -run 'TestShareRepair|TestRunShareRepair|TestActivationBound' + -run 'TestShareRepair|TestPrepareShareRepair|TestRunShareRepair|TestActivationBound' # Build/readiness compatibility go test ./pkg/tbtc ./cmd @@ -199,7 +267,11 @@ 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, and a partially upgraded fleet. Capture the -v2 inventory commitment, anchor acknowledgement, old-stream tombstone, -activation registry root, and fleet handshake attestations as the incident -record. Never capture scalar payloads, key packages, or debug memory dumps. +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/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 de2a345473..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,14 @@ 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 @@ -196,6 +204,30 @@ 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"); @@ -374,6 +406,8 @@ static int tbtc_signer_free_buffer_available(void) { 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; @@ -503,46 +537,72 @@ type buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse struct { RetiredKeyPackageCount uint16 `json:"retired_key_package_count"` } -type buildTaggedTBTCSignerShareRepairPart1Request struct { - Authorization *ShareRepairAuthorization `json:"authorization"` - HelperIdentifier uint16 `json:"helper_identifier"` +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 buildTaggedTBTCSignerShareRepairDelta struct { - ContextDigest string `json:"context_digest"` - SenderIdentifier uint16 `json:"sender_identifier"` - RecipientIdentifier uint16 `json:"recipient_identifier"` - DataHex json.RawMessage `json:"data_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 []buildTaggedTBTCSignerShareRepairDelta `json:"deltas"` + Deltas []buildTaggedTBTCSignerShareRepairEncryptedDelta `json:"deltas"` } type buildTaggedTBTCSignerShareRepairPart2Request struct { - Authorization *ShareRepairAuthorization `json:"authorization"` - HelperIdentifier uint16 `json:"helper_identifier"` - Deltas []buildTaggedTBTCSignerShareRepairDelta `json:"deltas"` + Authorization *ShareRepairAuthorization `json:"authorization"` + HelperIdentifier uint16 `json:"helper_identifier"` + Deltas []buildTaggedTBTCSignerShareRepairEncryptedDelta `json:"deltas"` + TransportRoster *ShareRepairTransportRoster `json:"transport_roster"` } -type buildTaggedTBTCSignerShareRepairSigma struct { - ContextDigest string `json:"context_digest"` - HelperIdentifier uint16 `json:"helper_identifier"` - DataHex json.RawMessage `json:"data_hex"` +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 *buildTaggedTBTCSignerShareRepairSigma `json:"sigma"` + ContextDigest string `json:"context_digest"` + Sigma *buildTaggedTBTCSignerShareRepairEncryptedSigma `json:"sigma"` } type buildTaggedTBTCSignerInstallRepairedShareRequest struct { Authorization *ShareRepairAuthorization `json:"authorization"` PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` - Sigmas []buildTaggedTBTCSignerShareRepairSigma `json:"sigmas"` + Sigmas []buildTaggedTBTCSignerShareRepairEncryptedSigma `json:"sigmas"` + TransportRoster *ShareRepairTransportRoster `json:"transport_roster"` } type buildTaggedTBTCSignerInstallRepairedShareResponse struct { @@ -817,13 +877,65 @@ 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 @@ -832,46 +944,54 @@ func (bttse *buildTaggedTBTCSignerEngine) ShareRepairPart1( if err != nil { return nil, err } - defer zeroBytes(responsePayload) - return decodeBuildTaggedTBTCSignerShareRepairPart1Response(responsePayload) + return decodeBuildTaggedTBTCSignerShareRepairPart1Response( + responsePayload, + authorization, + helperIdentifier, + ) } func (bttse *buildTaggedTBTCSignerEngine) ShareRepairPart2( authorization *ShareRepairAuthorization, helperIdentifier uint16, - deltas []*NativeShareRepairDelta, + deltas []*NativeShareRepairEncryptedDelta, + transportRoster *ShareRepairTransportRoster, ) (*NativeShareRepairPart2Result, error) { requestPayload, err := buildTaggedTBTCSignerShareRepairPart2RequestPayload( authorization, helperIdentifier, deltas, + transportRoster, ) if err != nil { return nil, err } - defer zeroBytes(requestPayload) responsePayload, err := callBuildTaggedTBTCSignerShareRepairPart2(requestPayload) if err != nil { return nil, err } - defer zeroBytes(responsePayload) - return decodeBuildTaggedTBTCSignerShareRepairPart2Response(responsePayload) + return decodeBuildTaggedTBTCSignerShareRepairPart2Response( + responsePayload, + authorization, + helperIdentifier, + ) } func (bttse *buildTaggedTBTCSignerEngine) InstallRepairedShare( authorization *ShareRepairAuthorization, publicKeyPackage *NativeFROSTPublicKeyPackage, - sigmas []*NativeShareRepairSigma, + sigmas []*NativeShareRepairEncryptedSigma, + transportRoster *ShareRepairTransportRoster, ) (*NativeShareRepairInstallResult, error) { requestPayload, err := buildTaggedTBTCSignerInstallRepairedShareRequestPayload( authorization, publicKeyPackage, sigmas, + transportRoster, ) if err != nil { return nil, err } - defer zeroBytes(requestPayload) responsePayload, err := callBuildTaggedTBTCSignerInstallRepairedShare(requestPayload) if err != nil { return nil, err @@ -1282,50 +1402,135 @@ 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 := ComputeShareRepairAuthorizationDigest(authorization); err != nil { - return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + 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, - deltas []*NativeShareRepairDelta, -) ([]buildTaggedTBTCSignerShareRepairDelta, error) { - result := make([]buildTaggedTBTCSignerShareRepairDelta, len(deltas)) + 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 || len(delta.Data) != 32 { - for prior := 0; prior < index; prior++ { - zeroBytes(result[prior].DataHex) - } + if delta == nil || delta.ContextDigest != contextDigest || + delta.SenderIdentifier != authorization.HelperIdentifiers[index] || + delta.RecipientIdentifier != helperIdentifier || + len(delta.Payload) != buildTaggedTBTCSignerShareRepairPayloadLength { return nil, buildTaggedTBTCSignerOperationError( op, - fmt.Sprintf("repair delta [%d] is nil or not 32 bytes", index), + fmt.Sprintf("encrypted delta [%d] is invalid or out of order", index), ) } - dataHex, err := encodeShareRepairSecretHexJSON(delta.Data) - if err != nil { - for prior := 0; prior < index; prior++ { - zeroBytes(result[prior].DataHex) - } - return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) - } - result[index] = buildTaggedTBTCSignerShareRepairDelta{ + result[index] = buildTaggedTBTCSignerShareRepairEncryptedDelta{ ContextDigest: delta.ContextDigest, SenderIdentifier: delta.SenderIdentifier, RecipientIdentifier: delta.RecipientIdentifier, - DataHex: dataHex, + PayloadHex: hex.EncodeToString(delta.Payload), } } return result, nil @@ -1334,57 +1539,66 @@ func buildTaggedTBTCSignerShareRepairDeltaPayloads( func buildTaggedTBTCSignerShareRepairPart2RequestPayload( authorization *ShareRepairAuthorization, helperIdentifier uint16, - deltas []*NativeShareRepairDelta, + deltas []*NativeShareRepairEncryptedDelta, + transportRoster *ShareRepairTransportRoster, ) ([]byte, error) { const op = "ShareRepairPart2" - if _, err := ComputeShareRepairAuthorizationDigest(authorization); err != nil { - return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + if err := validateBuildTaggedTBTCSignerShareRepairTransportRoster( + op, + authorization, + transportRoster, + ); err != nil { + return nil, err } - wireDeltas, err := buildTaggedTBTCSignerShareRepairDeltaPayloads(op, deltas) + wireDeltas, err := buildTaggedTBTCSignerShareRepairDeltaPayloads( + op, + authorization, + helperIdentifier, + deltas, + ) if err != nil { return nil, err } - defer func() { - for index := range wireDeltas { - zeroBytes(wireDeltas[index].DataHex) - } - }() return buildTaggedTBTCSignerMarshalRequest( op, buildTaggedTBTCSignerShareRepairPart2Request{ Authorization: authorization, HelperIdentifier: helperIdentifier, Deltas: wireDeltas, + TransportRoster: transportRoster, }, ) } func buildTaggedTBTCSignerShareRepairSigmaPayloads( op string, - sigmas []*NativeShareRepairSigma, -) ([]buildTaggedTBTCSignerShareRepairSigma, error) { - result := make([]buildTaggedTBTCSignerShareRepairSigma, len(sigmas)) + 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 || len(sigma.Data) != 32 { - for prior := 0; prior < index; prior++ { - zeroBytes(result[prior].DataHex) - } + if sigma == nil || sigma.ContextDigest != contextDigest || + sigma.HelperIdentifier != authorization.HelperIdentifiers[index] || + len(sigma.Payload) != buildTaggedTBTCSignerShareRepairPayloadLength { return nil, buildTaggedTBTCSignerOperationError( op, - fmt.Sprintf("repair sigma [%d] is nil or not 32 bytes", index), + fmt.Sprintf("encrypted sigma [%d] is invalid or out of order", index), ) } - dataHex, err := encodeShareRepairSecretHexJSON(sigma.Data) - if err != nil { - for prior := 0; prior < index; prior++ { - zeroBytes(result[prior].DataHex) - } - return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) - } - result[index] = buildTaggedTBTCSignerShareRepairSigma{ + result[index] = buildTaggedTBTCSignerShareRepairEncryptedSigma{ ContextDigest: sigma.ContextDigest, HelperIdentifier: sigma.HelperIdentifier, - DataHex: dataHex, + PayloadHex: hex.EncodeToString(sigma.Payload), } } return result, nil @@ -1393,28 +1607,33 @@ func buildTaggedTBTCSignerShareRepairSigmaPayloads( func buildTaggedTBTCSignerInstallRepairedShareRequestPayload( authorization *ShareRepairAuthorization, publicKeyPackage *NativeFROSTPublicKeyPackage, - sigmas []*NativeShareRepairSigma, + sigmas []*NativeShareRepairEncryptedSigma, + transportRoster *ShareRepairTransportRoster, ) ([]byte, error) { const op = "InstallRepairedShare" - if _, err := ComputeShareRepairAuthorizationDigest(authorization); err != nil { - return nil, buildTaggedTBTCSignerOperationError(op, err.Error()) + 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, sigmas) + wireSigmas, err := buildTaggedTBTCSignerShareRepairSigmaPayloads( + op, + authorization, + sigmas, + ) if err != nil { return nil, err } - defer func() { - for index := range wireSigmas { - zeroBytes(wireSigmas[index].DataHex) - } - }() return buildTaggedTBTCSignerMarshalRequest( op, buildTaggedTBTCSignerInstallRepairedShareRequest{ - Authorization: authorization, + Authorization: authorization, + TransportRoster: transportRoster, PublicKeyPackage: &buildTaggedTBTCSignerNativeFROSTPublicKeyPackage{ VerifyingShares: publicKeyPackage.VerifyingShares, VerifyingKey: publicKeyPackage.VerifyingKey, @@ -1424,41 +1643,109 @@ func buildTaggedTBTCSignerInstallRepairedShareRequestPayload( ) } +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 { - for index := range response.Deltas { - zeroBytes(response.Deltas[index].DataHex) - } return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("cannot decode response: %v", err)) } - defer func() { - for index := range response.Deltas { - zeroBytes(response.Deltas[index].DataHex) - } - }() - if response.PublicKeyPackage == nil || + 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 == "" { - return nil, buildTaggedTBTCSignerOperationError(op, "response public key package is absent") + response.PublicKeyPackage.VerifyingKey == "" || + len(response.Deltas) != len(authorization.HelperIdentifiers) { + return nil, buildTaggedTBTCSignerOperationError(op, "response has the wrong context or shape") } - deltas := make([]*NativeShareRepairDelta, len(response.Deltas)) + deltas := make([]*NativeShareRepairEncryptedDelta, len(response.Deltas)) for index, delta := range response.Deltas { - data, err := decodeShareRepairSecretHexJSON(delta.DataHex) - if err != nil || len(data) != 32 { - for prior := 0; prior < index; prior++ { - zeroBytes(deltas[prior].Data) - } - return nil, buildTaggedTBTCSignerOperationError(op, fmt.Sprintf("invalid delta [%d]", index)) + 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] = &NativeShareRepairDelta{ + deltas[index] = &NativeShareRepairEncryptedDelta{ ContextDigest: delta.ContextDigest, SenderIdentifier: delta.SenderIdentifier, RecipientIdentifier: delta.RecipientIdentifier, - Data: data, + Payload: payload, } } return &NativeShareRepairPart1Result{ @@ -1476,29 +1763,37 @@ func decodeBuildTaggedTBTCSignerShareRepairPart1Response( func decodeBuildTaggedTBTCSignerShareRepairPart2Response( responsePayload []byte, + authorization *ShareRepairAuthorization, + helperIdentifier uint16, ) (*NativeShareRepairPart2Result, error) { const op = "ShareRepairPart2" response := &buildTaggedTBTCSignerShareRepairPart2Response{} - if err := json.Unmarshal(responsePayload, response); err != nil { - if response.Sigma != nil { - zeroBytes(response.Sigma.DataHex) - } + if err := json.Unmarshal(responsePayload, response); err != nil || response.Sigma == nil { return nil, buildTaggedTBTCSignerOperationError(op, "cannot decode response sigma") } - if 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") } - defer zeroBytes(response.Sigma.DataHex) - data, err := decodeShareRepairSecretHexJSON(response.Sigma.DataHex) - if err != nil || len(data) != 32 { - return nil, buildTaggedTBTCSignerOperationError(op, "response sigma is invalid") + payload, err := decodeCanonicalShareRepairHex( + op, + "sigma.payload_hex", + response.Sigma.PayloadHex, + buildTaggedTBTCSignerShareRepairPayloadLength, + ) + if err != nil { + return nil, err } return &NativeShareRepairPart2Result{ ContextDigest: response.ContextDigest, - Sigma: &NativeShareRepairSigma{ + Sigma: &NativeShareRepairEncryptedSigma{ ContextDigest: response.Sigma.ContextDigest, HelperIdentifier: response.Sigma.HelperIdentifier, - Data: data, + Payload: payload, }, }, nil } @@ -2350,6 +2645,30 @@ 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) { 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 36e341f9ae..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 @@ -126,7 +126,7 @@ func TestRealCgoShareRepairSymbolsResolve(t *testing.T) { } if !buildTaggedTBTCSignerShareRepairSymbolsAvailable() { - t.Fatal("ABI 4.5 library is missing one or more share-repair symbols") + t.Fatal("ABI 5.0 library is missing one or more native-custody share-repair symbols") } } @@ -350,142 +350,244 @@ func TestBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesPayloadAndResponse( } func TestBuildTaggedTBTCSignerShareRepairPayloadsAndResponses(t *testing.T) { - authorization, _ := testShareRepairAuthorization(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) - secret := bytes.Repeat([]byte{0xab}, 32) - defer zeroBytes(secret) - delta := &NativeShareRepairDelta{ - ContextDigest: contextDigest, - SenderIdentifier: 1, - RecipientIdentifier: 2, - Data: secret, + localPublicKey, err := hex.DecodeString( + transportRoster.ParticipantPublicKeys[0].PublicKeyHex, + ) + if err != nil { + t.Fatal(err) } + payload1 := bytesOf(0xa1, buildTaggedTBTCSignerShareRepairPayloadLength) + payload2 := bytesOf(0xa2, buildTaggedTBTCSignerShareRepairPayloadLength) - part2Payload, err := buildTaggedTBTCSignerShareRepairPart2RequestPayload( + beginPayload, err := buildTaggedTBTCSignerShareRepairSessionRequestPayload( + "BeginShareRepairSession", authorization, - 2, - []*NativeShareRepairDelta{delta}, + 1, ) if err != nil { t.Fatal(err) } - defer zeroBytes(part2Payload) - part2Request := &buildTaggedTBTCSignerShareRepairPart2Request{} - if err := json.Unmarshal(part2Payload, part2Request); err != nil { + var beginRequest buildTaggedTBTCSignerShareRepairSessionRequest + if err := json.Unmarshal(beginPayload, &beginRequest); err != nil { t.Fatal(err) } - if part2Request.Authorization == nil || - part2Request.Authorization.SessionID != authorization.SessionID || - part2Request.HelperIdentifier != 2 || len(part2Request.Deltas) != 1 { - t.Fatal("share-repair Part2 bridge request lost its authorization or endpoint") + if beginRequest.Authorization == nil || + beginRequest.Authorization.SessionID != authorization.SessionID || + beginRequest.ParticipantIdentifier != 1 { + t.Fatal("share-repair begin request lost its authorization or participant") } - decodedDelta, err := decodeShareRepairSecretHexJSON( - part2Request.Deltas[0].DataHex, + 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, ) - zeroBytes(part2Request.Deltas[0].DataHex) - if err != nil || !bytes.Equal(decodedDelta, secret) { - zeroBytes(decodedDelta) - t.Fatalf("share-repair Part2 bridge secret did not round trip: %v", err) + 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) } - zeroBytes(decodedDelta) - secretWire, err := encodeShareRepairSecretHexJSON(secret) + part1RequestPayload, err := buildTaggedTBTCSignerShareRepairPart1RequestPayload( + authorization, + 1, + transportRoster, + ) if err != nil { t.Fatal(err) } - part1Wire := buildTaggedTBTCSignerShareRepairPart1Response{ + 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"}, + VerifyingShares: map[string]string{"1": "share-1", "2": "share-2"}, VerifyingKey: "group-key", }, - Deltas: []buildTaggedTBTCSignerShareRepairDelta{{ - ContextDigest: contextDigest, - SenderIdentifier: 1, - RecipientIdentifier: 2, - DataHex: secretWire, - }}, - } - part1Payload, err := json.Marshal(part1Wire) - zeroBytes(secretWire) + 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) } - defer zeroBytes(part1Payload) - part1, err := decodeBuildTaggedTBTCSignerShareRepairPart1Response(part1Payload) + part1, err := decodeBuildTaggedTBTCSignerShareRepairPart1Response( + part1Payload, + authorization, + 1, + ) if err != nil { t.Fatal(err) } - if len(part1.Deltas) != 1 || !bytes.Equal(part1.Deltas[0].Data, secret) { - t.Fatal("share-repair Part1 bridge response lost its secret delta") + 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, + }, } - zeroBytes(part1.Deltas[0].Data) - secretWire, err = encodeShareRepairSecretHexJSON(secret) + 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: &buildTaggedTBTCSignerShareRepairSigma{ + Sigma: &buildTaggedTBTCSignerShareRepairEncryptedSigma{ ContextDigest: contextDigest, - HelperIdentifier: 1, - DataHex: secretWire, + HelperIdentifier: 2, + PayloadHex: hex.EncodeToString(payload1), }, }, ) - zeroBytes(secretWire) if err != nil { t.Fatal(err) } - defer zeroBytes(part2ResponsePayload) part2, err := decodeBuildTaggedTBTCSignerShareRepairPart2Response( part2ResponsePayload, + authorization, + 2, ) if err != nil { t.Fatal(err) } - if part2.Sigma == nil || !bytes.Equal(part2.Sigma.Data, secret) { - t.Fatal("share-repair Part2 bridge response lost its secret sigma") + 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"}, + VerifyingShares: map[string]string{"1": "share-1", "2": "share-2"}, VerifyingKey: "group-key", }, - []*NativeShareRepairSigma{part2.Sigma}, + sigmas, + transportRoster, ) - zeroBytes(part2.Sigma.Data) if err != nil { t.Fatal(err) } - defer zeroBytes(installPayload) 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) != 1 { + len(installRequest.Sigmas) != 2 || installRequest.TransportRoster == nil || + installRequest.TransportRoster.SignatureHex != transportRoster.SignatureHex { t.Fatal("share-repair install bridge request lost its bound inputs") } - decodedSigma, err := decodeShareRepairSecretHexJSON( - installRequest.Sigmas[0].DataHex, - ) - zeroBytes(installRequest.Sigmas[0].DataHex) - if err != nil || !bytes.Equal(decodedSigma, secret) { - zeroBytes(decodedSigma) - t.Fatalf("share-repair install bridge secret did not round trip: %v", err) + 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") } - zeroBytes(decodedSigma) installResponsePayload, err := json.Marshal( buildTaggedTBTCSignerInstallRepairedShareResponse{ @@ -511,6 +613,51 @@ func TestBuildTaggedTBTCSignerShareRepairPayloadsAndResponses(t *testing.T) { } } +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 2803d8525a..65942b1301 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -24,27 +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. - // - // Minor 5 adds the three context-bound share-repair symbols. A DR-capable - // host must reject ABI 4.4 before it can reach a missing dlsym entry. - requiredTBTCSignerABIMinMinor uint32 = 5 + // 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 e2114d0e4f..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. Minor 5 adds share repair. The matching library version is compatible; ABI 4.4 and a - // different major are not. - if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 5 { + // 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,20 +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(requiredTBTCSignerABIMajor, 4); err == nil { - t.Fatal("ABI 4.4 without share-repair symbols 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/share_repair_bus_frost_native.go b/pkg/frost/signing/share_repair_bus_frost_native.go index a8b6ae858e..cd5be3b95b 100644 --- a/pkg/frost/signing/share_repair_bus_frost_native.go +++ b/pkg/frost/signing/share_repair_bus_frost_native.go @@ -132,8 +132,13 @@ const shareRepairTransportType = "frost/share_repair/v1" const ( shareRepairEphemeralPublicKeyLength = 33 - shareRepairMaximumSecretPayload = 4 * 1024 - shareRepairMaximumPublicPayload = 256 * 1024 + // 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 + shareRepairMaximumPublicPayload = 256 * 1024 ) type shareRepairTransportMessage struct { @@ -178,9 +183,9 @@ func (message *shareRepairTransportMessage) Marshal() ([]byte, error) { len(value.Payload) != sha256.Size { return nil, fmt.Errorf("share-repair completion shape is invalid") } - default: + case shareRepairDeltaMessage, shareRepairSigmaMessage: if value.Recipient == 0 || len(value.EphemeralPublicKey) != 0 || - len(value.Payload) == 0 || len(value.Payload) > shareRepairMaximumSecretPayload { + len(value.Payload) != shareRepairEncryptedScalarPayloadLength { return nil, fmt.Errorf("share-repair secret message shape is invalid") } } @@ -251,9 +256,9 @@ func (message *shareRepairTransportMessage) Unmarshal(data []byte) error { if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) != sha256.Size { return fmt.Errorf("share-repair completion shape is invalid") } - default: + case shareRepairDeltaMessage, shareRepairSigmaMessage: if rawRecipient == 0 || ephemeralLength != 0 || len(value.Payload) == 0 || - len(value.Payload) > shareRepairMaximumSecretPayload { + len(value.Payload) != shareRepairEncryptedScalarPayloadLength { return fmt.Errorf("share-repair secret message shape is invalid") } } @@ -266,6 +271,7 @@ type broadcastChannelShareRepairBus struct { logger log.StandardLogger channel net.BroadcastChannel membershipValidator *group.MembershipValidator + participants map[group.MemberIndex]struct{} mutex sync.Mutex subscribers []*shareRepairBusSubscriber startOnce sync.Once @@ -276,10 +282,19 @@ func newBroadcastChannelShareRepairBus( logger log.StandardLogger, channel net.BroadcastChannel, membershipValidator *group.MembershipValidator, + participants map[group.MemberIndex]struct{}, ) (shareRepairBus, error) { - if ctx == nil || channel == nil || membershipValidator == nil { + if ctx == nil || channel == nil || membershipValidator == nil || + len(participants) == 0 { 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") } @@ -291,6 +306,7 @@ func newBroadcastChannelShareRepairBus( logger: logger, channel: channel, membershipValidator: membershipValidator, + participants: participantCopy, }, nil } @@ -342,6 +358,15 @@ func (bus *broadcastChannelShareRepairBus) handleMessage(message net.Message) { if !ok { 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(), diff --git a/pkg/frost/signing/share_repair_bus_frost_native_test.go b/pkg/frost/signing/share_repair_bus_frost_native_test.go index f609d10ba6..16131ba24f 100644 --- a/pkg/frost/signing/share_repair_bus_frost_native_test.go +++ b/pkg/frost/signing/share_repair_bus_frost_native_test.go @@ -151,6 +151,7 @@ func TestShareRepairBusCancelsRetransmissionsPerMessage(t *testing.T) { &testutils.MockLogger{}, channel, fixture.validator, + map[group.MemberIndex]struct{}{1: {}, 2: {}}, ) if err != nil { t.Fatal(err) @@ -195,6 +196,7 @@ func TestShareRepairBusAuthenticatesSenderAndSuppressesReplay(t *testing.T) { &testutils.MockLogger{}, channel, fixture.validator, + map[group.MemberIndex]struct{}{1: {}, 2: {}}, ) if err != nil { t.Fatal(err) @@ -232,3 +234,61 @@ func TestShareRepairBusAuthenticatesSenderAndSuppressesReplay(t *testing.T) { 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: {}}, + ) + 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: []byte("nonparticipant-ciphertext"), + }} + 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: []byte("participant-ciphertext"), + }} + 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") + } +} diff --git a/pkg/frost/signing/share_repair_frost_native.go b/pkg/frost/signing/share_repair_frost_native.go index 29ef8a89fa..e0d9c87f99 100644 --- a/pkg/frost/signing/share_repair_frost_native.go +++ b/pkg/frost/signing/share_repair_frost_native.go @@ -21,10 +21,14 @@ import ( 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 @@ -54,31 +58,86 @@ type ShareRepairAuthorization struct { SignatureHex string `json:"signature_hex"` } -// NativeShareRepairDelta and NativeShareRepairSigma contain secret scalars. -// Callers must zero Data as soon as the next native phase has copied it. -type NativeShareRepairDelta struct { +// 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 - Data []byte + Payload []byte } -type NativeShareRepairSigma struct { +type NativeShareRepairEncryptedSigma struct { ContextDigest string HelperIdentifier uint16 - Data []byte + Payload []byte } type NativeShareRepairPart1Result struct { ContextDigest string HelperIdentifier uint16 PublicKeyPackage *NativeFROSTPublicKeyPackage - Deltas []*NativeShareRepairDelta + Deltas []*NativeShareRepairEncryptedDelta } type NativeShareRepairPart2Result struct { ContextDigest string - Sigma *NativeShareRepairSigma + Sigma *NativeShareRepairEncryptedSigma } type NativeShareRepairInstallResult struct { @@ -95,19 +154,30 @@ type NativeShareRepairInstallResult struct { // 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 []*NativeShareRepairDelta, + deltas []*NativeShareRepairEncryptedDelta, + transportRoster *ShareRepairTransportRoster, ) (*NativeShareRepairPart2Result, error) InstallRepairedShare( authorization *ShareRepairAuthorization, publicKeyPackage *NativeFROSTPublicKeyPackage, - sigmas []*NativeShareRepairSigma, + sigmas []*NativeShareRepairEncryptedSigma, + transportRoster *ShareRepairTransportRoster, ) (*NativeShareRepairInstallResult, error) } @@ -160,6 +230,49 @@ func DecodeShareRepairAuthorization(payload []byte) (*ShareRepairAuthorization, 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) { @@ -187,38 +300,6 @@ func parseCanonicalShareRepairSignature(value string) ([]byte, error) { return decoded, nil } -// encodeShareRepairSecretHexJSON and decodeShareRepairSecretHexJSON keep repair -// scalars in mutable byte slices. Using ordinary Go strings here would leave -// immutable plaintext hex copies behind after the FFI or ECIES operation. -func encodeShareRepairSecretHexJSON(data []byte) (json.RawMessage, error) { - if len(data) != 32 { - return nil, fmt.Errorf("share-repair secret scalar must be 32 bytes") - } - result := make([]byte, 66) - result[0] = '"' - hex.Encode(result[1:65], data) - result[65] = '"' - return json.RawMessage(result), nil -} - -func decodeShareRepairSecretHexJSON(data json.RawMessage) ([]byte, error) { - if len(data) != 66 || data[0] != '"' || data[65] != '"' { - return nil, fmt.Errorf("share-repair secret scalar is not canonical JSON hex") - } - for _, value := range data[1:65] { - if value >= 'A' && value <= 'F' { - return nil, fmt.Errorf("share-repair secret scalar is not lowercase hex") - } - } - result := make([]byte, 32) - decoded, err := hex.Decode(result, data[1:65]) - if err != nil || decoded != len(result) { - zeroBytes(result) - return nil, fmt.Errorf("share-repair secret scalar is invalid") - } - return result, 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") @@ -350,6 +431,131 @@ func ComputeShareRepairAuthorizationDigest( 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, @@ -387,6 +593,38 @@ func validateShareRepairAuthorization( }, 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, diff --git a/pkg/frost/signing/share_repair_frost_native_test.go b/pkg/frost/signing/share_repair_frost_native_test.go index dc1e353b5a..148e8434cc 100644 --- a/pkg/frost/signing/share_repair_frost_native_test.go +++ b/pkg/frost/signing/share_repair_frost_native_test.go @@ -54,6 +54,58 @@ func testShareRepairAuthorization( 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, @@ -127,29 +179,157 @@ func TestShareRepairAuthorizationDigestFrozenVector(t *testing.T) { } } -func TestShareRepairSecretHexJSONUsesCanonicalMutableBytes(t *testing.T) { - secret := bytes.Repeat([]byte{0xab}, 32) - wire, err := encodeShareRepairSecretHexJSON(secret) +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 len(wire) != 66 || wire[0] != '"' || wire[65] != '"' || - bytes.Contains(wire, []byte("AB")) { - t.Fatalf("unexpected canonical secret wire: %q", wire) + if decoded.Schema != bundle.Schema || + decoded.TransportRoster.SignatureHex != bundle.TransportRoster.SignatureHex { + t.Fatalf("recovery bundle changed across strict decode: %+v", decoded) } - decoded, err := decodeShareRepairSecretHexJSON(wire) - if err != nil || !bytes.Equal(decoded, secret) { - t.Fatalf("canonical secret wire did not round trip: %v", err) + if _, err := DecodeShareRepairRecoveryBundle( + append(payload, []byte(`{}`)...), + ); err == nil || !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("recovery bundle accepted trailing JSON: %v", err) } - zeroBytes(decoded) - upper := append(json.RawMessage(nil), wire...) - upper[1] = 'A' - if _, err := decodeShareRepairSecretHexJSON(upper); err == nil { - t.Fatal("uppercase secret hex was accepted") + + wrongSchema := bundle + wrongSchema.Schema = "tbtc-frost-share-repair-bundle/v2" + payload, err = json.Marshal(wrongSchema) + if err != nil { + t.Fatal(err) } - zeroBytes(wire) - if !bytes.Equal(wire, make([]byte, len(wire))) { - t.Fatal("secret JSON wire could not be scrubbed") + if _, err := DecodeShareRepairRecoveryBundle(payload); err == nil { + t.Fatal("recovery bundle accepted an unsupported schema") } } diff --git a/pkg/frost/signing/share_repair_runner_frost_native.go b/pkg/frost/signing/share_repair_runner_frost_native.go index d46f5a4d89..522e3e10a5 100644 --- a/pkg/frost/signing/share_repair_runner_frost_native.go +++ b/pkg/frost/signing/share_repair_runner_frost_native.go @@ -7,26 +7,93 @@ import ( "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/crypto/ephemeral" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" ) -type shareRepairSecretWire struct { - ContextDigest string `json:"context_digest"` - SenderIdentifier uint16 `json:"sender_identifier,omitempty"` - RecipientIdentifier uint16 `json:"recipient_identifier,omitempty"` - HelperIdentifier uint16 `json:"helper_identifier,omitempty"` - DataHex json.RawMessage `json:"data_hex"` -} - 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"` @@ -41,6 +108,7 @@ type shareRepairInstalledWire struct { type shareRepairRunner struct { member group.MemberIndex authorization *ShareRepairAuthorization + transportRoster *ShareRepairTransportRoster authorizationDigest [32]byte contextWire string participants map[group.MemberIndex]struct{} @@ -48,7 +116,6 @@ type shareRepairRunner struct { engine NativeTBTCSignerShareRepairEngine bus shareRepairBus stream <-chan shareRepairMessage - ephemeralPrivate *ephemeral.PrivateKey ephemeralPublic []byte pending []shareRepairMessage } @@ -72,6 +139,7 @@ func RunShareRepair( membershipValidator *group.MembershipValidator, engine NativeTBTCSignerShareRepairEngine, authorization *ShareRepairAuthorization, + transportRoster *ShareRepairTransportRoster, authorityPublicKey ed25519.PublicKey, localMemberIndexes []group.MemberIndex, ) (*NativeShareRepairInstallResult, error) { @@ -86,11 +154,19 @@ func RunShareRepair( 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), ) if err != nil { return nil, err @@ -99,40 +175,98 @@ func RunShareRepair( 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, -) (*NativeShareRepairInstallResult, error) { - if ctx == nil || engine == nil || authorization == nil || +) (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 := make(map[group.MemberIndex]struct{}, len(authorization.HelperIdentifiers)+1) + 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) - participants[member] = struct{}{} helperSet[member] = struct{}{} } - participants[group.MemberIndex(authorization.TargetIdentifier)] = struct{}{} localSet := make(map[group.MemberIndex]struct{}, len(localMemberIndexes)) runners := make([]*shareRepairRunner, 0, len(localMemberIndexes)) - started := false defer func() { - if !started { - for _, runner := range runners { - runner.ephemeralPrivate.Zero() + 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, + ) } } }() @@ -144,13 +278,49 @@ func runShareRepairOnBus( if _, participating := participants[member]; !participating { return nil, fmt.Errorf("local seat [%d] is not in the repair authorization", member) } - keyPair, err := ephemeral.GenerateKeyPair() + session, err := engine.BeginShareRepairSession( + authorization, + uint16(member), + ) if err != nil { - return nil, fmt.Errorf("cannot generate recovery ephemeral key for seat [%d]: %w", member, err) + // 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, @@ -158,12 +328,10 @@ func runShareRepairOnBus( engine: engine, bus: bus, stream: bus.Subscribe(member), - ephemeralPrivate: keyPair.PrivateKey, - ephemeralPublic: keyPair.PublicKey.Marshal(), + ephemeralPublic: append([]byte(nil), session.TransportPublicKey...), }) } bus.Start() - started = true runContext, cancel := context.WithCancel(ctx) defer cancel() @@ -171,13 +339,11 @@ func runShareRepairOnBus( for _, runner := range runners { runner := runner go func() { - defer runner.ephemeralPrivate.Zero() result, err := runner.run(runContext) outcomes <- shareRepairRunnerOutcome{member: runner.member, result: result, err: err} }() } - var targetResult *NativeShareRepairInstallResult var firstError error for range runners { outcome := <-outcomes @@ -219,28 +385,28 @@ func (runner *shareRepairRunner) run( cancelAnnouncement := runner.bus.Broadcast(announcement) defer cancelAnnouncement() - publicKeys, err := runner.collectAnnouncements(ctx) + _, err := runner.collectAnnouncements(ctx) if err != nil { return nil, err } if runner.member == group.MemberIndex(runner.authorization.TargetIdentifier) { - return runner.runTarget(ctx, publicKeys) + 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, publicKeys, cancelAnnouncement) + return nil, runner.runHelper(ctx, cancelAnnouncement) } func (runner *shareRepairRunner) collectAnnouncements( ctx context.Context, -) (map[group.MemberIndex]*ephemeral.PublicKey, error) { - publicKeys := make(map[group.MemberIndex]*ephemeral.PublicKey, len(runner.participants)) - selfPublic, err := ephemeral.UnmarshalPublicKey(runner.ephemeralPublic) +) (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 + publicKeys[runner.member] = selfPublic.SerializeCompressed() for len(publicKeys) < len(runner.participants) { var message shareRepairMessage select { @@ -251,6 +417,12 @@ func (runner *shareRepairRunner) collectAnnouncements( 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 len(runner.pending) >= shareRepairMaximumPendingMessages { return nil, fmt.Errorf( @@ -260,24 +432,70 @@ func (runner *shareRepairRunner) collectAnnouncements( runner.pending = append(runner.pending, message) continue } - if _, expected := runner.participants[message.Sender]; !expected { - continue - } - parsed, err := ephemeral.UnmarshalPublicKey(message.EphemeralPublicKey) + 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.Marshal(), parsed.Marshal()) { + if !bytes.Equal(existing, parsedWire) { return nil, fmt.Errorf("recovery seat [%d] equivocated its ephemeral key", message.Sender) } continue } - publicKeys[message.Sender] = parsed + publicKeys[message.Sender] = parsedWire } return publicKeys, 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) { @@ -295,76 +513,18 @@ func (runner *shareRepairRunner) nextMessage( } } -func encodeShareRepairSealedSecret( - secret shareRepairSecretWire, - recipient *ephemeral.PublicKey, -) ([]byte, error) { - defer zeroBytes(secret.DataHex) - plaintext, err := json.Marshal(secret) - if err != nil { - return nil, err - } - defer zeroBytes(plaintext) - sealed, err := sealRound2Share(plaintext, recipient) - if err != nil { - return nil, err - } - return json.Marshal(sealed) -} - -func decodeShareRepairSealedSecret( - payload []byte, - privateKey *ephemeral.PrivateKey, -) (*shareRepairSecretWire, error) { - sealed := &sealedRound2Share{} - decoder := json.NewDecoder(bytes.NewReader(payload)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(sealed); err != nil { - return nil, fmt.Errorf("cannot decode sealed recovery envelope: %w", err) - } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - return nil, fmt.Errorf("sealed recovery envelope has trailing JSON") - } - plaintext, err := openRound2Share(sealed, privateKey) - if err != nil { - return nil, err - } - defer zeroBytes(plaintext) - secret := &shareRepairSecretWire{} - decoder = json.NewDecoder(bytes.NewReader(plaintext)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(secret); err != nil { - zeroBytes(secret.DataHex) - return nil, fmt.Errorf("cannot decode recovery secret: %w", err) - } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - zeroBytes(secret.DataHex) - return nil, fmt.Errorf("recovery secret has trailing JSON") - } - return secret, nil -} - func (runner *shareRepairRunner) runHelper( ctx context.Context, - publicKeys map[group.MemberIndex]*ephemeral.PublicKey, 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 { - defer func() { - for _, delta := range part1.Deltas { - if delta != nil { - zeroBytes(delta.Data) - } - } - }() - } if part1 == nil || part1.ContextDigest != runner.contextWire || part1.HelperIdentifier != uint16(runner.member) || part1.PublicKeyPackage == nil || @@ -407,45 +567,20 @@ func (runner *shareRepairRunner) runHelper( delta := part1.Deltas[index] if delta == nil || delta.ContextDigest != runner.contextWire || delta.SenderIdentifier != uint16(runner.member) || - delta.RecipientIdentifier != recipient || len(delta.Data) != 32 { + delta.RecipientIdentifier != recipient || + len(delta.Payload) != shareRepairEncryptedScalarPayloadLength { return fmt.Errorf("native repair part1 delta [%d] is invalid", index) } - recipientKey := publicKeys[group.MemberIndex(recipient)] - if recipientKey == nil { - return fmt.Errorf("missing recovery ephemeral key for seat [%d]", recipient) - } - dataHex, err := encodeShareRepairSecretHexJSON(delta.Data) - if err != nil { - return fmt.Errorf("encode repair delta for [%d]: %w", recipient, err) - } - sealed, err := encodeShareRepairSealedSecret( - shareRepairSecretWire{ - ContextDigest: delta.ContextDigest, - SenderIdentifier: delta.SenderIdentifier, - RecipientIdentifier: delta.RecipientIdentifier, - DataHex: dataHex, - }, - recipientKey, - ) - if err != nil { - return fmt.Errorf("seal repair delta for [%d]: %w", recipient, err) - } - zeroBytes(delta.Data) preReceiptBroadcasts = append(preReceiptBroadcasts, runner.bus.Broadcast(shareRepairMessage{ Type: shareRepairDeltaMessage, Sender: runner.member, Recipient: group.MemberIndex(recipient), ContextDigest: runner.authorizationDigest, - Payload: sealed, + Payload: append([]byte(nil), delta.Payload...), })) } - deltas := make(map[uint16]*NativeShareRepairDelta, len(runner.helperSet)) - defer func() { - for _, delta := range deltas { - zeroBytes(delta.Data) - } - }() + deltas := make(map[uint16]*NativeShareRepairEncryptedDelta, len(runner.helperSet)) for len(deltas) < len(runner.helperSet) { message, err := runner.nextMessage(ctx) if err != nil { @@ -453,41 +588,28 @@ func (runner *shareRepairRunner) runHelper( } if message.Type != shareRepairDeltaMessage || message.ContextDigest != runner.authorizationDigest || - message.Recipient != runner.member { + message.Recipient != runner.member || + len(message.Payload) != shareRepairEncryptedScalarPayloadLength { continue } if _, expected := runner.helperSet[message.Sender]; !expected { continue } - secret, err := decodeShareRepairSealedSecret(message.Payload, runner.ephemeralPrivate) - if err != nil { - return fmt.Errorf("open repair delta from [%d]: %w", message.Sender, err) - } - data, err := decodeShareRepairSecretHexJSON(secret.DataHex) - zeroBytes(secret.DataHex) - if err != nil || len(data) != 32 || secret.ContextDigest != runner.contextWire || - secret.SenderIdentifier != uint16(message.Sender) || - secret.RecipientIdentifier != uint16(runner.member) || - secret.HelperIdentifier != 0 { - zeroBytes(data) - return fmt.Errorf("repair delta from [%d] has invalid bindings", message.Sender) - } - if existing := deltas[secret.SenderIdentifier]; existing != nil { - if !bytes.Equal(existing.Data, data) { - zeroBytes(data) + senderIdentifier := uint16(message.Sender) + if existing := deltas[senderIdentifier]; existing != nil { + if !bytes.Equal(existing.Payload, message.Payload) { return fmt.Errorf("repair helper [%d] equivocated its delta", message.Sender) } - zeroBytes(data) continue } - deltas[secret.SenderIdentifier] = &NativeShareRepairDelta{ - ContextDigest: secret.ContextDigest, - SenderIdentifier: secret.SenderIdentifier, - RecipientIdentifier: secret.RecipientIdentifier, - Data: data, + deltas[senderIdentifier] = &NativeShareRepairEncryptedDelta{ + ContextDigest: runner.contextWire, + SenderIdentifier: senderIdentifier, + RecipientIdentifier: uint16(runner.member), + Payload: append([]byte(nil), message.Payload...), } } - ordered := make([]*NativeShareRepairDelta, 0, len(deltas)) + ordered := make([]*NativeShareRepairEncryptedDelta, 0, len(deltas)) for _, sender := range runner.authorization.HelperIdentifiers { ordered = append(ordered, deltas[sender]) } @@ -495,64 +617,34 @@ func (runner *shareRepairRunner) runHelper( runner.authorization, uint16(runner.member), ordered, + runner.transportRoster, ) if err != nil { return fmt.Errorf("native repair part2: %w", err) } - for _, delta := range deltas { - zeroBytes(delta.Data) - } - if part2 != nil && part2.Sigma != nil { - defer zeroBytes(part2.Sigma.Data) - } if part2 == nil || part2.ContextDigest != runner.contextWire || part2.Sigma == nil || part2.Sigma.ContextDigest != runner.contextWire || - part2.Sigma.HelperIdentifier != uint16(runner.member) || len(part2.Sigma.Data) != 32 { + part2.Sigma.HelperIdentifier != uint16(runner.member) || + len(part2.Sigma.Payload) != shareRepairEncryptedScalarPayloadLength { return fmt.Errorf("native repair part2 returned an invalid sigma") } - targetKey := publicKeys[group.MemberIndex(runner.authorization.TargetIdentifier)] - if targetKey == nil { - return fmt.Errorf("missing recovery ephemeral key for target seat") - } - dataHex, err := encodeShareRepairSecretHexJSON(part2.Sigma.Data) - if err != nil { - return fmt.Errorf("encode repair sigma: %w", err) - } - sealed, err := encodeShareRepairSealedSecret( - shareRepairSecretWire{ - ContextDigest: part2.Sigma.ContextDigest, - HelperIdentifier: part2.Sigma.HelperIdentifier, - DataHex: dataHex, - }, - targetKey, - ) - if err != nil { - return fmt.Errorf("seal repair sigma: %w", err) - } - zeroBytes(part2.Sigma.Data) preReceiptBroadcasts = append(preReceiptBroadcasts, runner.bus.Broadcast(shareRepairMessage{ Type: shareRepairSigmaMessage, Sender: runner.member, Recipient: group.MemberIndex(runner.authorization.TargetIdentifier), ContextDigest: runner.authorizationDigest, - Payload: sealed, + Payload: append([]byte(nil), part2.Sigma.Payload...), })) return runner.waitForInstalledReceipt(ctx, cancelPreReceiptBroadcasts) } func (runner *shareRepairRunner) runTarget( ctx context.Context, - _ map[group.MemberIndex]*ephemeral.PublicKey, ) (*NativeShareRepairInstallResult, error) { - sigmas := make(map[uint16]*NativeShareRepairSigma, len(runner.helperSet)) + sigmas := make(map[uint16]*NativeShareRepairEncryptedSigma, len(runner.helperSet)) publicPackages := make(map[uint16][]byte, len(runner.helperSet)) var publicKeyPackage *NativeFROSTPublicKeyPackage var canonicalPublicKeyPackage []byte - defer func() { - for _, sigma := range sigmas { - zeroBytes(sigma.Data) - } - }() for len(sigmas) < len(runner.helperSet) || len(publicPackages) < len(runner.helperSet) { message, err := runner.nextMessage(ctx) if err != nil { @@ -597,36 +689,24 @@ func (runner *shareRepairRunner) runTarget( publicKeyPackage = candidate continue } - if message.Type != shareRepairSigmaMessage || message.Recipient != runner.member { + if message.Type != shareRepairSigmaMessage || message.Recipient != runner.member || + len(message.Payload) != shareRepairEncryptedScalarPayloadLength { continue } - secret, err := decodeShareRepairSealedSecret(message.Payload, runner.ephemeralPrivate) - if err != nil { - return nil, fmt.Errorf("open repair sigma from [%d]: %w", message.Sender, err) - } - data, err := decodeShareRepairSecretHexJSON(secret.DataHex) - zeroBytes(secret.DataHex) - if err != nil || len(data) != 32 || secret.ContextDigest != runner.contextWire || - secret.HelperIdentifier != uint16(message.Sender) || - secret.SenderIdentifier != 0 || secret.RecipientIdentifier != 0 { - zeroBytes(data) - return nil, fmt.Errorf("repair sigma from [%d] has invalid bindings", message.Sender) - } - if existing := sigmas[secret.HelperIdentifier]; existing != nil { - if !bytes.Equal(existing.Data, data) { - zeroBytes(data) + helperIdentifier := uint16(message.Sender) + if existing := sigmas[helperIdentifier]; existing != nil { + if !bytes.Equal(existing.Payload, message.Payload) { return nil, fmt.Errorf("repair helper [%d] equivocated its sigma", message.Sender) } - zeroBytes(data) continue } - sigmas[secret.HelperIdentifier] = &NativeShareRepairSigma{ - ContextDigest: secret.ContextDigest, - HelperIdentifier: secret.HelperIdentifier, - Data: data, + sigmas[helperIdentifier] = &NativeShareRepairEncryptedSigma{ + ContextDigest: runner.contextWire, + HelperIdentifier: helperIdentifier, + Payload: append([]byte(nil), message.Payload...), } } - ordered := make([]*NativeShareRepairSigma, 0, len(sigmas)) + ordered := make([]*NativeShareRepairEncryptedSigma, 0, len(sigmas)) for _, helper := range runner.authorization.HelperIdentifiers { ordered = append(ordered, sigmas[helper]) } @@ -634,13 +714,11 @@ func (runner *shareRepairRunner) runTarget( runner.authorization, publicKeyPackage, ordered, + runner.transportRoster, ) if err != nil { return nil, fmt.Errorf("native repaired-share install: %w", err) } - for _, sigma := range sigmas { - zeroBytes(sigma.Data) - } if result == nil || result.Schema != ShareRepairInstallResultSchema || result.SessionID != runner.authorization.SessionID || result.KeyGroup != runner.authorization.KeyGroup || diff --git a/pkg/frost/signing/share_repair_runner_frost_native_test.go b/pkg/frost/signing/share_repair_runner_frost_native_test.go index 8ec416de64..3488e21c57 100644 --- a/pkg/frost/signing/share_repair_runner_frost_native_test.go +++ b/pkg/frost/signing/share_repair_runner_frost_native_test.go @@ -5,6 +5,7 @@ package signing import ( "bytes" "context" + "crypto/ed25519" "crypto/sha256" "encoding/hex" "encoding/json" @@ -13,16 +14,57 @@ import ( "testing" "time" - "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/btcsuite/btcd/btcec/v2" "github.com/keep-network/keep-core/pkg/protocol/group" ) type testShareRepairEngine struct { - mutex sync.Mutex - secretBuffers [][]byte - installCalls int + mutex sync.Mutex + beginCalls int + finishCalls int + installCalls int } +type testShareRepairSessionOverrideEngine struct { + *testShareRepairEngine + beginError error + finishError error + mutate func(*NativeShareRepairSession) +} + +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) } @@ -38,16 +80,82 @@ func testShareRepairPublicKeyPackage() *NativeFROSTPublicKeyPackage { } } -func (engine *testShareRepairEngine) rememberSecret(value []byte) { +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() - defer engine.mutex.Unlock() - engine.secretBuffers = append(engine.secretBuffers, value) + 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 @@ -58,14 +166,18 @@ func (engine *testShareRepairEngine) ShareRepairPart1( HelperIdentifier: helperIdentifier, PublicKeyPackage: testShareRepairPublicKeyPackage(), } - for _, recipient := range authorization.HelperIdentifiers { - secret := testShareRepairSecret(byte(helperIdentifier*10 + recipient)) - engine.rememberSecret(secret) - result.Deltas = append(result.Deltas, &NativeShareRepairDelta{ + 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, - Data: secret, + Payload: testShareRepairCiphertext(1, helperIdentifier, recipient), }) } return result, nil @@ -74,8 +186,15 @@ func (engine *testShareRepairEngine) ShareRepairPart1( func (engine *testShareRepairEngine) ShareRepairPart2( authorization *ShareRepairAuthorization, helperIdentifier uint16, - deltas []*NativeShareRepairDelta, + 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") } @@ -83,20 +202,31 @@ func (engine *testShareRepairEngine) ShareRepairPart2( delta := deltas[index] if delta == nil || delta.SenderIdentifier != sender || delta.RecipientIdentifier != helperIdentifier || - !bytes.Equal(delta.Data, testShareRepairSecret(byte(sender*10+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) - secret := testShareRepairSecret(byte(100 + helperIdentifier)) - engine.rememberSecret(secret) contextWire := fmt.Sprintf("0x%x", digest) return &NativeShareRepairPart2Result{ ContextDigest: contextWire, - Sigma: &NativeShareRepairSigma{ + Sigma: &NativeShareRepairEncryptedSigma{ ContextDigest: contextWire, HelperIdentifier: helperIdentifier, - Data: secret, + Payload: testShareRepairCiphertext( + 2, + helperIdentifier, + authorization.TargetIdentifier, + ), }, }, nil } @@ -104,8 +234,15 @@ func (engine *testShareRepairEngine) ShareRepairPart2( func (engine *testShareRepairEngine) InstallRepairedShare( authorization *ShareRepairAuthorization, publicKeyPackage *NativeFROSTPublicKeyPackage, - sigmas []*NativeShareRepairSigma, + 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") @@ -116,7 +253,10 @@ func (engine *testShareRepairEngine) InstallRepairedShare( for index, helper := range authorization.HelperIdentifiers { sigma := sigmas[index] if sigma == nil || sigma.HelperIdentifier != helper || - !bytes.Equal(sigma.Data, testShareRepairSecret(byte(100+helper))) { + !bytes.Equal( + sigma.Payload, + testShareRepairCiphertext(2, helper, authorization.TargetIdentifier), + ) { return nil, fmt.Errorf("wrong sigma [%d]", index) } } @@ -184,7 +324,8 @@ func (bus *subscriptionBarrierShareRepairBus) Broadcast( func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { resetShareRepairActivationRegistryForTest() t.Cleanup(resetShareRepairActivationRegistryForTest) - authorization, _ := testShareRepairAuthorization(t) + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) digest, err := ComputeShareRepairAuthorizationDigest(authorization) if err != nil { t.Fatal(err) @@ -213,6 +354,7 @@ func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { ctx, engine, authorization, + transportRoster, digest, []group.MemberIndex{member}, bus, @@ -241,11 +383,13 @@ func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { engine.mutex.Unlock() t.Fatalf("expected one native install, got [%d]", engine.installCalls) } - for index, secret := range engine.secretBuffers { - if !bytes.Equal(secret, make([]byte, len(secret))) { - engine.mutex.Unlock() - t.Fatalf("native secret buffer [%d] was not scrubbed", index) - } + 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() @@ -322,15 +466,18 @@ func TestRunShareRepairOnBusConfidentialExactSetAndInstall(t *testing.T) { func TestRunShareRepairOnBusTimesOutWithoutExactParticipantSet(t *testing.T) { resetShareRepairActivationRegistryForTest() t.Cleanup(resetShareRepairActivationRegistryForTest) - authorization, _ := testShareRepairAuthorization(t) + 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, - &testShareRepairEngine{}, + engine, authorization, + transportRoster, digest, []group.MemberIndex{1}, bus, @@ -338,6 +485,16 @@ func TestRunShareRepairOnBusTimesOutWithoutExactParticipantSet(t *testing.T) { 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 @@ -354,6 +511,197 @@ func TestRunShareRepairOnBusTimesOutWithoutExactParticipantSet(t *testing.T) { } } +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") + } + }) + + 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 } @@ -414,16 +762,12 @@ func assertShareRepairStillWaiting( } func TestShareRepairAnnouncementRemainsLivePastLocalRendezvous(t *testing.T) { - authorization, _ := testShareRepairAuthorization(t) + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) digest, err := ComputeShareRepairAuthorizationDigest(authorization) if err != nil { t.Fatal(err) } - localKeyPair, err := ephemeral.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } - defer localKeyPair.PrivateKey.Zero() bus := &cancelTrackingShareRepairBus{ broadcasts: make(chan *cancelTrackingShareRepairBroadcast, 8), } @@ -431,6 +775,7 @@ func TestShareRepairAnnouncementRemainsLivePastLocalRendezvous(t *testing.T) { runner := &shareRepairRunner{ member: 1, authorization: authorization, + transportRoster: transportRoster, authorizationDigest: digest, contextWire: fmt.Sprintf("0x%x", digest), participants: map[group.MemberIndex]struct{}{ @@ -442,11 +787,10 @@ func TestShareRepairAnnouncementRemainsLivePastLocalRendezvous(t *testing.T) { 1: {}, 2: {}, }, - engine: &testShareRepairEngine{}, - bus: bus, - stream: stream, - ephemeralPrivate: localKeyPair.PrivateKey, - ephemeralPublic: localKeyPair.PublicKey.Marshal(), + engine: &testShareRepairEngine{}, + bus: bus, + stream: stream, + ephemeralPublic: testShareRepairPublicKey(1), } ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -461,17 +805,12 @@ func TestShareRepairAnnouncementRemainsLivePastLocalRendezvous(t *testing.T) { t.Fatalf("runner first published the wrong message: %+v", announcement.message) } for _, peer := range []group.MemberIndex{2, 3} { - peerKeyPair, err := ephemeral.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } stream <- shareRepairMessage{ Type: shareRepairAnnouncementMessage, Sender: peer, ContextDigest: digest, - EphemeralPublicKey: peerKeyPair.PublicKey.Marshal(), + EphemeralPublicKey: testShareRepairPublicKey(uint16(peer)), } - peerKeyPair.PrivateKey.Zero() } for { @@ -719,17 +1058,12 @@ func TestShareRepairHelperRelayDeadlineAfterReceiptIsSuccessful(t *testing.T) { } func TestShareRepairRunnerRejectsPendingMessageFlood(t *testing.T) { - authorization, _ := testShareRepairAuthorization(t) + authorization, authority := testShareRepairAuthorization(t) + transportRoster := testShareRepairTransportRoster(t, authorization, authority) digest, err := ComputeShareRepairAuthorizationDigest(authorization) if err != nil { t.Fatal(err) } - keyPair, err := ephemeral.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } - defer keyPair.PrivateKey.Zero() - stream := make(chan shareRepairMessage, shareRepairMaximumPendingMessages+1) for index := 0; index <= shareRepairMaximumPendingMessages; index++ { stream <- shareRepairMessage{ @@ -743,12 +1077,13 @@ func TestShareRepairRunnerRejectsPendingMessageFlood(t *testing.T) { runner := &shareRepairRunner{ member: 1, authorizationDigest: digest, + transportRoster: transportRoster, participants: map[group.MemberIndex]struct{}{ 1: {}, 2: {}, }, stream: stream, - ephemeralPublic: keyPair.PublicKey.Marshal(), + ephemeralPublic: testShareRepairPublicKey(1), } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() @@ -757,3 +1092,52 @@ func TestShareRepairRunnerRejectsPendingMessageFlood(t *testing.T) { t.Fatalf("expected pending-message flood rejection, got [%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_share_repair_maintenance_frost_native.go b/pkg/tbtc/frost_share_repair_maintenance_frost_native.go index c5e296d486..5417a0aedb 100644 --- a/pkg/tbtc/frost_share_repair_maintenance_frost_native.go +++ b/pkg/tbtc/frost_share_repair_maintenance_frost_native.go @@ -41,12 +41,13 @@ func runFrostShareRepairMaintenance( payload, err := readSecureFrostActivationFile(authorizationPath, 256*1024) if err != nil { - return true, fmt.Errorf("cannot read secure share-repair authorization: %w", err) + return true, fmt.Errorf("cannot read secure share-repair recovery bundle: %w", err) } - authorization, err := frostsigning.DecodeShareRepairAuthorization(payload) + 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 @@ -111,7 +112,19 @@ func runFrostShareRepairMaintenance( wallet.signingGroupOperators, node.chain.Signing(), ) - channelName := fmt.Sprintf("%s-frost-share-repair-%x", ProtocolName, digest) + 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) @@ -133,6 +146,7 @@ func runFrostShareRepairMaintenance( membershipValidator, engine, authorization, + &bundle.TransportRoster, ed25519.PublicKey(manifest.ActivationAuthorityPublicKey[:]), localMemberIndexes, ) 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 a7ea9327a3..b621f1d54d 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -214,7 +214,21 @@ func newNode( if err != nil { return nil, fmt.Errorf("cannot get node's operator address: [%v]", err) } - if config.FrostShareRepairAuthorizationPath != "" && + 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", @@ -712,6 +726,24 @@ 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, diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 0c077e1d7e..90a8422504 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -30,7 +30,7 @@ var logger = log.Logger("keep-tbtc") // 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 maintenance completed; remove the authorization path and restart", + "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. @@ -147,10 +147,21 @@ type Config struct { // 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, offline-authority-signed authorization. Every helper - // and the target exits cleanly after the bounded protocol; this path must be - // removed before restart. Normal production startup never runs recovery. + // 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 From d9e2dac0b3b4b2627d1c0d1f7f418e5ad1bf0511 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 09:03:03 -0400 Subject: [PATCH 5/7] fix(frost): bound share-repair retry state --- ci/frost-signer-pin.env | 2 +- docs/development/frost-share-repair.adoc | 16 + .../signing/share_repair_bus_frost_native.go | 207 ++++++---- .../share_repair_bus_frost_native_test.go | 278 ++++++++++++- .../share_repair_runner_frost_native.go | 150 +++++-- .../share_repair_runner_frost_native_test.go | 387 ++++++++++++++++++ 6 files changed, 922 insertions(+), 118 deletions(-) diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 637fad9562..7c353c403a 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=35259de9aeb2daa25429a2630f1e8290240e500f +FROST_SIGNER_MIRROR_REF=87492db35f12566f83429fb2a2f751811b5afd95 diff --git a/docs/development/frost-share-repair.adoc b/docs/development/frost-share-repair.adoc index e0aae2d173..3ef6778180 100644 --- a/docs/development/frost-share-repair.adoc +++ b/docs/development/frost-share-repair.adoc @@ -130,6 +130,22 @@ 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 diff --git a/pkg/frost/signing/share_repair_bus_frost_native.go b/pkg/frost/signing/share_repair_bus_frost_native.go index cd5be3b95b..eb9fabe342 100644 --- a/pkg/frost/signing/share_repair_bus_frost_native.go +++ b/pkg/frost/signing/share_repair_bus_frost_native.go @@ -55,10 +55,12 @@ type shareRepairBus interface { } type shareRepairBusSubscriber struct { - member group.MemberIndex - stream chan shareRepairMessage - mutex sync.Mutex - seen map[[32]byte]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( @@ -68,21 +70,44 @@ func (subscriber *shareRepairBusSubscriber) deliver( if message.Recipient != 0 && message.Recipient != subscriber.member { return } + if err := validateShareRepairMessage(message); err != nil { + return + } hash := message.contentHash() - delivered := message - delivered.EphemeralPublicKey = append([]byte(nil), message.EphemeralPublicKey...) - delivered.Payload = append([]byte(nil), message.Payload...) + 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. @@ -104,9 +129,10 @@ func newInProcessShareRepairBus(bufferSize int) shareRepairBus { 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{}), + 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) @@ -138,56 +164,91 @@ const ( // putting plaintext scalars on the wire. shareRepairEncryptedScalarPayloadLength = 33 + 24 + 32 + 16 shareRepairMaximumSecretPayload = 4 * 1024 - shareRepairMaximumPublicPayload = 256 * 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 ) -type shareRepairTransportMessage struct { - message shareRepairMessage +func shareRepairMessageRetainedBytes(message shareRepairMessage) int { + return len(message.EphemeralPublicKey) + len(message.Payload) } -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 value.Type < shareRepairAnnouncementMessage || - value.Type > shareRepairCompletionMessage || - value.Sender == 0 || value.ContextDigest == [32]byte{} || - len(value.Payload) > shareRepairMaximumPublicPayload { - return nil, fmt.Errorf("share-repair transport message is invalid") +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") } - switch value.Type { + 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 value.Recipient != 0 || - len(value.EphemeralPublicKey) != shareRepairEphemeralPublicKeyLength || - len(value.Payload) != 0 { - return nil, fmt.Errorf("share-repair announcement shape is invalid") + if recipient != 0 || + ephemeralLength != shareRepairEphemeralPublicKeyLength || + payloadLength != 0 { + return fmt.Errorf("share-repair announcement shape is invalid") } case shareRepairInstalledMessage: - if value.Recipient != 0 || len(value.EphemeralPublicKey) != 0 || - len(value.Payload) == 0 || len(value.Payload) > shareRepairMaximumSecretPayload { - return nil, fmt.Errorf("share-repair installed receipt shape is invalid") + if recipient != 0 || ephemeralLength != 0 || payloadLength == 0 || + payloadLength > shareRepairMaximumSecretPayload { + return fmt.Errorf("share-repair installed receipt shape is invalid") } case shareRepairPublicPackageMessage: - if value.Recipient != 0 || len(value.EphemeralPublicKey) != 0 || len(value.Payload) == 0 { - return nil, fmt.Errorf("share-repair public-package message shape is invalid") + if recipient != 0 || ephemeralLength != 0 || payloadLength == 0 || + payloadLength > shareRepairMaximumPublicPayload { + return fmt.Errorf("share-repair public-package message shape is invalid") } case shareRepairInstalledAcknowledgementMessage: - if value.Recipient == 0 || len(value.EphemeralPublicKey) != 0 || - len(value.Payload) != sha256.Size { - return nil, fmt.Errorf("share-repair installed acknowledgement shape is invalid") + if recipient == 0 || ephemeralLength != 0 || payloadLength != sha256.Size { + return fmt.Errorf("share-repair installed acknowledgement shape is invalid") } case shareRepairCompletionMessage: - if value.Recipient != 0 || len(value.EphemeralPublicKey) != 0 || - len(value.Payload) != sha256.Size { - return nil, fmt.Errorf("share-repair completion shape is invalid") + if recipient != 0 || ephemeralLength != 0 || payloadLength != sha256.Size { + return fmt.Errorf("share-repair completion shape is invalid") } case shareRepairDeltaMessage, shareRepairSigmaMessage: - if value.Recipient == 0 || len(value.EphemeralPublicKey) != 0 || - len(value.Payload) != shareRepairEncryptedScalarPayloadLength { - return nil, fmt.Errorf("share-repair secret message shape is invalid") + 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) @@ -220,10 +281,18 @@ func (message *shareRepairTransportMessage) Unmarshal(data []byte) error { return fmt.Errorf("share-repair context digest is zero") } ephemeralLength := int(binary.BigEndian.Uint16(data[41:43])) - if len(data) < 43+ephemeralLength || - len(data)-(43+ephemeralLength) > shareRepairMaximumPublicPayload { + 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), @@ -232,36 +301,6 @@ func (message *shareRepairTransportMessage) Unmarshal(data []byte) error { EphemeralPublicKey: append([]byte(nil), data[43:43+ephemeralLength]...), Payload: append([]byte(nil), data[43+ephemeralLength:]...), } - switch messageType { - case shareRepairAnnouncementMessage: - if rawRecipient != 0 || - ephemeralLength != shareRepairEphemeralPublicKeyLength || - len(value.Payload) != 0 { - return fmt.Errorf("share-repair announcement shape is invalid") - } - case shareRepairInstalledMessage: - if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) == 0 || - len(value.Payload) > shareRepairMaximumSecretPayload { - return fmt.Errorf("share-repair installed receipt shape is invalid") - } - case shareRepairPublicPackageMessage: - if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) == 0 { - return fmt.Errorf("share-repair public-package message shape is invalid") - } - case shareRepairInstalledAcknowledgementMessage: - if rawRecipient == 0 || ephemeralLength != 0 || len(value.Payload) != sha256.Size { - return fmt.Errorf("share-repair installed acknowledgement shape is invalid") - } - case shareRepairCompletionMessage: - if rawRecipient != 0 || ephemeralLength != 0 || len(value.Payload) != sha256.Size { - return fmt.Errorf("share-repair completion shape is invalid") - } - case shareRepairDeltaMessage, shareRepairSigmaMessage: - if rawRecipient == 0 || ephemeralLength != 0 || len(value.Payload) == 0 || - len(value.Payload) != shareRepairEncryptedScalarPayloadLength { - return fmt.Errorf("share-repair secret message shape is invalid") - } - } message.message = value return nil } @@ -272,6 +311,7 @@ type broadcastChannelShareRepairBus struct { channel net.BroadcastChannel membershipValidator *group.MembershipValidator participants map[group.MemberIndex]struct{} + expectedContext [32]byte mutex sync.Mutex subscribers []*shareRepairBusSubscriber startOnce sync.Once @@ -283,9 +323,10 @@ func newBroadcastChannelShareRepairBus( 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 { + len(participants) == 0 || expectedContext == [32]byte{} { return nil, fmt.Errorf("share-repair bus dependencies are incomplete") } participantCopy := make(map[group.MemberIndex]struct{}, len(participants)) @@ -307,6 +348,7 @@ func newBroadcastChannelShareRepairBus( channel: channel, membershipValidator: membershipValidator, participants: participantCopy, + expectedContext: expectedContext, }, nil } @@ -314,9 +356,10 @@ func (bus *broadcastChannelShareRepairBus) Subscribe( member group.MemberIndex, ) <-chan shareRepairMessage { subscriber := &shareRepairBusSubscriber{ - member: member, - stream: make(chan shareRepairMessage, 1024), - seen: make(map[[32]byte]struct{}), + 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) @@ -329,6 +372,9 @@ func (bus *broadcastChannelShareRepairBus) Start() { } func (bus *broadcastChannelShareRepairBus) deliver(message shareRepairMessage) { + if message.ContextDigest != bus.expectedContext { + return + } bus.mutex.Lock() subscribers := append([]*shareRepairBusSubscriber(nil), bus.subscribers...) bus.mutex.Unlock() @@ -358,6 +404,9 @@ func (bus *broadcastChannelShareRepairBus) handleMessage(message net.Message) { 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. diff --git a/pkg/frost/signing/share_repair_bus_frost_native_test.go b/pkg/frost/signing/share_repair_bus_frost_native_test.go index 16131ba24f..7404c76e77 100644 --- a/pkg/frost/signing/share_repair_bus_frost_native_test.go +++ b/pkg/frost/signing/share_repair_bus_frost_native_test.go @@ -6,6 +6,10 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/json" + "fmt" + "strings" + "sync" "testing" "github.com/keep-network/keep-core/internal/testutils" @@ -18,6 +22,83 @@ type contextRecordingShareRepairChannel struct { 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, @@ -152,6 +233,7 @@ func TestShareRepairBusCancelsRetransmissionsPerMessage(t *testing.T) { channel, fixture.validator, map[group.MemberIndex]struct{}{1: {}, 2: {}}, + [32]byte{0x44}, ) if err != nil { t.Fatal(err) @@ -197,6 +279,7 @@ func TestShareRepairBusAuthenticatesSenderAndSuppressesReplay(t *testing.T) { channel, fixture.validator, map[group.MemberIndex]struct{}{1: {}, 2: {}}, + [32]byte{0x44}, ) if err != nil { t.Fatal(err) @@ -217,6 +300,24 @@ func TestShareRepairBusAuthenticatesSenderAndSuppressesReplay(t *testing.T) { 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) @@ -243,6 +344,7 @@ func TestShareRepairBusRejectsAuthenticatedNonparticipantBeforeDelivery(t *testi &immediateRecvBroadcastChannel{}, fixture.validator, map[group.MemberIndex]struct{}{1: {}, 2: {}}, + [32]byte{0x44}, ) if err != nil { t.Fatal(err) @@ -258,7 +360,7 @@ func TestShareRepairBusRejectsAuthenticatedNonparticipantBeforeDelivery(t *testi Sender: 3, Recipient: 2, ContextDigest: [32]byte{0x44}, - Payload: []byte("nonparticipant-ciphertext"), + Payload: bytes.Repeat([]byte{0x31}, shareRepairEncryptedScalarPayloadLength), }} bus.handleMessage(fakeNetMessage{ senderPublicKey: fixture.operatorA, @@ -277,7 +379,7 @@ func TestShareRepairBusRejectsAuthenticatedNonparticipantBeforeDelivery(t *testi Sender: 1, Recipient: 2, ContextDigest: [32]byte{0x44}, - Payload: []byte("participant-ciphertext"), + Payload: bytes.Repeat([]byte{0x32}, shareRepairEncryptedScalarPayloadLength), }} bus.handleMessage(fakeNetMessage{ senderPublicKey: fixture.operatorA, @@ -292,3 +394,175 @@ func TestShareRepairBusRejectsAuthenticatedNonparticipantBeforeDelivery(t *testi 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_runner_frost_native.go b/pkg/frost/signing/share_repair_runner_frost_native.go index 522e3e10a5..f1151ed0e5 100644 --- a/pkg/frost/signing/share_repair_runner_frost_native.go +++ b/pkg/frost/signing/share_repair_runner_frost_native.go @@ -106,18 +106,20 @@ type shareRepairInstalledWire struct { } 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 + 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 { @@ -167,6 +169,7 @@ func RunShareRepair( channel, membershipValidator, shareRepairParticipantSet(authorization), + validated.digest, ) if err != nil { return nil, err @@ -424,12 +427,9 @@ func (runner *shareRepairRunner) collectAnnouncements( continue } if message.Type != shareRepairAnnouncementMessage { - if len(runner.pending) >= shareRepairMaximumPendingMessages { - return nil, fmt.Errorf( - "share-repair pending-message limit exceeded before announcements completed", - ) + if err := runner.bufferPendingMessage(message); err != nil { + return nil, err } - runner.pending = append(runner.pending, message) continue } parsed, err := btcec.ParsePubKey(message.EphemeralPublicKey) @@ -458,6 +458,36 @@ func (runner *shareRepairRunner) collectAnnouncements( 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, @@ -501,8 +531,26 @@ func (runner *shareRepairRunner) nextMessage( ) (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 { @@ -513,6 +561,40 @@ func (runner *shareRepairRunner) nextMessage( } } +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, @@ -596,18 +678,18 @@ func (runner *shareRepairRunner) runHelper( continue } senderIdentifier := uint16(message.Sender) - if existing := deltas[senderIdentifier]; existing != nil { - if !bytes.Equal(existing.Payload, message.Payload) { - return fmt.Errorf("repair helper [%d] equivocated its delta", 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 } - deltas[senderIdentifier] = &NativeShareRepairEncryptedDelta{ - ContextDigest: runner.contextWire, - SenderIdentifier: senderIdentifier, - RecipientIdentifier: uint16(runner.member), - Payload: append([]byte(nil), message.Payload...), - } + retainFirstShareRepairDelta( + deltas, + message, + runner.contextWire, + uint16(runner.member), + ) } ordered := make([]*NativeShareRepairEncryptedDelta, 0, len(deltas)) for _, sender := range runner.authorization.HelperIdentifiers { @@ -694,17 +776,13 @@ func (runner *shareRepairRunner) runTarget( continue } helperIdentifier := uint16(message.Sender) - if existing := sigmas[helperIdentifier]; existing != nil { - if !bytes.Equal(existing.Payload, message.Payload) { - return nil, fmt.Errorf("repair helper [%d] equivocated its sigma", 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 } - sigmas[helperIdentifier] = &NativeShareRepairEncryptedSigma{ - ContextDigest: runner.contextWire, - HelperIdentifier: helperIdentifier, - Payload: append([]byte(nil), message.Payload...), - } + retainFirstShareRepairSigma(sigmas, message, runner.contextWire) } ordered := make([]*NativeShareRepairEncryptedSigma, 0, len(sigmas)) for _, helper := range runner.authorization.HelperIdentifiers { diff --git a/pkg/frost/signing/share_repair_runner_frost_native_test.go b/pkg/frost/signing/share_repair_runner_frost_native_test.go index 3488e21c57..e36ca11ee3 100644 --- a/pkg/frost/signing/share_repair_runner_frost_native_test.go +++ b/pkg/frost/signing/share_repair_runner_frost_native_test.go @@ -32,6 +32,36 @@ type testShareRepairSessionOverrideEngine struct { 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, @@ -1093,6 +1123,363 @@ func TestShareRepairRunnerRejectsPendingMessageFlood(t *testing.T) { } } +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) From 0da5b53bc64cdbd5de700111e68d36bf3050299e Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 09:41:06 -0400 Subject: [PATCH 6/7] chore(ci): advance FROST signer pin --- ci/frost-signer-pin.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 7c353c403a..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=87492db35f12566f83429fb2a2f751811b5afd95 +FROST_SIGNER_MIRROR_REF=c0d08c0c0c831c54ff765c9520be674789b12efa From 53c6f4c8107671f4ee8f013406afd375ac4fc399 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 12:42:58 -0400 Subject: [PATCH 7/7] fix(frost): align share-repair session IDs --- .../signing/share_repair_frost_native.go | 30 ++++++++++++-- .../signing/share_repair_frost_native_test.go | 39 +++++++++++++++++++ .../share_repair_runner_frost_native_test.go | 21 ++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/pkg/frost/signing/share_repair_frost_native.go b/pkg/frost/signing/share_repair_frost_native.go index e0d9c87f99..01cbcca5a7 100644 --- a/pkg/frost/signing/share_repair_frost_native.go +++ b/pkg/frost/signing/share_repair_frost_native.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/btcsuite/btcd/btcec/v2" "github.com/keep-network/keep-core/pkg/protocol/group" @@ -33,6 +34,7 @@ const ( 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 @@ -311,6 +313,30 @@ func writeShareRepairLengthPrefixed(buffer *bytes.Buffer, value []byte) error { 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. @@ -324,9 +350,7 @@ func ComputeShareRepairAuthorizationDigest( if authorization.Schema != ShareRepairAuthorizationSchema { return result, fmt.Errorf("unsupported share-repair authorization schema") } - if strings.TrimSpace(authorization.SessionID) == "" || - authorization.SessionID != strings.TrimSpace(authorization.SessionID) || - len(authorization.SessionID) > 256 { + if !isValidShareRepairSessionID(authorization.SessionID) { return result, fmt.Errorf("share-repair session id is invalid") } if authorization.Threshold < 2 || authorization.ParticipantCount < authorization.Threshold || diff --git a/pkg/frost/signing/share_repair_frost_native_test.go b/pkg/frost/signing/share_repair_frost_native_test.go index 148e8434cc..0cd95a94dc 100644 --- a/pkg/frost/signing/share_repair_frost_native_test.go +++ b/pkg/frost/signing/share_repair_frost_native_test.go @@ -179,6 +179,45 @@ func TestShareRepairAuthorizationDigestFrozenVector(t *testing.T) { } } +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) diff --git a/pkg/frost/signing/share_repair_runner_frost_native_test.go b/pkg/frost/signing/share_repair_runner_frost_native_test.go index e36ca11ee3..a7a2f682a9 100644 --- a/pkg/frost/signing/share_repair_runner_frost_native_test.go +++ b/pkg/frost/signing/share_repair_runner_frost_native_test.go @@ -10,6 +10,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "sync" "testing" "time" @@ -653,6 +654,26 @@ func TestPrepareShareRepairTransportRosterEntry(t *testing.T) { } }) + 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