diff --git a/cmd/start.go b/cmd/start.go index c5bc8902f2..c9323ff311 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -65,11 +65,64 @@ Environment variables: func start(cmd *cobra.Command) error { ctx := context.Background() - beaconChain, tbtcChain, blockCounter, signing, operatorPrivateKey, err := - ethereum.Connect(ctx, clientConfig.Ethereum) + var primaryEthereumTransport *tbtc.FrostPrimaryEthereumTransport + var err error + if clientConfig.Tbtc.EnableFrostPreSignAuthorization && + !clientConfig.LibP2P.Bootstrap { + historyConfig := clientConfig.Tbtc.FrostRetainedGroupHistory + primaryEthereumTransport, err = + tbtc.NewFrostPrimaryEthereumTransport( + ctx, + tbtc.FrostPrimaryEthereumTransportConfig{ + URL: clientConfig.Ethereum.URL, + RequestTimeout: historyConfig.RequestTimeout, + TLSRootCAs: historyConfig.PrimaryTLSRootCAs, + Resolver: historyConfig.Resolver, + }, + ) + if err != nil { + return fmt.Errorf( + "cannot initialize guarded primary Ethereum transport: [%w]", + err, + ) + } + } + + var ( + beaconChain *ethereum.BeaconChain + tbtcChain *ethereum.TbtcChain + blockCounter chain.BlockCounter + signing chain.Signing + operatorPrivateKey *operator.PrivateKey + ) + if primaryEthereumTransport != nil { + beaconChain, + tbtcChain, + blockCounter, + signing, + operatorPrivateKey, + err = ethereum.ConnectWithClient( + ctx, + clientConfig.Ethereum, + primaryEthereumTransport.Client(), + ) + } else { + beaconChain, + tbtcChain, + blockCounter, + signing, + operatorPrivateKey, + err = ethereum.Connect(ctx, clientConfig.Ethereum) + } if err != nil { + if primaryEthereumTransport != nil { + primaryEthereumTransport.Close() + } return fmt.Errorf("error connecting to Ethereum node: [%v]", err) } + if primaryEthereumTransport != nil { + defer primaryEthereumTransport.Close() + } netProvider, err := initializeNetwork( ctx, @@ -162,6 +215,25 @@ func start(cmd *cobra.Command) error { btcChain, ) + var retainedGroupHistorySource interface{ Close() } + if clientConfig.Tbtc.EnableFrostPreSignAuthorization { + source, err := tbtc.NewFrostRetainedGroupHistorySource( + ctx, + clientConfig.Tbtc.FrostRetainedGroupHistory, + primaryEthereumTransport, + primaryEthereumTransport.ChainID(), + ) + if err != nil { + return fmt.Errorf( + "cannot initialize independent FROST retained-group history source: [%w]", + err, + ) + } + retainedGroupHistorySource = source + defer retainedGroupHistorySource.Close() + clientConfig.Tbtc.FrostRetainedGroupHistorySource = source + } + err = tbtc.Initialize( ctx, tbtcChain, diff --git a/docs/development/frost-retained-group-transport-attestation.adoc b/docs/development/frost-retained-group-transport-attestation.adoc new file mode 100644 index 0000000000..e5726b4a3a --- /dev/null +++ b/docs/development/frost-retained-group-transport-attestation.adoc @@ -0,0 +1,254 @@ += FROST retained-group endpoint and transport attestation v1 + +This document specifies the wire contract required by the FROST retained-group +history export and independent Ethereum verifier endpoints. It is a fail-closed +protocol: a normal HTTPS or JSON-RPC response without all of these proofs is not +conforming. + +== Endpoint roles + +The activation manifest commits a source identity and two endpoint identities: + +* `retained-history-export` +* `retained-history-verifier` + +Each endpoint identity commits all of the following: + +* canonical HTTPS URL, canonical DNS name, resolved CNAME, and the hash of the + frozen resolved IP set; +* TLS 1.3 leaf SPKI hash and exactly one SPIFFE URI SAN; +* backend Ed25519 SPKI hash; +* operator Ed25519 SPKI hash; +* transport-attestation Ed25519 SPKI hash; and +* TLS exporter protocol ID. + +The source identity additionally commits the retained-history envelope-signing +Ed25519 SPKI hash. The TLS leaf, backend, operator, transport-attestation, and +history-envelope keys are distinct roles. The export and verifier instances +must not reuse any role key, SPIFFE ID, trust domain, DNS/CNAME identity, or +resolved backend address. + +Each endpoint `TrustDomainID` is exactly the authority component of its SPIFFE +service identity. The export and verifier therefore use different SPIFFE trust +domains, not merely different paths under one authority. + +`BackendServiceFingerprint` and `OperatorFingerprint` are SHA-256 hashes of +DER-encoded PKIX Ed25519 SubjectPublicKeyInfo values. They are not arbitrary +labels. The backend and operator key holders sign every response, as described +below. The backend key must be held by the backend being identified; the +operator key must be held by the manifest-authorized operator. Co-locating +either key only at an untrusted TLS edge defeats the role separation. + +== Canonical transcript encoding + +Every transcript starts with its ASCII domain string, including the terminal +NUL byte. Each field is then appended in the specified order as: + +.... +uint64_be(length(field_name)) +field_name bytes +uint64_be(length(field_value)) +field_value bytes +.... + +Text values use their exact UTF-8 bytes. A `bytes32` value is the raw 32 bytes, +not its hexadecimal representation. A `uint64` value is eight-byte +big-endian. + +Endpoint and source fingerprints use the field order defined by +`computeFrostRetainedGroupEndpointFingerprint` and +`computeFrostRetainedGroupSourceEndpointFingerprint`. Frozen vectors are in +`TestFrostRetainedGroupIdentityFingerprintsFrozen`. + +== TLS profile + +The connection must: + +* negotiate exactly TLS 1.3 and HTTP/1.1; +* pass normal PKIX validation for the canonical endpoint host; +* present the manifest-pinned leaf SPKI; +* present a non-CA X.509-SVID leaf with `digitalSignature`, without + `keyCertSign` or `cRLSign`; +* include both `serverAuth` and `clientAuth` when an EKU extension is present; + and +* contain exactly one URI SAN, equal to the manifest SPIFFE service identity. + +The client connects only to the manifest-frozen IP set. Proxies, redirects, +connection reuse, compression, content transformation, query strings, encoded +paths, path normalization, and Host overrides are forbidden. + +== Request challenge + +For every POST, the client generates 32 fresh random bytes and sends their +lowercase, unprefixed hexadecimal encoding in: + +.... +Tbtc-Retained-Transport-Challenge +.... + +The request method, absolute canonical request target, and SHA-256 of the exact +request-body bytes are bound into the proof. + +== TLS exporter + +The exporter context is the canonical transcript with domain: + +.... +tbtc-frost-retained-group-tls-exporter-context/v1\0 +.... + +and fields, in order: + +.... +endpointFingerprint bytes32 +challenge bytes32 +requestMethod text +requestTarget text +requestBodySha256 bytes32 +responseStatus uint64 +responseBodySha256 bytes32 +.... + +The TLS exporter call is: + +.... +label = "EXPORTER-tbtc-frost-retained-group-v1" +context = exporter_context_sha256 +length = 32 +.... + +The resulting 32 bytes are hashed with the canonical transcript domain +`tbtc-frost-retained-group-tls-exporter-value/v1\0` and one byte-string field +named `exporterValue`. + +== Response attestation + +Every response, including non-200 responses, carries exactly one: + +.... +Tbtc-Retained-Transport-Attestation +.... + +The header is canonical padded standard Base64 of a strict JSON object with +schema `tbtc-frost-retained-group-transport-attestation/v1`. Duplicate, +unknown, missing, non-canonically encoded, or oversized values are rejected. +The JSON fields are defined by `frostRetainedGroupTransportAttestation`. + +The signed transport transcript uses domain: + +.... +tbtc-frost-retained-group-transport-attestation/v1\0 +.... + +and these fields, in order: + +.... +schema text +role text +endpointFingerprint bytes32 +canonicalEndpoint text +canonicalDNSName text +resolvedDNSName text +resolvedPeerIP text +tlsLeafSpkiHash bytes32 +serviceIdentity text +backendServiceFingerprint bytes32 +operatorFingerprint bytes32 +attestationKeyHash bytes32 +tlsExporterProtocolID bytes32 +challenge bytes32 +requestMethod text +requestTarget text +requestBodySha256 bytes32 +responseStatus uint64 +responseBodySha256 bytes32 +issuedAtUnixMs uint64 +expiresAtUnixMs uint64 +tlsExporterContextSha256 bytes32 +tlsExporterValueSha256 bytes32 +.... + +The backend signs a domain-separated transcript containing the transport +transcript digest: + +.... +domain = "tbtc-frost-retained-group-backend-attestation/v1\0" +field = transportAttestationDigest bytes32 +.... + +The operator signs the equivalent transcript under domain: + +.... +tbtc-frost-retained-group-operator-attestation/v1\0 +.... + +The transport-attestation key signs the transport transcript digest directly. +All three algorithms are exactly `ed25519`. Every public key is canonical +padded Base64 of DER PKIX SubjectPublicKeyInfo and must hash to its distinct +manifest role. Every signature is canonical padded Base64. + +Attestations have a maximum 30-second lifetime. Clients allow at most five +seconds of clock skew and reject non-canonical or overflowing decimal +timestamps. + +== Frozen conformance vector + +`TestFrostRetainedGroupTransportAttestationFrozenVectors` is the normative +machine-readable vector. Its fixed outputs are: + +.... +TLS exporter context: +50587c477f56e9d2597c4cf4d9cf69d69a8ded13b9a074d1c18042eb4aea2e30 + +TLS exporter value hash: +62e2fd2ccb30b5e2ca49a99f04cbb01a947d8228060ee377dc6896c6c96a08b0 + +Transport attestation digest: +53eb0f08ca2c1761592ec90387c5aba9913668ef68c1e4cf6f20dbbd531e267b + +Backend digest: +7fb92657871cf2dd864eeffaac5d699f1131f81b04507b2092f90987aa4a722c + +Operator digest: +02495d610fde3ad437a22de7e93dae5394d34a0ef4e590f4d613e3fa6197cbc4 + +Transport signature: +AV8bIwrHUE6ACkJ9vQWtQTVXGJDR8Nm42nQqCka8NISgXIIPbW2abLhOrGlX/bnYVUUUMbhRfcyYCf+asQ9KBg== + +Backend signature: +0ACdPMwZaraKgWpVRMNnwc6qgLtB90rGDoj18kYCcbd2jJG36VCVf0j5OhHlqP5KoQbzKwJ9BRelXF1pYd/yDA== + +Operator signature: +lx22S9wJxUSOsgZZlWqP7S5bTdoULktZQ9Ao7KDwdpI8zJDkq3AD76rj737DSthJUt0+6IEdElMyiCnXwdgFBg== + +SHA-256 of the exact JSON response-attestation object: +174da49defe9c6b6c669a8a33177ccf6257df24e1316c650734c8ff35099dcdb +.... + +An independently implemented endpoint must reproduce these values before it is +eligible for activation. + +== Primary-endpoint independence enforcement + +A FROST-enabled node creates its primary Ethereum client through +`FrostPrimaryEthereumTransport`. The transport freezes the first complete DNS +answer, disables proxies and redirects, requires TLS 1.3, and records the +certificate, SPKI, SPIFFE authority, remote IP, and TLS-exporter identity of +every live HTTPS connection or WSS reconnect before that connection can carry +an RPC response. + +The retained-history source binds its export and verifier endpoint identities +to that same transport. Construction rejects overlap in the frozen DNS/CNAME/IP +sets and in the manifest-pinned role identities. Every subsequently observed +primary, export, or verifier TLS peer is registered with the shared separation +policy; an address, certificate, SPKI, or SPIFFE-authority alias poisons the +policy fail closed. Verification boundaries also re-resolve the primary name +and require the result to equal the frozen answer, excluding split-horizon DNS +or later DNS drift. + +The primary and retained clients therefore enforce independence against the +connections that actually carry their responses. This replaces the earlier +URL-only observation limitation. Operational activation still requires three +genuinely independent endpoint identities and network paths satisfying these +checks; deploying distinct hostnames in front of shared TLS or backend +identity is intentionally rejected. diff --git a/pkg/chain/ethereum/ethereum.go b/pkg/chain/ethereum/ethereum.go index a55054c70d..817450c682 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -35,11 +35,12 @@ var logger = log.Logger("keep-ethereum") // provides the implementation of generic features like balance monitor, // block counter and similar. type baseChain struct { - key *keystore.Key - client ethutil.EthereumClient - rpcClient *rpc.Client - rpcLimiter *rate.Limiter - chainID *big.Int + key *keystore.Key + client ethutil.EthereumClient + rpcClient *rpc.Client + rpcLimiter *rate.Limiter + chainID *big.Int + frostPrimaryEthereumRequestTimeout time.Duration blockCounter *ethereum.BlockCounter nonceManager *ethereum.NonceManager @@ -62,6 +63,14 @@ type baseChain struct { tokenStaking *contract.TokenStaking } +// EthereumClient is the client surface required to build chain handles from +// an already-established transport. +type EthereumClient interface { + ethutil.EthereumClient + ChainID(context.Context) (*big.Int, error) + Client() *rpc.Client +} + // Connect creates Random Beacon and TBTC Ethereum chain handles. func Connect( ctx context.Context, @@ -82,7 +91,44 @@ func Connect( err, ) } + return connectWithClient(ctx, config, client) +} +// ConnectWithClient creates Random Beacon and TBTC Ethereum chain handles +// using the exact supplied client. It is used by the FROST start path so the +// chain handle and retained-history independence monitor share one guarded +// primary transport. +func ConnectWithClient( + ctx context.Context, + config ethereum.Config, + client EthereumClient, +) ( + *BeaconChain, + *TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, +) { + if client == nil { + return nil, nil, nil, nil, nil, + fmt.Errorf("Ethereum client is nil") + } + return connectWithClient(ctx, config, client) +} + +func connectWithClient( + ctx context.Context, + config ethereum.Config, + client EthereumClient, +) ( + *BeaconChain, + *TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, +) { baseChain, err := newBaseChain(ctx, config, client) if err != nil { return nil, nil, nil, nil, nil, fmt.Errorf( @@ -219,7 +265,7 @@ func validateContractsAddresses( func newBaseChain( ctx context.Context, config ethereum.Config, - client *ethclient.Client, + client EthereumClient, ) (*baseChain, error) { chainID, err := client.ChainID(ctx) if err != nil { @@ -254,6 +300,13 @@ func newBaseChain( RequestsPerSecondLimit: config.RequestsPerSecondLimit, ConcurrencyLimit: config.ConcurrencyLimit, }) + var frostPrimaryEthereumRequestTimeout time.Duration + if timeoutSource, ok := client.(interface { + FrostPrimaryEthereumRequestTimeout() time.Duration + }); ok { + frostPrimaryEthereumRequestTimeout = + timeoutSource.FrostPrimaryEthereumRequestTimeout() + } blockCounter, err := ethutil.NewBlockCounter(clientWithAddons) if err != nil { @@ -302,16 +355,17 @@ func newBaseChain( } return &baseChain{ - key: key, - client: clientWithAddons, - rpcClient: client.Client(), - rpcLimiter: rpcLimiter, - chainID: chainID, - blockCounter: blockCounter, - nonceManager: nonceManager, - miningWaiter: miningWaiter, - transactionMutex: transactionMutex, - tokenStaking: tokenStaking, + key: key, + client: clientWithAddons, + rpcClient: client.Client(), + rpcLimiter: rpcLimiter, + chainID: chainID, + frostPrimaryEthereumRequestTimeout: frostPrimaryEthereumRequestTimeout, + blockCounter: blockCounter, + nonceManager: nonceManager, + miningWaiter: miningWaiter, + transactionMutex: transactionMutex, + tokenStaking: tokenStaking, }, nil } diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 73df3de69b..413f923da8 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -86,18 +86,19 @@ var frostWalletRegistryAuthorizationABI = mustParseABI( type TbtcChain struct { *baseChain - bridge *tbtccontract.Bridge - bridgeAddress common.Address - maintainerProxy *tbtccontract.MaintainerProxy - walletRegistry *ecdsacontract.WalletRegistry - sortitionPool *ecdsacontract.EcdsaSortitionPool - frostWalletRegistry *frostabi.FrostWalletRegistry - frostWalletRegistryAddr common.Address - frostDkgValidator *frostvalidatorabi.FrostDkgValidator - frostSortitionPool *ecdsacontract.EcdsaSortitionPool - walletProposalValidator *tbtccontract.WalletProposalValidator - redemptionWatchtower *tbtccontract.RedemptionWatchtower - frostPreSignAuthorizationAdapter *frostPreSignEthereumAdapter + bridge *tbtccontract.Bridge + bridgeAddress common.Address + maintainerProxy *tbtccontract.MaintainerProxy + walletRegistry *ecdsacontract.WalletRegistry + sortitionPool *ecdsacontract.EcdsaSortitionPool + frostWalletRegistry *frostabi.FrostWalletRegistry + frostWalletRegistryAddr common.Address + frostDkgValidator *frostvalidatorabi.FrostDkgValidator + frostSortitionPool *ecdsacontract.EcdsaSortitionPool + walletProposalValidator *tbtccontract.WalletProposalValidator + redemptionWatchtower *tbtccontract.RedemptionWatchtower + frostPreSignAuthorizationAdapter *frostPreSignEthereumAdapter + frostPreSignAuthorizationVerifier *frostPreSignEthereumAdapter // ecdsaDkgValidatorAddress optional; when zero, TBTC uses defaultGroupParameters(network). ecdsaDkgValidatorAddress common.Address diff --git a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go index b0cc7829e8..b604f85b3d 100644 --- a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go +++ b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go @@ -3,6 +3,7 @@ package ethereum import ( "context" "fmt" + "math/big" "strings" "testing" @@ -270,8 +271,19 @@ func TestFrostPreSignExactHashReader_WrappedProductionClient(t *testing.T) { ConcurrencyLimit: config.ConcurrencyLimit, }), } + evidenceReader, err := newFrostPreSignPrimaryEthereumReader( + chain.client, + chain.rpcClient, + big.NewInt(1), + 0, + chain.rpcLimiter, + ) + if err != nil { + t.Fatal(err) + } adapter := &frostPreSignEthereumAdapter{ - chain: &TbtcChain{baseChain: chain}, + chain: &TbtcChain{baseChain: chain}, + reader: evidenceReader, } reader, err := adapter.exactHashReader() diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index f953b73c87..0ecdd8532e 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -12,8 +12,10 @@ import ( "encoding/json" "fmt" "io" + "math" "math/big" "os" + "reflect" "sort" "strings" "sync" @@ -33,7 +35,12 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v4" +const ( + frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v5" + + frostPreSignFinalityAgreementAttempts = 4 + frostPreSignFinalityAgreementRetryDelay = time.Second +) const frostPreSignBridgeABIJSON = `[ {"type":"function","name":"previewP2TRTransactionAuthorization","stateMutability":"view","inputs":[{"name":"payload","type":"bytes"}],"outputs":[{"name":"","type":"bytes"}]}, @@ -183,23 +190,64 @@ type frostPreSignManifestEthereum struct { } type frostPreSignManifestCanonicalJournal struct { - StoreID string `json:"storeID"` - StoreFingerprint string `json:"storeFingerprint"` - ClusterFingerprint string `json:"clusterFingerprint"` - Checkpoint frostPreSignManifestPoint `json:"checkpoint"` - DescriptorSetHash string `json:"descriptorSetHash"` - SourceTrustDomainID string `json:"sourceTrustDomainID"` - SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` - SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` - MinimumGeneration uint64 `json:"minimumGeneration"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + Checkpoint frostPreSignManifestPoint `json:"checkpoint"` + DescriptorSetHash string `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` + SourceIdentity frostPreSignManifestRetainedSourceIdentity `json:"sourceIdentity"` + MinimumGeneration uint64 `json:"minimumGeneration"` +} + +type frostPreSignManifestRetainedEndpointIdentity struct { + Schema string `json:"schema"` + Role string `json:"role"` + TrustDomainID string `json:"trustDomainID"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedAddressSetHash string `json:"resolvedAddressSetHash"` + TLSLeafSPKIHash string `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint string `json:"backendServiceFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + AttestationKeyHash string `json:"attestationKeyHash"` + TLSExporterProtocolID string `json:"tlsExporterProtocolID"` + EndpointFingerprint string `json:"endpointFingerprint"` +} + +type frostPreSignManifestRetainedSourceIdentity struct { + Schema string `json:"schema"` + TrustDomainID string `json:"trustDomainID"` + EndpointFingerprint string `json:"endpointFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + HistorySignerKeyHash string `json:"historySignerKeyHash"` + Export frostPreSignManifestRetainedEndpointIdentity `json:"export"` + Verifier frostPreSignManifestRetainedEndpointIdentity `json:"verifier"` } type frostPreSignManifestQuarantineJournal struct { - ProtocolID string `json:"protocolID"` - StoreID string `json:"storeID"` - StoreFingerprint string `json:"storeFingerprint"` - ClusterFingerprint string `json:"clusterFingerprint"` - MinimumGeneration uint64 `json:"minimumGeneration"` + ProtocolID string `json:"protocolID"` + LiftProtocolID string `json:"liftProtocolID"` + TombstoneProtocolID string `json:"tombstoneProtocolID"` + CheckpointAuthorityThreshold uint64 `json:"checkpointAuthorityThreshold"` + CheckpointAuthorities []frostPreSignManifestLiftAuthority `json:"checkpointAuthorities"` + CheckpointMinimumSequence uint64 `json:"checkpointMinimumSequence"` + CheckpointPredecessorHash string `json:"checkpointPredecessorHash"` + LiftAuthorityThreshold uint64 `json:"liftAuthorityThreshold"` + LiftAuthorities []frostPreSignManifestLiftAuthority `json:"liftAuthorities"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + MinimumGeneration uint64 `json:"minimumGeneration"` +} + +type frostPreSignManifestLiftAuthority struct { + AuthorityID string `json:"authorityID"` + PublicKeySPKIHash string `json:"publicKeySpkiHash"` } type frostPreSignManifestNativeSignerAnchor struct { @@ -260,6 +308,7 @@ type frostPreSignActivationManifest struct { FrostSigner frostPreSignManifestFrostSigner `json:"frostSigner"` manifestHash [32]byte activationAuthorityPublicKey [32]byte + activationAuthorityKeyHash [32]byte } type frostPreSignDeploymentPin struct { @@ -298,6 +347,8 @@ type frostPreSignLinkedLibraryPin struct { type frostPreSignEthereumAdapter struct { chain *TbtcChain + reader tbtc.FrostPreSignEthereumEvidenceVerifier + fromAddress common.Address profile tbtc.FrostPreSignActivationProfile manifest frostPreSignActivationManifest deployments []frostPreSignDeploymentPin @@ -322,6 +373,13 @@ type frostPreSignExactHashReader interface { ) ([]byte, error) } +type frostPreSignStandardEthereumReader interface { + HeaderByNumber(context.Context, *big.Int) (*types.Header, error) + HeaderByHash(context.Context, common.Hash) (*types.Header, error) + TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) + FilterLogs(context.Context, geth.FilterQuery) ([]types.Log, error) +} + type frostPreSignBitcoinTxInfo struct { Version [4]byte InputVector []byte @@ -410,10 +468,19 @@ func (tc *TbtcChain) ConfigureFrostPreSignAuthorization( manifestPath string, trustedEnvelopeSignerKeyHash string, expectedLinkedLibraryDescriptorSetHash string, + ethereumEvidenceVerifier tbtc.FrostPreSignEthereumEvidenceVerifier, ) (*tbtc.FrostPreSignActivationProfile, error) { if ctx == nil { return nil, fmt.Errorf("FROST activation context is nil") } + if tc == nil || tc.baseChain == nil { + return nil, fmt.Errorf("FROST Ethereum chain is unavailable") + } + if ethereumEvidenceVerifier == nil { + return nil, fmt.Errorf( + "independent FROST Ethereum evidence verifier is nil", + ) + } manifest, err := loadFrostPreSignActivationManifest( manifestPath, trustedEnvelopeSignerKeyHash, @@ -433,11 +500,51 @@ func (tc *TbtcChain) ConfigureFrostPreSignAuthorization( if err != nil || expectedDescriptorSetHash != manifestDescriptorSetHash { return nil, fmt.Errorf("signed activation linked-library descriptor set differs from this signer build") } - adapter, err := newFrostPreSignEthereumAdapter(ctx, tc, manifest) + primaryReader, err := newFrostPreSignPrimaryEthereumReader( + tc.client, + tc.rpcClient, + tc.chainID, + tc.frostPrimaryEthereumRequestTimeout, + tc.rpcLimiter, + ) + if err != nil { + return nil, err + } + adapter, err := newFrostPreSignEthereumAdapter( + ctx, + tc, + manifest, + primaryReader, + true, + ) if err != nil { return nil, err } + verifier, err := newFrostPreSignEthereumAdapter( + ctx, + tc, + manifest, + ethereumEvidenceVerifier, + false, + ) + if err != nil { + return nil, fmt.Errorf( + "independent FROST Ethereum verifier rejected activation: [%w]", + err, + ) + } + if _, err := frostPreSignMatchingCurrentFinality( + ctx, + adapter, + verifier, + ); err != nil { + return nil, fmt.Errorf( + "FROST Ethereum endpoints do not share one finalized activation point: [%w]", + err, + ) + } tc.frostPreSignAuthorizationAdapter = adapter + tc.frostPreSignAuthorizationVerifier = verifier profile := adapter.profile return &profile, nil } @@ -520,6 +627,7 @@ func loadFrostPreSignActivationManifest( } manifest.manifestHash = payloadHash copy(manifest.activationAuthorityPublicKey[:], publicKey) + manifest.activationAuthorityKeyHash = trustedKeyHash if err := validateFrostPreSignActivationManifest(manifest); err != nil { return nil, err } @@ -543,21 +651,69 @@ func loadFrostPreSignActivationManifest( } type frostPreSignCanonicalHashReader struct { - headerReader interface { + standardReader frostPreSignStandardEthereumReader + headerReader interface { HeaderByHash(context.Context, common.Hash) (*types.Header, error) } - rpcClient *rpc.Client - rpcLimiter interface { + rpcClient *rpc.Client + chainID *big.Int + requestTimeout time.Duration + rpcLimiter interface { AcquirePermit() error ReleasePermit() } } +func (reader *frostPreSignCanonicalHashReader) ChainID( + context.Context, +) (*big.Int, error) { + if reader.chainID == nil { + return nil, fmt.Errorf("Ethereum chain ID is unavailable") + } + return new(big.Int).Set(reader.chainID), nil +} + +func (reader *frostPreSignCanonicalHashReader) HeaderByNumber( + ctx context.Context, + number *big.Int, +) (*types.Header, error) { + if reader.standardReader == nil { + return nil, fmt.Errorf("standard Ethereum reader is unavailable") + } + return reader.standardReader.HeaderByNumber(ctx, number) +} + func (reader *frostPreSignCanonicalHashReader) HeaderByHash( ctx context.Context, blockHash common.Hash, ) (*types.Header, error) { - return reader.headerReader.HeaderByHash(ctx, blockHash) + if reader.standardReader == nil { + if reader.headerReader == nil { + return nil, fmt.Errorf("exact-hash Ethereum reader is unavailable") + } + return reader.headerReader.HeaderByHash(ctx, blockHash) + } + return reader.standardReader.HeaderByHash(ctx, blockHash) +} + +func (reader *frostPreSignCanonicalHashReader) TransactionReceipt( + ctx context.Context, + transactionHash common.Hash, +) (*types.Receipt, error) { + if reader.standardReader == nil { + return nil, fmt.Errorf("standard Ethereum reader is unavailable") + } + return reader.standardReader.TransactionReceipt(ctx, transactionHash) +} + +func (reader *frostPreSignCanonicalHashReader) FilterLogs( + ctx context.Context, + query geth.FilterQuery, +) ([]types.Log, error) { + if reader.standardReader == nil { + return nil, fmt.Errorf("standard Ethereum reader is unavailable") + } + return reader.standardReader.FilterLogs(ctx, query) } func (reader *frostPreSignCanonicalHashReader) CodeAtHash( @@ -565,6 +721,8 @@ func (reader *frostPreSignCanonicalHashReader) CodeAtHash( account common.Address, blockHash common.Hash, ) ([]byte, error) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() var result hexutil.Bytes err := reader.callContext( ctx, @@ -582,6 +740,8 @@ func (reader *frostPreSignCanonicalHashReader) StorageAtHash( key common.Hash, blockHash common.Hash, ) ([]byte, error) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() var result hexutil.Bytes err := reader.callContext( ctx, @@ -599,6 +759,8 @@ func (reader *frostPreSignCanonicalHashReader) CallContractAtHash( message geth.CallMsg, blockHash common.Hash, ) ([]byte, error) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() var result hexutil.Bytes err := reader.callContext( ctx, @@ -622,10 +784,21 @@ func (reader *frostPreSignCanonicalHashReader) callContext( } defer reader.rpcLimiter.ReleasePermit() } - return reader.rpcClient.CallContext(ctx, result, method, args...) } +func (reader *frostPreSignCanonicalHashReader) requestContext( + ctx context.Context, +) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + if reader.requestTimeout <= 0 { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, reader.requestTimeout) +} + func frostPreSignCallArgument(message geth.CallMsg) map[string]interface{} { result := map[string]interface{}{ "from": message.From, @@ -665,19 +838,38 @@ func (adapter *frostPreSignEthereumAdapter) exactHashReader() ( frostPreSignExactHashReader, error, ) { - headerReader, ok := adapter.chain.client.(interface { - HeaderByHash(context.Context, common.Hash) (*types.Header, error) - }) + if adapter == nil || adapter.reader == nil { + return nil, fmt.Errorf("FROST Ethereum evidence reader is unavailable") + } + return adapter.reader, nil +} + +func newFrostPreSignPrimaryEthereumReader( + client interface{}, + rpcClient *rpc.Client, + chainID *big.Int, + requestTimeout time.Duration, + rpcLimiter interface { + AcquirePermit() error + ReleasePermit() + }, +) (tbtc.FrostPreSignEthereumEvidenceVerifier, error) { + standardReader, ok := client.(frostPreSignStandardEthereumReader) if !ok { - return nil, fmt.Errorf("Ethereum client does not expose exact-block-hash headers") + return nil, fmt.Errorf( + "Ethereum client does not expose required canonical evidence reads", + ) } - if adapter.chain.rpcClient == nil { + if rpcClient == nil || chainID == nil || chainID.Sign() <= 0 { return nil, fmt.Errorf("Ethereum client does not expose canonical EIP-1898 reads") } return &frostPreSignCanonicalHashReader{ - headerReader: headerReader, - rpcClient: adapter.chain.rpcClient, - rpcLimiter: adapter.chain.rpcLimiter, + standardReader: standardReader, + headerReader: standardReader, + rpcClient: rpcClient, + chainID: new(big.Int).Set(chainID), + requestTimeout: requestTimeout, + rpcLimiter: rpcLimiter, }, nil } @@ -809,9 +1001,12 @@ func validateFrostPreSignActivationManifest( "attestation signer key": frost.AttestationSignerKeyHash, "retained group inventory protocol": frost.RetainedGroupInventoryProtocolID, "quarantine journal protocol": frost.QuarantineJournal.ProtocolID, + "quarantine lift protocol": frost.QuarantineJournal.LiftProtocolID, + "quarantine tombstone protocol": frost.QuarantineJournal.TombstoneProtocolID, } { - if _, err := frostPreSignParseBytes32(value); err != nil { - return fmt.Errorf("invalid FROST activation %s: [%w]", name, err) + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == [32]byte{} { + return fmt.Errorf("invalid FROST activation %s", name) } } durableSessionStoreFingerprint, err := frostPreSignParseBytes32( @@ -893,6 +1088,75 @@ func validateFrostPreSignActivationManifest( } parsedJournalValues[name] = parsed } + sourceIdentity, err := frostPreSignRetainedSourceIdentity( + journal.SourceIdentity, + ) + if err != nil { + return fmt.Errorf( + "FROST canonical journal complete endpoint identity is invalid: [%w]", + err, + ) + } + if sourceIdentity.TrustDomainID != journal.SourceTrustDomainID || + sourceIdentity.EndpointFingerprint != + parsedJournalValues["source endpoint fingerprint"] || + sourceIdentity.OperatorFingerprint != + parsedJournalValues["source operator fingerprint"] { + return fmt.Errorf( + "FROST canonical journal complete endpoint identity differs from its aggregate fields", + ) + } + otherRoleHashes := make(map[[32]byte]string) + for name, value := range map[string]string{ + "Ethereum source endpoint": manifest.Ethereum.SourceEndpointFingerprint, + "Ethereum verifier endpoint": manifest.Ethereum.VerifierEndpointFingerprint, + "runtime handshake endpoint": frost.HandshakeEndpointFingerprint, + "Ethereum source operator": manifest.Ethereum.SourceOperatorFingerprint, + "Ethereum verifier operator": manifest.Ethereum.VerifierOperatorFingerprint, + "runtime handshake operator": frost.HandshakeOperatorFingerprint, + "runtime attestation signer": frost.AttestationSignerKeyHash, + } { + parsed, parseErr := frostPreSignParseBytes32(value) + if parseErr != nil || parsed == [32]byte{} { + return fmt.Errorf("FROST %s identity is invalid", name) + } + if previous, exists := otherRoleHashes[parsed]; exists { + return fmt.Errorf( + "FROST %s identity aliases %s", + name, + previous, + ) + } + otherRoleHashes[parsed] = name + } + if previous, exists := otherRoleHashes[manifest.activationAuthorityKeyHash]; exists { + return fmt.Errorf( + "FROST activation authority identity aliases %s", + previous, + ) + } + otherRoleHashes[manifest.activationAuthorityKeyHash] = "activation authority" + for name, value := range map[string][32]byte{ + "retained export endpoint": sourceIdentity.Export.EndpointFingerprint, + "retained verifier endpoint": sourceIdentity.Verifier.EndpointFingerprint, + "retained export TLS leaf": sourceIdentity.Export.TLSLeafSPKIHash, + "retained verifier TLS leaf": sourceIdentity.Verifier.TLSLeafSPKIHash, + "retained export backend": sourceIdentity.Export.BackendServiceFingerprint, + "retained verifier backend": sourceIdentity.Verifier.BackendServiceFingerprint, + "retained export operator": sourceIdentity.Export.OperatorFingerprint, + "retained verifier operator": sourceIdentity.Verifier.OperatorFingerprint, + "retained history signer": sourceIdentity.HistorySignerKeyHash, + "retained export attestation": sourceIdentity.Export.AttestationKeyHash, + "retained verifier attestation": sourceIdentity.Verifier.AttestationKeyHash, + } { + if other, exists := otherRoleHashes[value]; exists { + return fmt.Errorf( + "FROST %s identity aliases %s", + name, + other, + ) + } + } for name, value := range map[string]string{ "Ethereum source endpoint": manifest.Ethereum.SourceEndpointFingerprint, "Ethereum verifier endpoint": manifest.Ethereum.VerifierEndpointFingerprint, @@ -913,19 +1177,36 @@ func validateFrostPreSignActivationManifest( return fmt.Errorf("FROST canonical journal source operator is not independent of %s", name) } } + trustDomains := make(map[string]string) for name, value := range map[string]string{ "Ethereum source": manifest.Ethereum.SourceTrustDomainID, "Ethereum verifier": manifest.Ethereum.VerifierTrustDomainID, "runtime signer": frost.TrustDomainID, + "retained source": journal.SourceTrustDomainID, + "retained export": sourceIdentity.Export.TrustDomainID, + "retained verifier": sourceIdentity.Verifier.TrustDomainID, } { - if strings.TrimSpace(value) == "" || value == journal.SourceTrustDomainID { - return fmt.Errorf("FROST canonical journal source trust domain is not independent of %s", name) + if strings.TrimSpace(value) == "" || value != strings.TrimSpace(value) { + return fmt.Errorf("FROST %s trust domain is invalid", name) + } + if previous, exists := trustDomains[value]; exists { + return fmt.Errorf( + "FROST %s trust domain aliases %s", + name, + previous, + ) } + trustDomains[value] = name } quarantine := frost.QuarantineJournal if strings.TrimSpace(quarantine.StoreID) == "" || len(quarantine.StoreID) > 255 { return fmt.Errorf("FROST quarantine journal manifest is incomplete") } + if err := validateFrostPreSignQuarantineLiftAuthorities( + manifest, + ); err != nil { + return err + } quarantineStoreFingerprint, err := frostPreSignParseBytes32(quarantine.StoreFingerprint) if err != nil || quarantineStoreFingerprint == [32]byte{} { return fmt.Errorf("invalid FROST quarantine journal store fingerprint") @@ -1072,12 +1353,259 @@ func frostPreSignNativeSignerAnchorManifest( return result, nil } +func frostPreSignRetainedEndpointIdentity( + wire frostPreSignManifestRetainedEndpointIdentity, +) (tbtc.FrostRetainedGroupEndpointIdentity, error) { + parse := func(name string, value string) ([32]byte, error) { + parsed, err := frostPreSignParseBytes32(value) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid retained %s: [%w]", name, err) + } + return parsed, nil + } + addressSet, err := parse("resolved address-set hash", wire.ResolvedAddressSetHash) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + leaf, err := parse("TLS leaf SPKI hash", wire.TLSLeafSPKIHash) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + backend, err := parse( + "backend service fingerprint", + wire.BackendServiceFingerprint, + ) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + operator, err := parse("operator fingerprint", wire.OperatorFingerprint) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + attestation, err := parse("attestation key hash", wire.AttestationKeyHash) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + exporter, err := parse("TLS exporter protocol ID", wire.TLSExporterProtocolID) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + fingerprint, err := parse("endpoint fingerprint", wire.EndpointFingerprint) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + return tbtc.FrostRetainedGroupEndpointIdentity{ + Schema: wire.Schema, + Role: wire.Role, + TrustDomainID: wire.TrustDomainID, + CanonicalEndpoint: wire.CanonicalEndpoint, + CanonicalDNSName: wire.CanonicalDNSName, + ResolvedDNSName: wire.ResolvedDNSName, + ResolvedAddressSetHash: addressSet, + TLSLeafSPKIHash: leaf, + ServiceIdentity: wire.ServiceIdentity, + BackendServiceFingerprint: backend, + OperatorFingerprint: operator, + AttestationKeyHash: attestation, + TLSExporterProtocolID: exporter, + EndpointFingerprint: fingerprint, + }, nil +} + +func frostPreSignRetainedSourceIdentity( + wire frostPreSignManifestRetainedSourceIdentity, +) (tbtc.FrostRetainedGroupHistoryIdentity, error) { + endpointFingerprint, err := frostPreSignParseBytes32( + wire.EndpointFingerprint, + ) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + operatorFingerprint, err := frostPreSignParseBytes32( + wire.OperatorFingerprint, + ) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + historySignerKeyHash, err := frostPreSignParseBytes32( + wire.HistorySignerKeyHash, + ) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + exportIdentity, err := frostPreSignRetainedEndpointIdentity(wire.Export) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + verifierIdentity, err := frostPreSignRetainedEndpointIdentity(wire.Verifier) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + result := tbtc.FrostRetainedGroupHistoryIdentity{ + Schema: wire.Schema, + TrustDomainID: wire.TrustDomainID, + EndpointFingerprint: endpointFingerprint, + OperatorFingerprint: operatorFingerprint, + HistorySignerKeyHash: historySignerKeyHash, + Export: exportIdentity, + Verifier: verifierIdentity, + } + if err := tbtc.ValidateFrostRetainedGroupHistoryIdentity(result); err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + return result, nil +} + +func validateFrostPreSignQuarantineLiftAuthorities( + manifest *frostPreSignActivationManifest, +) error { + if manifest == nil { + return fmt.Errorf("FROST quarantine lift manifest is nil") + } + frost := manifest.FrostSigner + quarantine := frost.QuarantineJournal + quarantineProtocolID, _ := frostPreSignParseBytes32(quarantine.ProtocolID) + liftProtocolID, _ := frostPreSignParseBytes32(quarantine.LiftProtocolID) + tombstoneProtocolID, _ := frostPreSignParseBytes32(quarantine.TombstoneProtocolID) + if quarantineProtocolID == liftProtocolID || + quarantineProtocolID == tombstoneProtocolID || + liftProtocolID == tombstoneProtocolID { + return fmt.Errorf("FROST quarantine protocol identities are not distinct") + } + + forbidden := make(map[[32]byte]string) + for name, value := range map[string]string{ + "runtime attestation": frost.AttestationSignerKeyHash, + "runtime exporter": frost.HandshakeOperatorFingerprint, + "retained history source": frost.CanonicalJournal.SourceOperatorFingerprint, + "retained history verifier": frost.CanonicalJournal.SourceIdentity.Verifier.OperatorFingerprint, + "retained history signer": frost.CanonicalJournal.SourceIdentity.HistorySignerKeyHash, + "retained export TLS leaf": frost.CanonicalJournal.SourceIdentity.Export.TLSLeafSPKIHash, + "retained verifier TLS leaf": frost.CanonicalJournal.SourceIdentity.Verifier.TLSLeafSPKIHash, + "retained export backend": frost.CanonicalJournal.SourceIdentity.Export.BackendServiceFingerprint, + "retained verifier backend": frost.CanonicalJournal.SourceIdentity.Verifier.BackendServiceFingerprint, + "retained export attestation": frost.CanonicalJournal.SourceIdentity.Export.AttestationKeyHash, + "retained verifier attestation": frost.CanonicalJournal.SourceIdentity.Verifier.AttestationKeyHash, + "primary history source": manifest.Ethereum.SourceOperatorFingerprint, + "primary history verifier": manifest.Ethereum.VerifierOperatorFingerprint, + } { + hash, err := frostPreSignParseBytes32(value) + if err != nil || hash == [32]byte{} { + return fmt.Errorf("FROST %s role key is invalid", name) + } + forbidden[hash] = name + } + if manifest.activationAuthorityKeyHash == [32]byte{} { + return fmt.Errorf("FROST activation authority key is unavailable") + } + forbidden[manifest.activationAuthorityKeyHash] = "activation" + + checkpointHashes, err := validateFrostPreSignManifestAuthoritySet( + "checkpoint", + quarantine.CheckpointAuthorityThreshold, + quarantine.CheckpointAuthorities, + forbidden, + ) + if err != nil { + return err + } + for hash := range checkpointHashes { + forbidden[hash] = "checkpoint authority" + } + checkpointPredecessorHash, err := frostPreSignParseBytes32( + quarantine.CheckpointPredecessorHash, + ) + if err != nil || + quarantine.CheckpointMinimumSequence == 0 || + quarantine.CheckpointMinimumSequence > 9007199254740991 || + (quarantine.CheckpointMinimumSequence == 1 && + checkpointPredecessorHash != [32]byte{}) || + (quarantine.CheckpointMinimumSequence > 1 && + checkpointPredecessorHash == [32]byte{}) { + return fmt.Errorf( + "FROST checkpoint transparency floor is invalid", + ) + } + if _, err := validateFrostPreSignManifestAuthoritySet( + "quarantine lift", + quarantine.LiftAuthorityThreshold, + quarantine.LiftAuthorities, + forbidden, + ); err != nil { + return err + } + return nil +} + +func validateFrostPreSignManifestAuthoritySet( + name string, + threshold uint64, + authorities []frostPreSignManifestLiftAuthority, + forbidden map[[32]byte]string, +) (map[[32]byte]bool, error) { + if threshold < 2 || len(authorities) < 3 || + threshold > uint64(len(authorities)) || + threshold <= uint64(len(authorities))/2 { + return nil, fmt.Errorf( + "FROST %s authority set must be a production strict majority of at least 2-of-3", + name, + ) + } + seenHashes := make(map[[32]byte]bool, len(authorities)) + previousID := "" + for index, authority := range authorities { + if !validFrostPreSignLiftAuthorityID(authority.AuthorityID) || + (index > 0 && authority.AuthorityID <= previousID) { + return nil, fmt.Errorf( + "FROST %s authority IDs are not canonical and strictly sorted", + name, + ) + } + previousID = authority.AuthorityID + keyHash, err := frostPreSignParseBytes32(authority.PublicKeySPKIHash) + if err != nil || keyHash == [32]byte{} || seenHashes[keyHash] { + return nil, fmt.Errorf( + "FROST %s authority SPKI hashes are invalid or duplicate", + name, + ) + } + if role, exists := forbidden[keyHash]; exists { + return nil, fmt.Errorf( + "FROST %s authority [%s] aliases the %s role", + name, + authority.AuthorityID, + role, + ) + } + seenHashes[keyHash] = true + } + return seenHashes, nil +} + +func validFrostPreSignLiftAuthorityID(value string) bool { + if value == "" || len(value) > 64 { + return false + } + for index := range value { + character := value[index] + if !((character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + (index > 0 && (character == '-' || character == '_'))) { + return false + } + } + return true +} + func newFrostPreSignEthereumAdapter( ctx context.Context, tc *TbtcChain, manifest *frostPreSignActivationManifest, + reader tbtc.FrostPreSignEthereumEvidenceVerifier, + enableRelay bool, ) (*frostPreSignEthereumAdapter, error) { - if tc == nil || tc.baseChain == nil || tc.client == nil || manifest == nil { + if tc == nil || tc.baseChain == nil || tc.client == nil || + manifest == nil || reader == nil { return nil, fmt.Errorf("FROST Ethereum adapter dependencies are nil") } if tc.frostWalletRegistry == nil || tc.frostSortitionPool == nil { @@ -1094,31 +1622,43 @@ func newFrostPreSignEthereumAdapter( common.Address(profile.FrostRegistry) != tc.frostWalletRegistryAddr { return nil, fmt.Errorf("activation manifest differs from configured Bridge/FROST registry") } + actualChainID, err := reader.ChainID(ctx) + if err != nil || actualChainID == nil || + actualChainID.Cmp(tc.chainID) != 0 { + return nil, fmt.Errorf( + "activation manifest chain ID differs from Ethereum evidence reader: [%w]", + err, + ) + } expectedGenesisHash, err := frostPreSignParseBytes32( manifest.Ethereum.GenesisBlockHash, ) if err != nil { return nil, err } - genesisHeader, err := tc.client.HeaderByNumber(ctx, big.NewInt(0)) + genesisHeader, err := reader.HeaderByNumber(ctx, big.NewInt(0)) if err != nil || genesisHeader == nil || genesisHeader.Number == nil || genesisHeader.Number.Sign() != 0 || genesisHeader.Hash() != common.Hash(expectedGenesisHash) { return nil, fmt.Errorf("activation manifest genesis block differs from connected Ethereum chain: [%w]", err) } - finality, err := frostPreSignCurrentFinality(ctx, tc.client) + finality, err := frostPreSignCurrentFinality(ctx, reader) if err != nil { return nil, err } adapter := &frostPreSignEthereumAdapter{ chain: tc, + reader: reader, + fromAddress: tc.key.Address, profile: profile, manifest: *manifest, deployments: deployments, - bridge: bind.NewBoundContract( + } + if enableRelay { + adapter.bridge = bind.NewBoundContract( common.Address(profile.BridgeAddress), frostPreSignBridgeABI, tc.client, tc.client, tc.client, - ), + ) } if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { return nil, fmt.Errorf("FROST activation manifest verification failed: [%w]", err) @@ -1740,7 +2280,9 @@ func frostPreSignCurrentFinality( if err != nil { return nil, fmt.Errorf("cannot obtain finalized Ethereum header: [%w]", err) } - if header == nil || header.Number == nil || header.Number.Sign() <= 0 { + if header == nil || header.Number == nil || + !header.Number.IsUint64() || header.Number.Sign() <= 0 || + header.Hash() == (common.Hash{}) { return nil, fmt.Errorf("finalized Ethereum header is invalid") } return &tbtc.FrostPreSignFinality{ @@ -2005,7 +2547,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if finality == nil || finality.BlockNumber == 0 || finality.BlockHash == [32]byte{} { return fmt.Errorf("Ethereum finality checkpoint is invalid") } - before, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + before, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { return err } @@ -2032,7 +2574,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( exactHeader.Hash() != common.Hash(finality.BlockHash) { return fmt.Errorf("exact finalized Ethereum header mismatch") } - header, err := adapter.chain.client.HeaderByNumber( + header, err := adapter.reader.HeaderByNumber( ctx, new(big.Int).SetUint64(finality.BlockNumber), ) @@ -2042,7 +2584,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if header == nil || [32]byte(header.Hash()) != finality.BlockHash { return fmt.Errorf("finalized Ethereum block hash mismatch") } - after, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + after, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { return err } @@ -2052,7 +2594,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( (before.BlockNumber == after.BlockNumber && before.BlockHash != after.BlockHash) { return fmt.Errorf("Ethereum finalized head changed inconsistently while verifying checkpoint") } - headerAfter, err := adapter.chain.client.HeaderByNumber( + headerAfter, err := adapter.reader.HeaderByNumber( ctx, new(big.Int).SetUint64(finality.BlockNumber), ) @@ -2100,7 +2642,7 @@ func (adapter *frostPreSignEthereumAdapter) callAtHash( output, err := exactReader.CallContractAtHash( ctx, geth.CallMsg{ - From: adapter.chain.key.Address, + From: adapter.fromAddress, To: &address, Data: callData, }, @@ -2165,22 +2707,67 @@ func (tc *TbtcChain) PrepareFrostPreSignAuthorization( transaction *tbtc.FrostPreSignTransaction, walletOperators []chain.Address, ) (*tbtc.FrostPreSignAuthorizationProposal, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + finality, err := frostPreSignMatchingCurrentFinality( + ctx, + adapter, + verifier, + ) + if err != nil { + return nil, err + } + primaryProposal, err := adapter.prepareAtFinality( + ctx, + transaction, + walletOperators, + finality, + ) + if err != nil { + return nil, err + } + verifiedProposal, err := verifier.prepareAtFinality( + ctx, + transaction, + walletOperators, + finality, + ) if err != nil { + return nil, fmt.Errorf( + "independent FROST authorization preparation failed: [%w]", + err, + ) + } + if err := frostPreSignRequireMatchingEvidence( + "authorization preparation", + primaryProposal, + verifiedProposal, + ); err != nil { return nil, err } - return adapter.prepare(ctx, transaction, walletOperators) + return primaryProposal, nil } func (tc *TbtcChain) VerifyFrostPreSignActivationPoint( ctx context.Context, finality tbtc.FrostPreSignFinality, ) error { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() if err != nil { return err } - return adapter.verifyDeploymentAt(ctx, &finality) + if err := adapter.verifyDeploymentAt(ctx, &finality); err != nil { + return err + } + if err := verifier.verifyDeploymentAt(ctx, &finality); err != nil { + return fmt.Errorf( + "independent FROST activation-point verification failed: [%w]", + err, + ) + } + return nil } func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( @@ -2221,11 +2808,71 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + endpointIdentitySetHash, err := frostPreSignEndpointIdentitySetHash( + adapter.manifest, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } quarantine := frost.QuarantineJournal quarantineJournalProtocolID, err := parse(quarantine.ProtocolID) if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + quarantineLiftProtocolID, err := parse(quarantine.LiftProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + quarantineTombstoneProtocolID, err := parse(quarantine.TombstoneProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + liftAuthorities := make( + []tbtc.FrostRetainedGroupAuthority, + len(quarantine.LiftAuthorities), + ) + for index, authority := range quarantine.LiftAuthorities { + publicKeySPKIHash, err := parse(authority.PublicKeySPKIHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + liftAuthorities[index] = tbtc.FrostRetainedGroupAuthority{ + AuthorityID: authority.AuthorityID, + PublicKeySPKIHash: publicKeySPKIHash, + } + } + checkpointAuthorities := make( + []tbtc.FrostRetainedGroupAuthority, + len(quarantine.CheckpointAuthorities), + ) + for index, authority := range quarantine.CheckpointAuthorities { + publicKeySPKIHash, err := parse(authority.PublicKeySPKIHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + checkpointAuthorities[index] = tbtc.FrostRetainedGroupAuthority{ + AuthorityID: authority.AuthorityID, + PublicKeySPKIHash: publicKeySPKIHash, + } + } + checkpointPredecessorHash, err := parse( + quarantine.CheckpointPredecessorHash, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + verifierOperatorFingerprint, err := parse( + adapter.manifest.Ethereum.VerifierOperatorFingerprint, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + handshakeOperatorFingerprint, err := parse( + adapter.manifest.FrostSigner.HandshakeOperatorFingerprint, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } journal := frost.CanonicalJournal storeFingerprint, err := parse(journal.StoreFingerprint) if err != nil { @@ -2247,6 +2894,12 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + sourceIdentity, err := frostPreSignRetainedSourceIdentity( + journal.SourceIdentity, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } quarantineStoreFingerprint, err := parse(quarantine.StoreFingerprint) if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err @@ -2267,11 +2920,15 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( } 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, @@ -2298,18 +2955,163 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( SourceTrustDomainID: journal.SourceTrustDomainID, SourceEndpointFingerprint: sourceEndpointFingerprint, SourceOperatorFingerprint: sourceOperatorFingerprint, + SourceIdentity: sourceIdentity, MinimumGeneration: journal.MinimumGeneration, }, QuarantineJournal: tbtc.FrostRetainedGroupQuarantineJournalManifest{ - ProtocolID: quarantineJournalProtocolID, - StoreID: quarantine.StoreID, - StoreFingerprint: quarantineStoreFingerprint, - ClusterFingerprint: quarantineClusterFingerprint, - MinimumGeneration: quarantine.MinimumGeneration, + ProtocolID: quarantineJournalProtocolID, + LiftProtocolID: quarantineLiftProtocolID, + TombstoneProtocolID: quarantineTombstoneProtocolID, + CheckpointAuthorityThreshold: quarantine.CheckpointAuthorityThreshold, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: quarantine.CheckpointMinimumSequence, + CheckpointPredecessorHash: checkpointPredecessorHash, + LiftAuthorityThreshold: quarantine.LiftAuthorityThreshold, + LiftAuthorities: liftAuthorities, + StoreID: quarantine.StoreID, + StoreFingerprint: quarantineStoreFingerprint, + ClusterFingerprint: quarantineClusterFingerprint, + MinimumGeneration: quarantine.MinimumGeneration, }, }, nil } +func frostPreSignEndpointIdentitySetHash( + manifest frostPreSignActivationManifest, +) ([32]byte, error) { + ethereum := manifest.Ethereum + frost := manifest.FrostSigner + type identity struct { + role string + trustDomainID string + endpointFingerprint string + tlsLeafSPKIHash string + operatorFingerprint string + backendFingerprint string + attestationKeyHash string + historySignerKeyHash string + storeID string + storeFingerprint string + clusterFingerprint string + } + identities := []identity{ + { + role: "ethereum-source", + trustDomainID: ethereum.SourceTrustDomainID, + endpointFingerprint: ethereum.SourceEndpointFingerprint, + operatorFingerprint: ethereum.SourceOperatorFingerprint, + storeID: ethereum.SourceHistoryStoreID, + storeFingerprint: ethereum.SourceHistoryStoreFingerprint, + }, + { + role: "ethereum-verifier", + trustDomainID: ethereum.VerifierTrustDomainID, + endpointFingerprint: ethereum.VerifierEndpointFingerprint, + operatorFingerprint: ethereum.VerifierOperatorFingerprint, + storeID: ethereum.VerifierHistoryStoreID, + storeFingerprint: ethereum.VerifierHistoryStoreFingerprint, + }, + { + role: "retained-group-source", + trustDomainID: frost.CanonicalJournal.SourceTrustDomainID, + endpointFingerprint: frost.CanonicalJournal.SourceEndpointFingerprint, + operatorFingerprint: frost.CanonicalJournal.SourceOperatorFingerprint, + historySignerKeyHash: frost.CanonicalJournal.SourceIdentity.HistorySignerKeyHash, + storeID: frost.CanonicalJournal.StoreID, + storeFingerprint: frost.CanonicalJournal.StoreFingerprint, + clusterFingerprint: frost.CanonicalJournal.ClusterFingerprint, + }, + { + role: "retained-history-export", + trustDomainID: frost.CanonicalJournal.SourceIdentity.Export.TrustDomainID, + endpointFingerprint: frost.CanonicalJournal.SourceIdentity.Export.EndpointFingerprint, + tlsLeafSPKIHash: frost.CanonicalJournal.SourceIdentity.Export.TLSLeafSPKIHash, + operatorFingerprint: frost.CanonicalJournal.SourceIdentity.Export.OperatorFingerprint, + backendFingerprint: frost.CanonicalJournal.SourceIdentity.Export.BackendServiceFingerprint, + attestationKeyHash: frost.CanonicalJournal.SourceIdentity.Export.AttestationKeyHash, + }, + { + role: "retained-history-verifier", + trustDomainID: frost.CanonicalJournal.SourceIdentity.Verifier.TrustDomainID, + endpointFingerprint: frost.CanonicalJournal.SourceIdentity.Verifier.EndpointFingerprint, + tlsLeafSPKIHash: frost.CanonicalJournal.SourceIdentity.Verifier.TLSLeafSPKIHash, + operatorFingerprint: frost.CanonicalJournal.SourceIdentity.Verifier.OperatorFingerprint, + backendFingerprint: frost.CanonicalJournal.SourceIdentity.Verifier.BackendServiceFingerprint, + attestationKeyHash: frost.CanonicalJournal.SourceIdentity.Verifier.AttestationKeyHash, + }, + { + role: "runtime-handshake", + trustDomainID: frost.TrustDomainID, + endpointFingerprint: frost.HandshakeEndpointFingerprint, + operatorFingerprint: frost.HandshakeOperatorFingerprint, + }, + } + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-endpoint-identity-set-v3\x00")) + for _, entry := range identities { + endpointFingerprint, err := frostPreSignParseBytes32( + entry.endpointFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + operatorFingerprint, err := frostPreSignParseBytes32( + entry.operatorFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + frostPreSignWriteHashString(hasher, entry.role) + frostPreSignWriteHashString(hasher, entry.trustDomainID) + hasher.Write(endpointFingerprint[:]) + hasher.Write(operatorFingerprint[:]) + for _, value := range []string{ + entry.tlsLeafSPKIHash, + entry.backendFingerprint, + entry.attestationKeyHash, + entry.historySignerKeyHash, + } { + if value == "" { + hasher.Write(make([]byte, 32)) + continue + } + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "invalid endpoint identity role hash", + ) + } + hasher.Write(parsed[:]) + } + frostPreSignWriteHashString(hasher, entry.storeID) + if entry.storeFingerprint == "" { + hasher.Write(make([]byte, 32)) + } else { + storeFingerprint, err := frostPreSignParseBytes32( + entry.storeFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + hasher.Write(storeFingerprint[:]) + } + if entry.clusterFingerprint == "" { + hasher.Write(make([]byte, 32)) + } else { + clusterFingerprint, err := frostPreSignParseBytes32( + entry.clusterFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + hasher.Write(clusterFingerprint[:]) + } + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + func frostPreSignRuntimeDeploymentEvidence( deployments []frostPreSignDeploymentPin, ) []tbtc.FrostPreSignDeploymentEvidence { @@ -2399,10 +3201,27 @@ func (adapter *frostPreSignEthereumAdapter) prepare( if ctx == nil || transaction == nil { return nil, fmt.Errorf("FROST authorization preparation input is nil") } - finality, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + finality, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { return nil, err } + return adapter.prepareAtFinality( + ctx, + transaction, + walletOperators, + finality, + ) +} + +func (adapter *frostPreSignEthereumAdapter) prepareAtFinality( + ctx context.Context, + transaction *tbtc.FrostPreSignTransaction, + walletOperators []chain.Address, + finality *tbtc.FrostPreSignFinality, +) (*tbtc.FrostPreSignAuthorizationProposal, error) { + if ctx == nil || transaction == nil || finality == nil { + return nil, fmt.Errorf("FROST authorization preparation input is nil") + } if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { return nil, err } @@ -2529,6 +3348,119 @@ func (tc *TbtcChain) frostPreSignAdapter() (*frostPreSignEthereumAdapter, error) return tc.frostPreSignAuthorizationAdapter, nil } +func (tc *TbtcChain) frostPreSignAdapterPair() ( + *frostPreSignEthereumAdapter, + *frostPreSignEthereumAdapter, + error, +) { + if tc == nil || tc.frostPreSignAuthorizationAdapter == nil || + tc.frostPreSignAuthorizationVerifier == nil { + return nil, nil, fmt.Errorf( + "production FROST authorization verifier pair is not configured", + ) + } + return tc.frostPreSignAuthorizationAdapter, + tc.frostPreSignAuthorizationVerifier, + nil +} + +func frostPreSignMatchingCurrentFinality( + ctx context.Context, + primary *frostPreSignEthereumAdapter, + verifier *frostPreSignEthereumAdapter, +) (*tbtc.FrostPreSignFinality, error) { + return frostPreSignMatchingCurrentFinalityWithRetry( + ctx, + primary, + verifier, + frostPreSignFinalityAgreementAttempts, + frostPreSignFinalityAgreementRetryDelay, + ) +} + +func frostPreSignMatchingCurrentFinalityWithRetry( + ctx context.Context, + primary *frostPreSignEthereumAdapter, + verifier *frostPreSignEthereumAdapter, + attempts int, + retryDelay time.Duration, +) (*tbtc.FrostPreSignFinality, error) { + if ctx == nil || primary == nil || verifier == nil || + primary.reader == nil || verifier.reader == nil { + return nil, fmt.Errorf("FROST Ethereum verifier pair is incomplete") + } + if attempts <= 0 || retryDelay < 0 { + return nil, fmt.Errorf("FROST Ethereum finality retry policy is invalid") + } + + var primaryFinality *tbtc.FrostPreSignFinality + var verifiedFinality *tbtc.FrostPreSignFinality + for attempt := 0; attempt < attempts; attempt++ { + var err error + primaryFinality, err = frostPreSignCurrentFinality( + ctx, + primary.reader, + ) + if err != nil { + return nil, err + } + verifiedFinality, err = frostPreSignCurrentFinality( + ctx, + verifier.reader, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot obtain independent finalized Ethereum header: [%w]", + err, + ) + } + if primaryFinality.BlockNumber == verifiedFinality.BlockNumber && + primaryFinality.BlockHash == verifiedFinality.BlockHash { + break + } + if attempt == attempts-1 { + return nil, fmt.Errorf( + "FROST Ethereum endpoints disagree on the current finalized block", + ) + } + if retryDelay == 0 { + continue + } + timer := time.NewTimer(retryDelay) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return nil, fmt.Errorf( + "cannot retry independent finalized Ethereum headers: [%w]", + ctx.Err(), + ) + case <-timer.C: + } + } + + if err := primary.requireCanonicalFinality( + ctx, + primaryFinality, + ); err != nil { + return nil, err + } + if err := verifier.requireCanonicalFinality( + ctx, + verifiedFinality, + ); err != nil { + return nil, fmt.Errorf( + "independent finalized Ethereum checkpoint verification failed: [%w]", + err, + ) + } + return primaryFinality, nil +} + func (adapter *frostPreSignEthereumAdapter) resolveWalletMembersAt( ctx context.Context, operators []chain.Address, @@ -2873,6 +3805,11 @@ func (adapter *frostPreSignEthereumAdapter) relay( if ctx == nil || proposal == nil || proposal.Transaction == nil || attestation == nil { return [32]byte{}, fmt.Errorf("FROST authorization relay input is nil") } + if adapter == nil || adapter.chain == nil || adapter.bridge == nil { + return [32]byte{}, fmt.Errorf( + "FROST authorization relay adapter is unavailable", + ) + } if !bytes.Equal(frostPreSignUint32SliceBytes(proposal.WalletMembersIDs), frostPreSignUint32SliceBytes(attestation.WalletMembersIDs)) { return [32]byte{}, fmt.Errorf("FROST relay attestation wallet members differ from preview") } @@ -2952,11 +3889,37 @@ func (tc *TbtcChain) WaitForFrostPreSignAuthorizationFinality( relayTransactionHash [32]byte, proposal *tbtc.FrostPreSignAuthorizationProposal, ) (*tbtc.FrostPreSignFinality, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + verifiedFinality, err := verifier.waitForFinality( + ctx, + relayTransactionHash, + proposal, + ) if err != nil { + return nil, fmt.Errorf( + "independent COMPLETE relay finality verification failed: [%w]", + err, + ) + } + primaryFinality, err := adapter.waitForFinality( + ctx, + relayTransactionHash, + proposal, + ) + if err != nil { + return nil, err + } + if err := frostPreSignRequireMatchingEvidence( + "COMPLETE relay finality", + primaryFinality, + verifiedFinality, + ); err != nil { return nil, err } - return adapter.waitForFinality(ctx, relayTransactionHash, proposal) + return primaryFinality, nil } func (adapter *frostPreSignEthereumAdapter) waitForFinality( @@ -2970,54 +3933,62 @@ func (adapter *frostPreSignEthereumAdapter) waitForFinality( } ticker := time.NewTicker(time.Second) defer ticker.Stop() - var receipt *types.Receipt - for receipt == nil { - var err error - receipt, err = adapter.chain.client.TransactionReceipt( + for { + // A pre-finality receipt is provisional. Re-read it on every poll so a + // transaction re-included at a different canonical block after a reorg + // can still reach finality. + receipt, err := adapter.reader.TransactionReceipt( ctx, common.Hash(relayTransactionHash), ) if err != nil && err != geth.NotFound { return nil, fmt.Errorf("cannot obtain COMPLETE relay receipt: [%w]", err) } - if receipt != nil { - break - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-ticker.C: - } - } - if receipt.Status != types.ReceiptStatusSuccessful || receipt.BlockNumber == nil { - return nil, fmt.Errorf("COMPLETE relay transaction reverted or has no inclusion block") - } - sequence, logIndex, err := adapter.validateAuthorizationReceipt(receipt, proposal) - if err != nil { - return nil, err - } - - for { - finalized, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) - if err != nil { - return nil, err - } - if finalized.BlockNumber >= receipt.BlockNumber.Uint64() { - header, err := adapter.chain.client.HeaderByNumber(ctx, receipt.BlockNumber) + if err != geth.NotFound && receipt != nil { + if receipt.BlockNumber == nil || !receipt.BlockNumber.IsUint64() || + receipt.BlockNumber.Sign() <= 0 { + return nil, fmt.Errorf( + "COMPLETE relay transaction has no valid inclusion block", + ) + } + finalized, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { - return nil, fmt.Errorf("cannot verify COMPLETE receipt block: [%w]", err) + return nil, err } - if header == nil || header.Hash() != receipt.BlockHash { - return nil, fmt.Errorf("COMPLETE relay receipt is not canonical") + if finalized.BlockNumber >= receipt.BlockNumber.Uint64() { + header, err := adapter.reader.HeaderByNumber(ctx, receipt.BlockNumber) + if err != nil { + return nil, fmt.Errorf( + "cannot verify COMPLETE receipt block: [%w]", + err, + ) + } + if header != nil && header.Hash() == receipt.BlockHash { + if receipt.Status != types.ReceiptStatusSuccessful { + return nil, fmt.Errorf( + "COMPLETE relay transaction reverted", + ) + } + sequence, logIndex, err := adapter.validateAuthorizationReceipt( + receipt, + relayTransactionHash, + proposal, + ) + if err != nil { + return nil, err + } + return &tbtc.FrostPreSignFinality{ + RelayTransactionHash: relayTransactionHash, + BlockNumber: receipt.BlockNumber.Uint64(), + BlockHash: [32]byte(receipt.BlockHash), + TransactionIndex: uint32(receipt.TransactionIndex), + LogIndex: logIndex, + AuthorizationSequence: sequence, + }, nil + } + // The observed receipt was orphaned. Continue polling for the + // same transaction's canonical re-inclusion. } - return &tbtc.FrostPreSignFinality{ - RelayTransactionHash: relayTransactionHash, - BlockNumber: receipt.BlockNumber.Uint64(), - BlockHash: [32]byte(receipt.BlockHash), - TransactionIndex: uint32(receipt.TransactionIndex), - LogIndex: logIndex, - AuthorizationSequence: sequence, - }, nil } select { case <-ctx.Done(): @@ -3029,8 +4000,20 @@ func (adapter *frostPreSignEthereumAdapter) waitForFinality( func (adapter *frostPreSignEthereumAdapter) validateAuthorizationReceipt( receipt *types.Receipt, + relayTransactionHash [32]byte, proposal *tbtc.FrostPreSignAuthorizationProposal, ) ([32]byte, uint32, error) { + if receipt == nil || proposal == nil || proposal.Transaction == nil || + receipt.TxHash != common.Hash(relayTransactionHash) || + receipt.BlockHash == (common.Hash{}) || + receipt.BlockNumber == nil || + !receipt.BlockNumber.IsUint64() || + receipt.BlockNumber.Sign() <= 0 || + uint64(receipt.TransactionIndex) > uint64(math.MaxUint32) { + return [32]byte{}, 0, fmt.Errorf( + "COMPLETE receipt transaction identity mismatch", + ) + } authorizedEvent := frostPreSignRegistryABI.Events["P2TRPreSigningReservationAuthorized"] advancedEvent := frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] registryAddress := common.Address(adapter.profile.RegistryAddress) @@ -3039,6 +4022,15 @@ func (adapter *frostPreSignEthereumAdapter) validateAuthorizationReceipt( if logEntry == nil || logEntry.Address != registryAddress || len(logEntry.Topics) == 0 { continue } + if logEntry.Removed || + logEntry.TxHash != receipt.TxHash || + logEntry.BlockHash != receipt.BlockHash || + logEntry.BlockNumber != receipt.BlockNumber.Uint64() || + logEntry.TxIndex != receipt.TransactionIndex { + return [32]byte{}, 0, fmt.Errorf( + "COMPLETE receipt log transaction identity mismatch", + ) + } switch logEntry.Topics[0] { case authorizedEvent.ID: if authorizedLog != nil { @@ -3079,17 +4071,22 @@ func (adapter *frostPreSignEthereumAdapter) validateAuthorizationReceipt( if sequence == [32]byte{} { return [32]byte{}, 0, fmt.Errorf("COMPLETE authorization sequence is zero") } + if uint64(advancedLog.Index) > uint64(math.MaxUint32) { + return [32]byte{}, 0, fmt.Errorf( + "COMPLETE authorization log index overflows", + ) + } return sequence, uint32(advancedLog.Index), nil } func (tc *TbtcChain) CurrentFrostPreSignFinality( ctx context.Context, ) (*tbtc.FrostPreSignFinality, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() if err != nil { return nil, err } - return frostPreSignCurrentFinality(ctx, adapter.chain.client) + return frostPreSignMatchingCurrentFinality(ctx, adapter, verifier) } func (tc *TbtcChain) ReadFrostPreSignAuthorizationState( @@ -3097,11 +4094,37 @@ func (tc *TbtcChain) ReadFrostPreSignAuthorizationState( proposal *tbtc.FrostPreSignAuthorizationProposal, finality tbtc.FrostPreSignFinality, ) (*tbtc.FrostPreSignAuthorizationState, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + primaryState, err := adapter.readAuthorizationState( + ctx, + proposal, + finality, + ) if err != nil { return nil, err } - return adapter.readAuthorizationState(ctx, proposal, finality) + verifiedState, err := verifier.readAuthorizationState( + ctx, + proposal, + finality, + ) + if err != nil { + return nil, fmt.Errorf( + "independent FROST authorization state verification failed: [%w]", + err, + ) + } + if err := frostPreSignRequireMatchingEvidence( + "authorization state", + primaryState, + verifiedState, + ); err != nil { + return nil, err + } + return primaryState, nil } func (adapter *frostPreSignEthereumAdapter) readAuthorizationState( @@ -3270,20 +4293,70 @@ func (tc *TbtcChain) GetCanonicalFrostBitcoinBroadcastAuthorizationStatus( ctx context.Context, request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, ) (*tbtc.FrostBitcoinBroadcastAuthorizationStatus, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + current, err := frostPreSignMatchingCurrentFinality( + ctx, + adapter, + verifier, + ) + if err != nil { + return nil, err + } + primaryStatus, err := adapter.canonicalBroadcastStatus( + ctx, + request, + current, + ) + if err != nil { + return nil, err + } + verifiedStatus, err := verifier.canonicalBroadcastStatus( + ctx, + request, + current, + ) if err != nil { + return nil, fmt.Errorf( + "independent Bitcoin broadcast authorization verification failed: [%w]", + err, + ) + } + if err := frostPreSignRequireMatchingEvidence( + "Bitcoin broadcast authorization", + primaryStatus, + verifiedStatus, + ); err != nil { return nil, err } - return adapter.canonicalBroadcastStatus(ctx, request) + return primaryStatus, nil +} + +func frostPreSignRequireMatchingEvidence( + evidenceName string, + primary interface{}, + verified interface{}, +) error { + if !reflect.DeepEqual(primary, verified) { + return fmt.Errorf( + "FROST Ethereum endpoints disagree on %s", + evidenceName, + ) + } + return nil } func (adapter *frostPreSignEthereumAdapter) canonicalBroadcastStatus( ctx context.Context, request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, + current *tbtc.FrostPreSignFinality, ) (*tbtc.FrostBitcoinBroadcastAuthorizationStatus, error) { if ctx == nil || request == nil || request.FinalizedBlock == 0 || request.FinalizedBlockHash == [32]byte{} || - request.VariantSequence.AuthorizationSequence == [32]byte{} { + request.VariantSequence.AuthorizationSequence == [32]byte{} || + current == nil { return nil, fmt.Errorf("Bitcoin broadcast authorization request is invalid") } requestHash := request.ComputeHash() @@ -3316,10 +4389,6 @@ func (adapter *frostPreSignEthereumAdapter) canonicalBroadcastStatus( }, nil } - current, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) - if err != nil { - return nil, err - } if err := adapter.verifyDeploymentAt(ctx, current); err != nil { return nil, err } @@ -3352,7 +4421,7 @@ func (adapter *frostPreSignEthereumAdapter) validateHistoricalBroadcastEvent( ) error { blockHash := common.Hash(request.FinalizedBlockHash) event := frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] - logs, err := adapter.chain.client.FilterLogs(ctx, geth.FilterQuery{ + logs, err := adapter.reader.FilterLogs(ctx, geth.FilterQuery{ BlockHash: &blockHash, Addresses: []common.Address{common.Address(adapter.profile.RegistryAddress)}, Topics: [][]common.Hash{ @@ -3366,7 +4435,17 @@ func (adapter *frostPreSignEthereumAdapter) validateHistoricalBroadcastEvent( return fmt.Errorf("cannot read historical COMPLETE authorization event: [%w]", err) } if len(logs) != 1 || logs[0].Index != uint(request.FinalizedLogIndex) || - logs[0].TxIndex != uint(request.FinalizedTransactionIndex) || logs[0].Removed { + logs[0].TxIndex != uint(request.FinalizedTransactionIndex) || + logs[0].Removed || + logs[0].Address != common.Address(adapter.profile.RegistryAddress) || + logs[0].BlockHash != blockHash || + logs[0].TxHash == (common.Hash{}) || + len(logs[0].Topics) != 4 || + logs[0].Topics[0] != event.ID || + logs[0].Topics[1] != common.Hash(request.ReservationID) || + logs[0].Topics[2] != common.Hash(request.TransactionHash) || + logs[0].Topics[3] != + common.Hash(request.VariantSequence.AuthorizationSequence) { return fmt.Errorf("historical COMPLETE authorization event identity mismatch") } return nil diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go new file mode 100644 index 0000000000..93a56bcee0 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go @@ -0,0 +1,528 @@ +package ethereum + +import ( + "context" + "fmt" + "math/big" + "testing" + "time" + + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + ethereumConfig "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +type testFrostPreSignEvidenceReader struct { + finalized *types.Header + finalizedSequence []*types.Header + finalizedCall int + headers map[uint64]*types.Header + receipts []*types.Receipt + receiptCall int +} + +func TestNewFrostPreSignPrimaryEthereumReaderAcceptsWrappedClient( + t *testing.T, +) { + server := rpc.NewServer() + rpcClient := rpc.DialInProc(server) + defer rpcClient.Close() + client := ethclient.NewClient(rpcClient) + wrapped := wrapClientAddons(ethereumConfig.Config{}, client) + + reader, err := newFrostPreSignPrimaryEthereumReader( + wrapped, + rpcClient, + big.NewInt(1), + 0, + nil, + ) + if err != nil { + t.Fatal(err) + } + if reader == nil { + t.Fatal("wrapped primary Ethereum reader is nil") + } +} + +func (reader *testFrostPreSignEvidenceReader) ChainID( + context.Context, +) (*big.Int, error) { + return big.NewInt(1), nil +} + +func (reader *testFrostPreSignEvidenceReader) HeaderByNumber( + _ context.Context, + number *big.Int, +) (*types.Header, error) { + if reader.finalized == nil || number == nil { + return nil, fmt.Errorf("header unavailable") + } + if number.Sign() < 0 { + if len(reader.finalizedSequence) == 0 { + return reader.finalized, nil + } + index := reader.finalizedCall + if index >= len(reader.finalizedSequence) { + index = len(reader.finalizedSequence) - 1 + } + reader.finalizedCall++ + return reader.finalizedSequence[index], nil + } + if number.IsUint64() && reader.headers != nil { + if header := reader.headers[number.Uint64()]; header != nil { + return header, nil + } + } + if reader.finalized.Number != nil && + number.Cmp(reader.finalized.Number) == 0 { + return reader.finalized, nil + } + return nil, fmt.Errorf("header unavailable") +} + +func (reader *testFrostPreSignEvidenceReader) HeaderByHash( + _ context.Context, + hash common.Hash, +) (*types.Header, error) { + if reader.finalized != nil && reader.finalized.Hash() == hash { + return reader.finalized, nil + } + return nil, fmt.Errorf("header unavailable") +} + +func (reader *testFrostPreSignEvidenceReader) TransactionReceipt( + context.Context, + common.Hash, +) (*types.Receipt, error) { + if len(reader.receipts) != 0 { + index := reader.receiptCall + if index >= len(reader.receipts) { + index = len(reader.receipts) - 1 + } + reader.receiptCall++ + return reader.receipts[index], nil + } + return nil, fmt.Errorf("receipt unavailable") +} + +func (*testFrostPreSignEvidenceReader) FilterLogs( + context.Context, + geth.FilterQuery, +) ([]types.Log, error) { + return nil, nil +} + +func (*testFrostPreSignEvidenceReader) CodeAtHash( + context.Context, + common.Address, + common.Hash, +) ([]byte, error) { + return nil, fmt.Errorf("code unavailable") +} + +func (*testFrostPreSignEvidenceReader) StorageAtHash( + context.Context, + common.Address, + common.Hash, + common.Hash, +) ([]byte, error) { + return nil, fmt.Errorf("storage unavailable") +} + +func (*testFrostPreSignEvidenceReader) CallContractAtHash( + context.Context, + geth.CallMsg, + common.Hash, +) ([]byte, error) { + return nil, fmt.Errorf("call unavailable") +} + +func TestFrostPreSignCurrentFinalityRequiresEndpointAgreement(t *testing.T) { + primaryHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x01}, + } + verifierHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x02}, + } + chain := &TbtcChain{ + frostPreSignAuthorizationAdapter: &frostPreSignEthereumAdapter{ + reader: &testFrostPreSignEvidenceReader{ + finalized: primaryHeader, + }, + }, + frostPreSignAuthorizationVerifier: &frostPreSignEthereumAdapter{ + reader: &testFrostPreSignEvidenceReader{ + finalized: verifierHeader, + }, + }, + } + + if _, err := frostPreSignMatchingCurrentFinalityWithRetry( + context.Background(), + chain.frostPreSignAuthorizationAdapter, + chain.frostPreSignAuthorizationVerifier, + 1, + 0, + ); err == nil { + t.Fatal("different finalized block hashes were accepted") + } + + chain.frostPreSignAuthorizationVerifier.reader = + &testFrostPreSignEvidenceReader{finalized: primaryHeader} + actual, err := chain.CurrentFrostPreSignFinality(context.Background()) + if err != nil { + t.Fatal(err) + } + if actual.BlockNumber != 10 || + actual.BlockHash != [32]byte(primaryHeader.Hash()) { + t.Fatalf("unexpected common finality [%+v]", actual) + } +} + +func TestFrostPreSignCurrentFinalityRetriesTransientEndpointSkew( + t *testing.T, +) { + olderHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x01}, + } + newerHeader := &types.Header{ + Number: big.NewInt(11), + Time: 11, + Extra: []byte{0x02}, + } + primaryReader := &testFrostPreSignEvidenceReader{ + finalized: newerHeader, + finalizedSequence: []*types.Header{ + olderHeader, + newerHeader, + newerHeader, + newerHeader, + }, + headers: map[uint64]*types.Header{ + 10: olderHeader, + 11: newerHeader, + }, + } + verifierReader := &testFrostPreSignEvidenceReader{ + finalized: newerHeader, + finalizedSequence: []*types.Header{ + newerHeader, + newerHeader, + newerHeader, + newerHeader, + }, + headers: map[uint64]*types.Header{ + 11: newerHeader, + }, + } + + actual, err := frostPreSignMatchingCurrentFinalityWithRetry( + context.Background(), + &frostPreSignEthereumAdapter{reader: primaryReader}, + &frostPreSignEthereumAdapter{reader: verifierReader}, + 2, + 0, + ) + if err != nil { + t.Fatalf("temporary finalized-head skew was not retried: [%v]", err) + } + if actual.BlockNumber != 11 || + actual.BlockHash != [32]byte(newerHeader.Hash()) { + t.Fatalf("unexpected converged finality [%+v]", actual) + } +} + +func TestFrostPreSignAuthorizationStateRequiresVerifierAgreement( + t *testing.T, +) { + canonical := &tbtc.FrostPreSignAuthorizationState{ + ActiveReservationID: [32]byte{0x01}, + } + forged := *canonical + forged.ActiveReservationID = [32]byte{0xa5} + + if err := frostPreSignRequireMatchingEvidence( + "authorization state", + &forged, + canonical, + ); err == nil { + t.Fatal("forged primary reservation agreed with verifier") + } + if err := frostPreSignRequireMatchingEvidence( + "authorization state", + canonical, + canonical, + ); err != nil { + t.Fatalf("matching authorization state rejected: [%v]", err) + } +} + +func TestFrostPreSignAuthorizationReceiptBindsTransactionAndLogs( + t *testing.T, +) { + registry := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + adapter := &frostPreSignEthereumAdapter{ + profile: tbtc.FrostPreSignActivationProfile{ + RegistryAddress: [20]byte(registry), + }, + } + relayHash := [32]byte{0x11} + blockHash := common.HexToHash("0x22") + proposal := &tbtc.FrostPreSignAuthorizationProposal{ + Transaction: &tbtc.FrostPreSignTransaction{ + Action: tbtc.FrostPreSignActionRedemption, + TransactionHash: bitcoin.Hash{0x33}, + }, + ReservationID: [32]byte{0x44}, + WalletID: [32]byte{0x55}, + AuthorizationRoot: [32]byte{0x66}, + SnapshotHash: [32]byte{0x77}, + ResourceHash: [32]byte{0x88}, + } + authorizedEvent := + frostPreSignRegistryABI.Events["P2TRPreSigningReservationAuthorized"] + authorizedData, err := authorizedEvent.Inputs.NonIndexed().Pack( + proposal.AuthorizationRoot, + proposal.SnapshotHash, + proposal.ResourceHash, + uint8(proposal.Transaction.Action), + ) + if err != nil { + t.Fatal(err) + } + advancedEvent := + frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] + transactionHash := common.Hash(proposal.Transaction.TransactionHash) + receipt := &types.Receipt{ + TxHash: common.Hash(relayHash), + BlockHash: blockHash, + BlockNumber: big.NewInt(10), + TransactionIndex: 3, + Logs: []*types.Log{ + { + Address: registry, + Topics: []common.Hash{ + authorizedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.Hash(proposal.WalletID), + }, + Data: authorizedData, + BlockHash: blockHash, + BlockNumber: 10, + TxHash: common.Hash(relayHash), + TxIndex: 3, + Index: 7, + }, + { + Address: registry, + Topics: []common.Hash{ + advancedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.HexToHash("0x99"), + }, + BlockHash: blockHash, + BlockNumber: 10, + TxHash: common.Hash(relayHash), + TxIndex: 3, + Index: 8, + }, + }, + } + + if _, _, err := adapter.validateAuthorizationReceipt( + receipt, + relayHash, + proposal, + ); err != nil { + t.Fatalf("valid receipt rejected: [%v]", err) + } + + wrongRelayHash := relayHash + wrongRelayHash[1] = 0x01 + if _, _, err := adapter.validateAuthorizationReceipt( + receipt, + wrongRelayHash, + proposal, + ); err == nil { + t.Fatal("receipt for a different relay transaction accepted") + } + + receipt.Logs[1].TxHash = common.HexToHash("0xaa") + if _, _, err := adapter.validateAuthorizationReceipt( + receipt, + relayHash, + proposal, + ); err == nil { + t.Fatal("event from a different transaction accepted") + } +} + +func TestFrostPreSignWaitForFinalityRefreshesReincludedReceipt( + t *testing.T, +) { + registry := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + relayHash := [32]byte{0x11} + proposal := &tbtc.FrostPreSignAuthorizationProposal{ + Transaction: &tbtc.FrostPreSignTransaction{ + Action: tbtc.FrostPreSignActionRedemption, + TransactionHash: bitcoin.Hash{0x33}, + }, + ReservationID: [32]byte{0x44}, + WalletID: [32]byte{0x55}, + AuthorizationRoot: [32]byte{0x66}, + SnapshotHash: [32]byte{0x77}, + ResourceHash: [32]byte{0x88}, + } + orphanedHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x01}, + } + canonicalHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x02}, + } + reincludedHeader := &types.Header{ + Number: big.NewInt(11), + Time: 11, + Extra: []byte{0x03}, + } + orphanedReceipt := testFrostPreSignAuthorizationReceipt( + t, + registry, + relayHash, + proposal, + orphanedHeader, + 3, + 7, + 8, + ) + reincludedReceipt := testFrostPreSignAuthorizationReceipt( + t, + registry, + relayHash, + proposal, + reincludedHeader, + 4, + 17, + 18, + ) + reader := &testFrostPreSignEvidenceReader{ + finalized: reincludedHeader, + headers: map[uint64]*types.Header{ + 10: canonicalHeader, + 11: reincludedHeader, + }, + receipts: []*types.Receipt{ + orphanedReceipt, + reincludedReceipt, + }, + } + adapter := &frostPreSignEthereumAdapter{ + reader: reader, + profile: tbtc.FrostPreSignActivationProfile{ + RegistryAddress: [20]byte(registry), + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + finality, err := adapter.waitForFinality(ctx, relayHash, proposal) + if err != nil { + t.Fatalf("canonically re-included relay was rejected: [%v]", err) + } + if reader.receiptCall < 2 { + t.Fatal("relay receipt was not refreshed while waiting for finality") + } + if finality.BlockNumber != 11 || + finality.BlockHash != [32]byte(reincludedHeader.Hash()) || + finality.TransactionIndex != 4 || + finality.LogIndex != 18 { + t.Fatalf("finality retained obsolete inclusion: [%+v]", finality) + } +} + +func testFrostPreSignAuthorizationReceipt( + t *testing.T, + registry common.Address, + relayHash [32]byte, + proposal *tbtc.FrostPreSignAuthorizationProposal, + header *types.Header, + transactionIndex uint, + authorizedLogIndex uint, + advancedLogIndex uint, +) *types.Receipt { + t.Helper() + authorizedEvent := + frostPreSignRegistryABI.Events["P2TRPreSigningReservationAuthorized"] + authorizedData, err := authorizedEvent.Inputs.NonIndexed().Pack( + proposal.AuthorizationRoot, + proposal.SnapshotHash, + proposal.ResourceHash, + uint8(proposal.Transaction.Action), + ) + if err != nil { + t.Fatal(err) + } + advancedEvent := + frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] + transactionHash := common.Hash(proposal.Transaction.TransactionHash) + blockNumber := header.Number.Uint64() + blockHash := header.Hash() + return &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.Hash(relayHash), + BlockHash: blockHash, + BlockNumber: new(big.Int).Set(header.Number), + TransactionIndex: transactionIndex, + Logs: []*types.Log{ + { + Address: registry, + Topics: []common.Hash{ + authorizedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.Hash(proposal.WalletID), + }, + Data: authorizedData, + BlockHash: blockHash, + BlockNumber: blockNumber, + TxHash: common.Hash(relayHash), + TxIndex: transactionIndex, + Index: authorizedLogIndex, + }, + { + Address: registry, + Topics: []common.Hash{ + advancedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.HexToHash("0x99"), + }, + BlockHash: blockHash, + BlockNumber: blockNumber, + TxHash: common.Hash(relayHash), + TxIndex: transactionIndex, + Index: advancedLogIndex, + }, + }, + } +} 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 74f97726b8..2eedb2bc4a 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -12,14 +12,96 @@ func testManifestHex32(value byte) string { return fmt.Sprintf("0x%02x%s", value, strings.Repeat("00", 31)) } +func testFrostRetainedSourceIdentity() ( + tbtc.FrostRetainedGroupHistoryIdentity, + frostPreSignManifestRetainedSourceIdentity, +) { + protocolID := tbtc.FrostRetainedGroupTLSExporterProtocolID() + exportIdentity := tbtc.FrostRetainedGroupEndpointIdentity{ + Schema: "tbtc-frost-retained-group-endpoint-identity/v1", + Role: "retained-history-export", + TrustDomainID: "export.retained.example", + CanonicalEndpoint: "https://export.example:443/history", + CanonicalDNSName: "export.example", + ResolvedDNSName: "export-origin.example", + ResolvedAddressSetHash: [32]byte{0x60}, + TLSLeafSPKIHash: [32]byte{0x61}, + ServiceIdentity: "spiffe://export.retained.example/export", + BackendServiceFingerprint: [32]byte{0x62}, + OperatorFingerprint: [32]byte{0x63}, + AttestationKeyHash: [32]byte{0x64}, + TLSExporterProtocolID: protocolID, + } + exportIdentity.EndpointFingerprint = + tbtc.ComputeFrostRetainedGroupEndpointIdentityFingerprint(exportIdentity) + verifierIdentity := tbtc.FrostRetainedGroupEndpointIdentity{ + Schema: "tbtc-frost-retained-group-endpoint-identity/v1", + Role: "retained-history-verifier", + TrustDomainID: "verifier.retained.example", + CanonicalEndpoint: "https://verifier.example:443/rpc", + CanonicalDNSName: "verifier.example", + ResolvedDNSName: "verifier-origin.example", + ResolvedAddressSetHash: [32]byte{0x65}, + TLSLeafSPKIHash: [32]byte{0x66}, + ServiceIdentity: "spiffe://verifier.retained.example/verifier", + BackendServiceFingerprint: [32]byte{0x67}, + OperatorFingerprint: [32]byte{0x68}, + AttestationKeyHash: [32]byte{0x69}, + TLSExporterProtocolID: protocolID, + } + verifierIdentity.EndpointFingerprint = + tbtc.ComputeFrostRetainedGroupEndpointIdentityFingerprint(verifierIdentity) + identity := tbtc.FrostRetainedGroupHistoryIdentity{ + Schema: "tbtc-frost-retained-group-source-identity/v1", + TrustDomainID: "independent-journal-source", + OperatorFingerprint: exportIdentity.OperatorFingerprint, + HistorySignerKeyHash: [32]byte{0x6a}, + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + tbtc.ComputeFrostRetainedGroupSourceEndpointFingerprint(identity) + toEndpoint := func( + value tbtc.FrostRetainedGroupEndpointIdentity, + ) frostPreSignManifestRetainedEndpointIdentity { + return frostPreSignManifestRetainedEndpointIdentity{ + Schema: value.Schema, + Role: value.Role, + TrustDomainID: value.TrustDomainID, + CanonicalEndpoint: value.CanonicalEndpoint, + CanonicalDNSName: value.CanonicalDNSName, + ResolvedDNSName: value.ResolvedDNSName, + ResolvedAddressSetHash: fmt.Sprintf("0x%x", value.ResolvedAddressSetHash), + TLSLeafSPKIHash: fmt.Sprintf("0x%x", value.TLSLeafSPKIHash), + ServiceIdentity: value.ServiceIdentity, + BackendServiceFingerprint: fmt.Sprintf("0x%x", value.BackendServiceFingerprint), + OperatorFingerprint: fmt.Sprintf("0x%x", value.OperatorFingerprint), + AttestationKeyHash: fmt.Sprintf("0x%x", value.AttestationKeyHash), + TLSExporterProtocolID: fmt.Sprintf("0x%x", value.TLSExporterProtocolID), + EndpointFingerprint: fmt.Sprintf("0x%x", value.EndpointFingerprint), + } + } + return identity, frostPreSignManifestRetainedSourceIdentity{ + Schema: identity.Schema, + TrustDomainID: identity.TrustDomainID, + EndpointFingerprint: fmt.Sprintf("0x%x", identity.EndpointFingerprint), + OperatorFingerprint: fmt.Sprintf("0x%x", identity.OperatorFingerprint), + HistorySignerKeyHash: fmt.Sprintf("0x%x", identity.HistorySignerKeyHash), + Export: toEndpoint(identity.Export), + Verifier: toEndpoint(identity.Verifier), + } +} + func testFrostJournalActivationManifest() *frostPreSignActivationManifest { checkpointHash := testManifestHex32(0x02) + sourceIdentity, wireSourceIdentity := testFrostRetainedSourceIdentity() manifest := &frostPreSignActivationManifest{ - Schema: frostPreSignManifestVersion, - ActivationSequence: 1, - ActivationID: testManifestHex32(0x01), - Environment: "test", - manifestHash: [32]byte{0x99}, + Schema: frostPreSignManifestVersion, + ActivationSequence: 1, + ActivationID: testManifestHex32(0x01), + Environment: "test", + manifestHash: [32]byte{0x99}, + activationAuthorityKeyHash: [32]byte{0x40}, Ethereum: frostPreSignManifestEthereum{ ChainID: 1, GenesisBlockHash: testManifestHex32(0x30), @@ -63,12 +145,29 @@ func testFrostJournalActivationManifest() *frostPreSignActivationManifest { Checkpoint: frostPreSignManifestPoint{BlockNumber: 1, BlockHash: testManifestHex32(0x28)}, DescriptorSetHash: testManifestHex32(0x22), SourceTrustDomainID: "independent-journal-source", - SourceEndpointFingerprint: testManifestHex32(0x23), - SourceOperatorFingerprint: testManifestHex32(0x24), + SourceEndpointFingerprint: fmt.Sprintf("0x%x", sourceIdentity.EndpointFingerprint), + SourceOperatorFingerprint: fmt.Sprintf("0x%x", sourceIdentity.OperatorFingerprint), + SourceIdentity: wireSourceIdentity, MinimumGeneration: 7, }, QuarantineJournal: frostPreSignManifestQuarantineJournal{ - ProtocolID: testManifestHex32(0x25), + ProtocolID: testManifestHex32(0x25), + LiftProtocolID: testManifestHex32(0x29), + TombstoneProtocolID: testManifestHex32(0x2a), + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: []frostPreSignManifestLiftAuthority{ + {AuthorityID: "checkpoint-1", PublicKeySPKIHash: testManifestHex32(0x2b)}, + {AuthorityID: "checkpoint-2", PublicKeySPKIHash: testManifestHex32(0x2c)}, + {AuthorityID: "checkpoint-3", PublicKeySPKIHash: testManifestHex32(0x2d)}, + }, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: testManifestHex32(0x00), + LiftAuthorityThreshold: 2, + LiftAuthorities: []frostPreSignManifestLiftAuthority{ + {AuthorityID: "authority-1", PublicKeySPKIHash: testManifestHex32(0x36)}, + {AuthorityID: "authority-2", PublicKeySPKIHash: testManifestHex32(0x37)}, + {AuthorityID: "authority-3", PublicKeySPKIHash: testManifestHex32(0x38)}, + }, StoreID: "quarantine-journal-store", StoreFingerprint: testManifestHex32(0x26), ClusterFingerprint: testManifestHex32(0x27), @@ -113,7 +212,7 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { manifest.FrostSigner.CanonicalJournal.SourceEndpointFingerprint = manifest.Ethereum.SourceEndpointFingerprint if err := validateFrostPreSignActivationManifest(manifest); err == nil || - !strings.Contains(err.Error(), "not independent") { + !strings.Contains(err.Error(), "differs from its aggregate") { t.Fatalf("expected independent-source validation failure, got [%v]", err) } }) @@ -190,6 +289,204 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { t.Fatalf("expected native anchor authority failure, got [%v]", err) } }) + t.Run("history signer aliases runtime attestation", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.AttestationSignerKeyHash = + manifest.FrostSigner.CanonicalJournal.SourceIdentity. + HistorySignerKeyHash + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained history signer") { + t.Fatalf("expected history-signer role alias rejection, got [%v]", err) + } + }) + t.Run("retained TLS leaf aliases activation authority", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + leaf, err := frostPreSignParseBytes32( + manifest.FrostSigner.CanonicalJournal.SourceIdentity.Export. + TLSLeafSPKIHash, + ) + if err != nil { + t.Fatal(err) + } + manifest.activationAuthorityKeyHash = leaf + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained export TLS leaf") { + t.Fatalf("expected retained-leaf role alias rejection, got [%v]", err) + } + }) + t.Run("retained backend aliases primary verifier", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.VerifierOperatorFingerprint = + manifest.FrostSigner.CanonicalJournal.SourceIdentity.Export. + BackendServiceFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained export backend") { + t.Fatalf("expected retained-backend role alias rejection, got [%v]", err) + } + }) + t.Run("outer operator roles alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.VerifierOperatorFingerprint = + manifest.Ethereum.SourceOperatorFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "aliases") { + t.Fatalf("expected outer-role alias rejection, got [%v]", err) + } + }) + t.Run("activation authority aliases outer role", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + operator, err := frostPreSignParseBytes32( + manifest.Ethereum.SourceOperatorFingerprint, + ) + if err != nil { + t.Fatal(err) + } + manifest.activationAuthorityKeyHash = operator + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "activation authority") { + t.Fatalf("expected activation/outer alias rejection, got [%v]", err) + } + }) + t.Run("outer trust domains alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.VerifierTrustDomainID = + manifest.Ethereum.SourceTrustDomainID + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "trust domain aliases") { + t.Fatalf("expected outer trust-domain alias rejection, got [%v]", err) + } + }) + t.Run("retained nested trust domain aliases outer role", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.SourceTrustDomainID = + manifest.FrostSigner.CanonicalJournal.SourceIdentity.Export. + TrustDomainID + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "trust domain aliases") { + t.Fatalf("expected nested/outer trust-domain alias rejection, got [%v]", err) + } + }) +} + +func TestValidateFrostPreSignActivationManifest_QuarantineAuthoritySets( + t *testing.T, +) { + t.Run("2-of-3 lift authority set", func(t *testing.T) { + if err := validateFrostPreSignActivationManifest( + testFrostJournalActivationManifest(), + ); err != nil { + t.Fatal(err) + } + }) + t.Run("3-of-4 lift authority set", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorityThreshold = 3 + manifest.FrostSigner.QuarantineJournal.LiftAuthorities = append( + manifest.FrostSigner.QuarantineJournal.LiftAuthorities, + frostPreSignManifestLiftAuthority{ + AuthorityID: "authority-4", + PublicKeySPKIHash: testManifestHex32(0x39), + }, + ) + if err := validateFrostPreSignActivationManifest(manifest); err != nil { + t.Fatal(err) + } + }) + t.Run("2-of-4 is not a strict majority", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities = append( + manifest.FrostSigner.QuarantineJournal.LiftAuthorities, + frostPreSignManifestLiftAuthority{ + AuthorityID: "authority-4", + PublicKeySPKIHash: testManifestHex32(0x39), + }, + ) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "strict majority") { + t.Fatalf("expected 2-of-4 rejection, got [%v]", err) + } + }) + t.Run("unsorted authority IDs", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + authorities := manifest.FrostSigner.QuarantineJournal.LiftAuthorities + authorities[0], authorities[1] = authorities[1], authorities[0] + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "strictly sorted") { + t.Fatalf("expected unsorted authority rejection, got [%v]", err) + } + }) + t.Run("duplicate authority key", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + authorities := manifest.FrostSigner.QuarantineJournal.LiftAuthorities + authorities[1].PublicKeySPKIHash = authorities[0].PublicKeySPKIHash + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate authority-key rejection, got [%v]", err) + } + }) + t.Run("activation role alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash = testManifestHex32(0x40) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "aliases the activation role") { + t.Fatalf("expected activation-role alias rejection, got [%v]", err) + } + }) + t.Run("checkpoint role alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash = manifest.FrostSigner.QuarantineJournal. + CheckpointAuthorities[0].PublicKeySPKIHash + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "checkpoint authority") { + t.Fatalf("expected checkpoint-role alias rejection, got [%v]", err) + } + }) + t.Run("retained backend role alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash = manifest.FrostSigner.CanonicalJournal. + SourceIdentity.Verifier.BackendServiceFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained verifier backend") { + t.Fatalf("expected retained-backend authority alias rejection, got [%v]", err) + } + }) + t.Run("protocol identity alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftProtocolID = + manifest.FrostSigner.QuarantineJournal.ProtocolID + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "not distinct") { + t.Fatalf("expected protocol-identity alias rejection, got [%v]", err) + } + }) + t.Run("zero checkpoint floor", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.CheckpointMinimumSequence = 0 + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "transparency floor") { + t.Fatalf("expected zero checkpoint-floor rejection, got [%v]", err) + } + }) + t.Run("missing non-genesis predecessor", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.CheckpointMinimumSequence = 2 + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "transparency floor") { + t.Fatalf("expected missing predecessor rejection, got [%v]", err) + } + }) + t.Run("nonzero genesis predecessor", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.CheckpointPredecessorHash = + testManifestHex32(0x7f) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "transparency floor") { + t.Fatalf("expected genesis predecessor rejection, got [%v]", err) + } + }) } func TestFrostPreSignDecodeStrictJSON_RejectsUnknownCanonicalJournalField(t *testing.T) { diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index 2bc10ca745..195507bc5b 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -20,6 +20,7 @@ import ( "time" "github.com/keep-network/keep-core/pkg/bitcoin" + "golang.org/x/sync/semaphore" "golang.org/x/sys/unix" ) @@ -235,12 +236,12 @@ type bitcoinBroadcastOutbox struct { deepReconcileCursor int now func() time.Time - replayMutex sync.Mutex - mutex sync.Mutex - records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord - lockFile *os.File - closed bool - recovered bool + replaySemaphore *semaphore.Weighted + mutex sync.Mutex + records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord + lockFile *os.File + closed bool + recovered bool persistFailureHook func(*bitcoinBroadcastOutboxRecord) error } @@ -291,6 +292,7 @@ func newBitcoinBroadcastOutbox( archiveConfirmations: defaultBitcoinBroadcastArchiveConfirmations, deepReconcileBatch: defaultBitcoinBroadcastDeepReconcileBatch, now: time.Now, + replaySemaphore: semaphore.NewWeighted(1), records: make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord), lockFile: lockFile, } @@ -677,6 +679,33 @@ func (bbo *bitcoinBroadcastOutbox) activationSnapshot() ( return bitcoinBroadcastOutboxActivationSnapshot{}, fmt.Errorf("Bitcoin broadcast outbox is closed") } + return bbo.activationSnapshotLocked(), nil +} + +// withUnchangedActivationSnapshot serializes the final activation-state check +// and the operation it authorizes with every outbox mutation. Callers must +// keep operation short and must not call another method that acquires mutex. +func (bbo *bitcoinBroadcastOutbox) withUnchangedActivationSnapshot( + expected bitcoinBroadcastOutboxActivationSnapshot, + operation func() error, +) error { + if operation == nil { + return fmt.Errorf("Bitcoin broadcast outbox activation operation is nil") + } + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return fmt.Errorf("Bitcoin broadcast outbox is closed") + } + if bbo.activationSnapshotLocked() != expected { + return fmt.Errorf( + "Bitcoin broadcast outbox activation state changed before signing", + ) + } + return operation() +} + +func (bbo *bitcoinBroadcastOutbox) activationSnapshotLocked() bitcoinBroadcastOutboxActivationSnapshot { type reservationState struct { pending bool confirmedVariant bitcoin.Hash @@ -716,7 +745,7 @@ func (bbo *bitcoinBroadcastOutbox) activationSnapshot() ( snapshot.AmbiguousReservationCount++ } } - return snapshot, nil + return snapshot } // replayOnce is the test/internal synchronous entry point. @@ -726,7 +755,8 @@ func (bbo *bitcoinBroadcastOutbox) replayOnce() error { type bitcoinBroadcastReplayCandidate struct { reservationID [32]byte - record *bitcoinBroadcastOutboxRecord + primary *bitcoinBroadcastOutboxRecord + alternatives []*bitcoinBroadcastOutboxRecord } type bitcoinBroadcastTransientReplayError struct { @@ -780,20 +810,26 @@ func (errorValue *bitcoinBroadcastReplayErrors) hasFatalFailure() bool { return false } -// replayOnceWithContext checks only the latest/confirmed record of each active -// reservation. Deeply confirmed history is reconciled in a fixed-size rotating -// batch, so canonical RPC work is bounded independently of archived history. +// replayOnceWithContext checks the latest or previously confirmed record first. +// Before rebroadcasting, it also reconciles every superseded signed variant, +// because another wallet operator may have broadcast any one of those +// conflicting variants. Deeply confirmed history is reconciled in a fixed-size +// rotating reservation batch; healthy archived reservations still require only +// their primary confirmation read. func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) error { - bbo.replayMutex.Lock() - defer bbo.replayMutex.Unlock() + if err := bbo.acquireReplaySemaphore(ctx); err != nil { + return err + } + defer bbo.replaySemaphore.Release(1) active, archived, err := bbo.replayCandidates() if err != nil { return err } replayErrors := &bitcoinBroadcastReplayErrors{} +candidateLoop: for _, candidate := range append(active, archived...) { - refreshed, err := bbo.refreshConfirmation(ctx, candidate.record) + refreshed, err := bbo.refreshConfirmation(ctx, candidate.primary) if err != nil { replayErrors.failures = append( replayErrors.failures, @@ -801,7 +837,25 @@ func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) er ) continue } - if refreshed.Confirmation != nil || refreshed.Quarantine != nil { + if refreshed.Confirmation != nil { + continue + } + canonicalVariantFound := false + for _, alternative := range candidate.alternatives { + refreshed, err := bbo.refreshConfirmation(ctx, alternative) + if err != nil { + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + continue candidateLoop + } + if refreshed.Confirmation != nil { + canonicalVariantFound = true + break + } + } + if canonicalVariantFound { continue } latest, err := bbo.latestBroadcastRecord(candidate.reservationID) @@ -852,6 +906,7 @@ func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( type reservationState struct { latest *bitcoinBroadcastOutboxRecord confirmed *bitcoinBroadcastOutboxRecord + records []*bitcoinBroadcastOutboxRecord } states := make(map[[32]byte]*reservationState) for _, record := range bbo.records { @@ -861,6 +916,7 @@ func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( state = &reservationState{} states[reservationID] = state } + state.records = append(state.records, record) if state.latest == nil || laterBitcoinBroadcastVariant(record, state.latest) { state.latest = record } @@ -878,17 +934,38 @@ func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( active := make([]bitcoinBroadcastReplayCandidate, 0, len(states)) archived := make([]bitcoinBroadcastReplayCandidate, 0, len(states)) for reservationID, state := range states { - record := state.latest + primary := state.latest if state.confirmed != nil { - record = state.confirmed + primary = state.confirmed } + alternatives := make( + []*bitcoinBroadcastOutboxRecord, + 0, + len(state.records)-1, + ) + for _, record := range state.records { + if record.TransactionHash == primary.TransactionHash { + continue + } + alternatives = append( + alternatives, + cloneBitcoinBroadcastOutboxRecord(record), + ) + } + sort.Slice(alternatives, func(i, j int) bool { + return laterBitcoinBroadcastVariant( + alternatives[j], + alternatives[i], + ) + }) candidate := bitcoinBroadcastReplayCandidate{ reservationID: reservationID, - record: cloneBitcoinBroadcastOutboxRecord(record), + primary: cloneBitcoinBroadcastOutboxRecord(primary), + alternatives: alternatives, } - if record.Confirmation != nil && - record.Confirmation.Canonical && - record.Confirmation.Confirmations >= bbo.archiveConfirmations { + if primary.Confirmation != nil && + primary.Confirmation.Canonical && + primary.Confirmation.Confirmations >= bbo.archiveConfirmations { archived = append(archived, candidate) } else { active = append(active, candidate) @@ -1094,12 +1171,24 @@ func (bbo *bitcoinBroadcastOutbox) broadcastAuthorizedRecord( } broadcastErr := bbo.btcChain.BroadcastTransaction(tx) now := bbo.now().Unix() + attemptedAt := maxBitcoinBroadcastTimestamp( + next.UpdatedAtUnix, + now, + ) + attemptedAt = maxBitcoinBroadcastTimestamp( + attemptedAt, + next.FirstBroadcastAtUnix, + ) + attemptedAt = maxBitcoinBroadcastTimestamp( + attemptedAt, + next.LastAttemptUnix, + ) if next.FirstBroadcastAtUnix == 0 { - next.FirstBroadcastAtUnix = now + next.FirstBroadcastAtUnix = attemptedAt } next.BroadcastAttempts++ - next.LastAttemptUnix = now - next.UpdatedAtUnix = maxBitcoinBroadcastTimestamp(next.UpdatedAtUnix, now) + next.LastAttemptUnix = attemptedAt + next.UpdatedAtUnix = attemptedAt if err := bbo.persistAndSwapRecord(record, next); err != nil { return broadcastErr, nil, fmt.Errorf("cannot persist Bitcoin broadcast attempt: [%w]", err) } @@ -1110,8 +1199,10 @@ func (bbo *bitcoinBroadcastOutbox) broadcastTransaction( ctx context.Context, transactionHash bitcoin.Hash, ) error { - bbo.replayMutex.Lock() - defer bbo.replayMutex.Unlock() + if err := bbo.acquireReplaySemaphore(ctx); err != nil { + return err + } + defer bbo.replaySemaphore.Release(1) bbo.mutex.Lock() record := bbo.records[transactionHash] @@ -1142,6 +1233,24 @@ func (bbo *bitcoinBroadcastOutbox) broadcastTransaction( return broadcastErr } +func (bbo *bitcoinBroadcastOutbox) acquireReplaySemaphore( + ctx context.Context, +) error { + if ctx == nil { + return fmt.Errorf("Bitcoin broadcast replay context is nil") + } + if bbo == nil || bbo.replaySemaphore == nil { + return fmt.Errorf("Bitcoin broadcast replay semaphore is unavailable") + } + if err := bbo.replaySemaphore.Acquire(ctx, 1); err != nil { + return fmt.Errorf( + "cannot acquire Bitcoin broadcast replay semaphore: [%w]", + err, + ) + } + return nil +} + func (bbo *bitcoinBroadcastOutbox) persistAndSwapRecord( expected *bitcoinBroadcastOutboxRecord, next *bitcoinBroadcastOutboxRecord, diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go index 5fdf2c5f72..e4b0e82887 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox_test.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -699,6 +699,169 @@ func TestBitcoinBroadcastOutbox_StartRetriesTransientCandidateFailure( } } +func TestBitcoinBroadcastOutbox_ReconcilesPreviouslyBroadcastSupersededVariant( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, t.TempDir(), chain) + defer outbox.close() + + oldVariant := testOutboxTransaction(31, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + oldVariant, + testBitcoinBroadcastAuthorization(32, 31, 1), + ) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if chain.broadcastCount(oldVariant.Hash()) != 1 { + t.Fatal("initial variant was not broadcast") + } + + replacement := testOutboxTransaction(31, 6000) + enqueueTestBitcoinTransaction( + t, + outbox, + replacement, + testBitcoinBroadcastAuthorization(33, 31, 2), + ) + chain.setCanonicalStatus( + oldVariant.Hash(), + &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: 2, + BlockHeight: 800031, + BlockHash: bitcoin.Hash{0x31, 0xcc}, + }, + ) + + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[oldVariant.Hash()].Confirmation == nil { + t.Fatal("canonical superseded RBF variant was not persisted") + } + if chain.broadcastCount(replacement.Hash()) != 0 { + t.Fatal("replacement was broadcast after its predecessor confirmed") + } +} + +func TestBitcoinBroadcastOutbox_ReconcilesExternallyBroadcastSupersededVariant( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, t.TempDir(), chain) + defer outbox.close() + + oldVariant := testOutboxTransaction(34, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + oldVariant, + testBitcoinBroadcastAuthorization(35, 34, 1), + ) + + replacement := testOutboxTransaction(34, 6000) + enqueueTestBitcoinTransaction( + t, + outbox, + replacement, + testBitcoinBroadcastAuthorization(36, 34, 2), + ) + chain.setCanonicalStatus( + oldVariant.Hash(), + &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: 2, + BlockHeight: 800034, + BlockHash: bitcoin.Hash{0x34, 0xcc}, + }, + ) + + if outbox.records[oldVariant.Hash()].BroadcastAttempts != 0 { + t.Fatal("old variant unexpectedly has a local broadcast attempt") + } + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[oldVariant.Hash()].Confirmation == nil { + t.Fatal("externally broadcast superseded RBF variant was not persisted") + } + if chain.broadcastCount(replacement.Hash()) != 0 { + t.Fatal("replacement was broadcast after its predecessor confirmed externally") + } +} + +func TestBitcoinBroadcastOutbox_BroadcastLockWaitHonorsContext( + t *testing.T, +) { + statusStarted := make(chan struct{}) + statusRelease := make(chan struct{}) + chain := &blockingOutboxTestBitcoinChain{ + outboxTestBitcoinChain: newOutboxTestBitcoinChain(), + statusStarted: statusStarted, + statusRelease: statusRelease, + } + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + chain, + ) + defer outbox.close() + tx := testOutboxTransaction(0x4a, 9000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(0x4b, 0x4c, 1), + ) + + replayResult := make(chan error, 1) + go func() { + replayResult <- outbox.replayOnceWithContext(context.Background()) + }() + select { + case <-statusStarted: + case <-time.After(time.Second): + close(statusRelease) + t.Fatal("background replay did not enter the blocking Bitcoin read") + } + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + broadcastResult := make(chan error, 1) + go func() { + broadcastResult <- outbox.broadcastTransaction(ctx, tx.Hash()) + }() + + var broadcastErr error + select { + case broadcastErr = <-broadcastResult: + case <-time.After(500 * time.Millisecond): + close(statusRelease) + <-replayResult + broadcastErr = <-broadcastResult + t.Fatalf( + "foreground broadcast ignored its context while waiting for replay; eventual result: [%v]", + broadcastErr, + ) + } + if !errors.Is(broadcastErr, context.DeadlineExceeded) { + close(statusRelease) + <-replayResult + t.Fatalf("unexpected canceled broadcast result: [%v]", broadcastErr) + } + close(statusRelease) + if err := <-replayResult; err != nil { + t.Fatalf("background replay failed after release: [%v]", err) + } +} + func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishConfirmationMutation( t *testing.T, ) { @@ -778,6 +941,53 @@ func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishAttemptCounters( } } +func TestBitcoinBroadcastOutbox_BroadcastAttemptTimestampsRemainMonotonicAcrossClockRollback( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + directory := t.TempDir() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + tx := testOutboxTransaction(34, 7000) + currentTime := time.Unix(2_000, 0) + outbox.now = func() time.Time { + return currentTime + } + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(35, 34, 1), + ) + + currentTime = time.Unix(2_100, 0) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + currentTime = time.Unix(1_900, 0) + if err := outbox.replayOnce(); err != nil { + t.Fatalf("clock rollback broke durable replay: [%v]", err) + } + record := outbox.records[tx.Hash()] + if record.BroadcastAttempts != 2 || + record.FirstBroadcastAtUnix != 2_100 || + record.LastAttemptUnix != 2_100 || + record.UpdatedAtUnix != 2_100 || + chain.broadcastCount(tx.Hash()) != 2 { + t.Fatalf( + "broadcast attempt timestamps regressed after clock rollback: %+v", + record, + ) + } + if err := outbox.close(); err != nil { + t.Fatal(err) + } + restarted := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer restarted.close() + if restarted.records[tx.Hash()].LastAttemptUnix != 2_100 { + t.Fatal("monotonic broadcast attempt timestamp did not survive restart") + } +} + func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishQuarantine( t *testing.T, ) { @@ -1345,6 +1555,23 @@ type outboxTestBitcoinChain struct { statusCalls uint } +type blockingOutboxTestBitcoinChain struct { + *outboxTestBitcoinChain + statusStarted chan struct{} + statusRelease <-chan struct{} + statusOnce sync.Once +} + +func (botbc *blockingOutboxTestBitcoinChain) GetCanonicalTransactionStatus( + hash bitcoin.Hash, +) (*bitcoin.CanonicalTransactionStatus, error) { + botbc.statusOnce.Do(func() { + close(botbc.statusStarted) + }) + <-botbc.statusRelease + return botbc.outboxTestBitcoinChain.GetCanonicalTransactionStatus(hash) +} + type outboxTestAuthorizationStatusSource struct { mutex sync.Mutex err error diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 5bfcad682c..6f660d4d40 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -10,6 +10,7 @@ import ( "encoding/hex" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "math/big" @@ -18,6 +19,7 @@ import ( "net/http" "net/url" "path" + "reflect" "sort" "strings" "sync" @@ -27,9 +29,21 @@ import ( ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v3" - frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" - frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v4" + frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" + frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" + frostActivationHandshakeReconciliationTimeout = frostRetainedGroupMaximumReconciliationDuration + frostActivationHandshakeRequestTimeout = 5 * time.Second + frostActivationHandshakeQuickCheckTimeout = 2 * time.Second + frostActivationHandshakeRetryAfter = "1" +) + +var errFrostActivationReconciliationPending = errors.New( + "FROST activation reconciliation is pending", +) + +var errFrostActivationJournalBusy = errors.New( + "FROST activation journal live-state check is busy", ) type frostActivationEthereumPoint struct { @@ -38,9 +52,11 @@ type frostActivationEthereumPoint struct { } type frostActivationChallenge struct { - Nonce string `json:"nonce"` - ManifestHash string `json:"manifestHash"` - EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` + Nonce string `json:"nonce"` + ManifestHash string `json:"manifestHash"` + BindingHash string `json:"bindingHash"` + EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` + CheckpointFloor frostRetainedGroupWireCheckpointCursor `json:"checkpointFloor"` } type frostActivationHandshakeRequest struct { @@ -49,17 +65,19 @@ type frostActivationHandshakeRequest struct { } type frostActivationCanonicalJournalState struct { - StoreID string `json:"storeID"` - StoreFingerprint string `json:"storeFingerprint"` - ClusterFingerprint string `json:"clusterFingerprint"` - Checkpoint frostActivationEthereumPoint `json:"checkpoint"` - Current frostActivationEthereumPoint `json:"current"` - DescriptorSetHash string `json:"descriptorSetHash"` - SourceTrustDomainID string `json:"sourceTrustDomainID"` - SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` - SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` - Generation uint64 `json:"generation"` - Complete bool `json:"complete"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + BindingHash string `json:"bindingHash"` + Checkpoint frostActivationEthereumPoint `json:"checkpoint"` + Current frostActivationEthereumPoint `json:"current"` + DescriptorSetHash string `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` + SourceIdentity frostRetainedGroupWireIdentity `json:"sourceIdentity"` + Generation uint64 `json:"generation"` + Complete bool `json:"complete"` } type frostActivationWalletGroupInventory struct { @@ -81,8 +99,11 @@ type frostActivationQuarantineJournalState struct { StoreFingerprint string `json:"storeFingerprint"` ClusterFingerprint string `json:"clusterFingerprint"` Root string `json:"root"` + ActiveRoot string `json:"activeRoot"` + TombstoneRoot string `json:"tombstoneRoot"` Generation uint64 `json:"generation"` CurrentQuarantineCount uint64 `json:"currentQuarantineCount"` + TombstoneCount uint64 `json:"tombstoneCount"` Complete bool `json:"complete"` } @@ -109,6 +130,23 @@ type frostActivationNativeSignerState struct { Complete bool `json:"complete"` } +type frostActivationCheckpointJournalState struct { + ManifestMinimumSequence uint64 `json:"manifestMinimumSequence"` + ManifestPredecessorHash string `json:"manifestPredecessorHash"` + ChallengeFloor frostRetainedGroupWireCheckpointCursor `json:"challengeFloor"` + DurableHead frostRetainedGroupWireCheckpointCursor `json:"durableHead"` + Point frostActivationEthereumPoint `json:"point"` + HistoryRoot string `json:"historyRoot"` + CanonicalGeneration uint64 `json:"canonicalGeneration"` + CanonicalInventoryRoot string `json:"canonicalInventoryRoot"` + QuarantineGeneration uint64 `json:"quarantineGeneration"` + QuarantineEventRoot string `json:"quarantineEventRoot"` + QuarantineActiveRoot string `json:"quarantineActiveRoot"` + QuarantineTombstoneRoot string `json:"quarantineTombstoneRoot"` + Ancestry []frostRetainedGroupWireCheckpointCertificate `json:"ancestry"` + Complete bool `json:"complete"` +} + type frostActivationHandshakeState struct { ProtocolID string `json:"protocolID"` ReservationProtocolID string `json:"reservationProtocolID"` @@ -123,6 +161,7 @@ type frostActivationHandshakeState struct { FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` CanonicalJournal frostActivationCanonicalJournalState `json:"canonicalJournal"` QuarantineJournal frostActivationQuarantineJournalState `json:"quarantineJournal"` + CheckpointJournal frostActivationCheckpointJournalState `json:"checkpointJournal"` NativeSignerState frostActivationNativeSignerState `json:"nativeSignerState"` InteractiveSigningReady bool `json:"interactiveSigningReady"` FinalizedReservationReadbackEnforced bool `json:"finalizedReservationReadbackEnforced"` @@ -138,6 +177,7 @@ type frostActivationHandshakePayload struct { Kind string `json:"kind"` Nonce string `json:"nonce"` ManifestHash string `json:"manifestHash"` + BindingHash string `json:"bindingHash"` EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` State frostActivationHandshakeState `json:"state"` } @@ -153,15 +193,65 @@ type frostActivationHandshakeExporter struct { privateKey ed25519.PrivateKey publicKeySPKI string manifest FrostPreSignActivationRuntimeManifest + bindingHash [32]byte pointVerifier FrostPreSignActivationPointVerifier storeBinding *frostDurableSessionStoreBinding outbox *bitcoinBroadcastOutbox - readiness frostProductionSignerReadinessVerifier + journal *frostRetainedGroupJournal + readiness frostActivationHandshakeReadinessVerifier + + mutex sync.Mutex + listener net.Listener + server *http.Server + reconciliationCancel context.CancelFunc + closed bool - mutex sync.Mutex - listener net.Listener - server *http.Server - closed bool + reconciliationMutex sync.Mutex + reconciliationWake chan struct{} + reconciliationSequence uint64 + reconciliationDesired *frostActivationReconciliationJob + reconciliationActive *frostActivationReconciliationJob + reconciliationActiveCancel context.CancelFunc + reconciliationCompleted *frostActivationReconciliationCache +} + +type frostActivationReconciliationJob struct { + sequence uint64 + point FrostPreSignFinality +} + +type frostActivationJournalStamp struct { + bindingHash [32]byte + canonicalPoint FrostPreSignFinality + canonicalGeneration uint64 + canonicalBatchRoot [32]byte + canonicalInventory [32]byte + quarantinePoint FrostPreSignFinality + quarantineGeneration uint64 + quarantineBatchRoot [32]byte + quarantineRoot [32]byte + quarantineActiveRoot [32]byte + quarantineTombstoneRoot [32]byte + checkpointSequence uint64 + checkpointHash [32]byte + checkpointHistoryRoot [32]byte +} + +type frostActivationReconciliationCache struct { + point FrostPreSignFinality + journal frostRetainedGroupJournalSnapshot + inventory frostNativeSignerInventorySnapshot + interactiveSigningReady bool + readiness frostProductionSignerReadinessSnapshot + stamp frostActivationJournalStamp +} + +type frostActivationHandshakeReadinessVerifier interface { + frostProductionSignerReadinessVerifier + verifyFrostProductionSignerReadinessUnchanged( + context.Context, + *frostProductionSignerReadinessSnapshot, + ) error } func newFrostActivationHandshakeExporter( @@ -171,7 +261,8 @@ func newFrostActivationHandshakeExporter( pointVerifier FrostPreSignActivationPointVerifier, storeBinding *frostDurableSessionStoreBinding, outbox *bitcoinBroadcastOutbox, - readiness frostProductionSignerReadinessVerifier, + journal *frostRetainedGroupJournal, + readiness frostActivationHandshakeReadinessVerifier, ) (*frostActivationHandshakeExporter, error) { parsedEndpoint, err := validateFrostActivationHandshakeEndpoint(endpoint) if err != nil { @@ -190,7 +281,8 @@ func newFrostActivationHandshakeExporter( durableSessionStoreFingerprintErr != nil || durableSessionStoreFingerprint == [32]byte{} || durableSessionStoreFingerprint == manifest.CanonicalJournal.StoreFingerprint || durableSessionStoreFingerprint == manifest.QuarantineJournal.StoreFingerprint || - pointVerifier == nil || storeBinding == nil || outbox == nil || readiness == nil { + pointVerifier == nil || storeBinding == nil || outbox == nil || + journal == nil || readiness == nil { return nil, fmt.Errorf("FROST activation handshake dependencies are invalid") } boundStoreFingerprint, err := storeBinding.verify() @@ -206,15 +298,33 @@ func newFrostActivationHandshakeExporter( if sha256.Sum256(publicKeyDER) != manifest.AttestationSignerKeyHash { return nil, fmt.Errorf("FROST activation attestation key differs from signed manifest") } + journal.mutex.Lock() + bindingHash := journal.metadata.BindingHash + journalBindingValid := bindingHash != [32]byte{} && + journal.metadata.ManifestHash == manifest.ManifestHash && + journal.quarantineMetadata.ManifestHash == manifest.ManifestHash && + journal.quarantineMetadata.BindingHash == bindingHash && + journal.state.BindingHash == bindingHash && + journal.quarantineState.BindingHash == bindingHash && + journal.checkpointState.BindingHash == bindingHash + journal.mutex.Unlock() + if !journalBindingValid { + return nil, fmt.Errorf( + "FROST activation handshake journal binding differs from signed runtime state", + ) + } exporter := &frostActivationHandshakeExporter{ - endpoint: parsedEndpoint, - privateKey: privateKey, - publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), - manifest: manifest, - pointVerifier: pointVerifier, - storeBinding: storeBinding, - outbox: outbox, - readiness: readiness, + endpoint: parsedEndpoint, + privateKey: privateKey, + publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), + manifest: manifest, + bindingHash: bindingHash, + pointVerifier: pointVerifier, + storeBinding: storeBinding, + outbox: outbox, + journal: journal, + readiness: readiness, + reconciliationWake: make(chan struct{}, 1), } return exporter, nil } @@ -298,12 +408,15 @@ func (fahe *frostActivationHandshakeExporter) start(ctx context.Context) error { Handler: http.HandlerFunc(fahe.serveHTTP), ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 3 * time.Second, - WriteTimeout: 3 * time.Second, + WriteTimeout: frostActivationHandshakeRequestTimeout + time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 4096, } + reconciliationContext, reconciliationCancel := context.WithCancel(ctx) fahe.listener = listener fahe.server = server + fahe.reconciliationCancel = reconciliationCancel + go fahe.reconciliationWorker(reconciliationContext) go func() { if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { logger.Errorf("FROST activation handshake exporter failed: [%v]", err) @@ -327,6 +440,9 @@ func (fahe *frostActivationHandshakeExporter) close() error { return nil } fahe.closed = true + if fahe.reconciliationCancel != nil { + fahe.reconciliationCancel() + } if fahe.server == nil { return nil } @@ -335,6 +451,353 @@ func (fahe *frostActivationHandshakeExporter) close() error { return fahe.server.Shutdown(ctx) } +func (fahe *frostActivationHandshakeExporter) reconciliationWorker( + ctx context.Context, +) { + fahe.runReconciliationWorker(ctx, fahe.reconcileActivationState) +} + +func (fahe *frostActivationHandshakeExporter) runReconciliationWorker( + ctx context.Context, + reconcile func( + context.Context, + FrostPreSignFinality, + ) (*frostActivationReconciliationCache, error), +) { + for { + select { + case <-ctx.Done(): + return + case <-fahe.reconciliationWake: + } + for { + job, reconciliationContext, cancel := fahe.takeReconciliationJob(ctx) + if job == nil { + break + } + cache, err := reconcile( + reconciliationContext, + job.point, + ) + cancel() + if !fahe.completeReconciliationJob(job, cache, err) { + break + } + } + } +} + +func (fahe *frostActivationHandshakeExporter) takeReconciliationJob( + ctx context.Context, +) ( + *frostActivationReconciliationJob, + context.Context, + context.CancelFunc, +) { + fahe.reconciliationMutex.Lock() + defer fahe.reconciliationMutex.Unlock() + if fahe.reconciliationDesired == nil { + return nil, nil, nil + } + job := fahe.reconciliationDesired + fahe.reconciliationDesired = nil + reconciliationContext, cancel := context.WithTimeout( + ctx, + frostActivationHandshakeReconciliationTimeout, + ) + fahe.reconciliationActive = job + fahe.reconciliationActiveCancel = cancel + return job, reconciliationContext, cancel +} + +func (fahe *frostActivationHandshakeExporter) completeReconciliationJob( + job *frostActivationReconciliationJob, + cache *frostActivationReconciliationCache, + reconciliationErr error, +) bool { + fahe.reconciliationMutex.Lock() + if fahe.reconciliationActive != nil && + fahe.reconciliationActive.sequence == job.sequence { + fahe.reconciliationActive = nil + fahe.reconciliationActiveCancel = nil + } + if reconciliationErr == nil && cache != nil && + fahe.reconciliationSequence == job.sequence && + fahe.reconciliationDesired == nil { + fahe.reconciliationCompleted = cache + } + checkpointRecoveryProgress := errors.Is( + reconciliationErr, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) + if checkpointRecoveryProgress && + fahe.reconciliationSequence == job.sequence && + fahe.reconciliationDesired == nil { + fahe.reconciliationSequence++ + fahe.reconciliationDesired = &frostActivationReconciliationJob{ + sequence: fahe.reconciliationSequence, + point: job.point, + } + } + hasDesired := fahe.reconciliationDesired != nil + fahe.reconciliationMutex.Unlock() + if checkpointRecoveryProgress { + logger.Infof( + "background FROST activation reconciliation advanced the checkpoint recovery cursor for block [%d]", + job.point.BlockNumber, + ) + } else if reconciliationErr != nil { + logger.Warnf( + "background FROST activation reconciliation failed for block [%d]: [%v]", + job.point.BlockNumber, + reconciliationErr, + ) + } + return hasDesired +} + +func (fahe *frostActivationHandshakeExporter) queueReconciliation( + point FrostPreSignFinality, + force bool, +) { + fahe.reconciliationMutex.Lock() + if force || (fahe.reconciliationCompleted != nil && + fahe.reconciliationCompleted.point != point) { + fahe.reconciliationCompleted = nil + } + if fahe.reconciliationDesired != nil && + fahe.reconciliationDesired.point == point { + fahe.reconciliationMutex.Unlock() + return + } + if !force && fahe.reconciliationDesired == nil && + fahe.reconciliationActive != nil && + fahe.reconciliationActive.point == point { + fahe.reconciliationMutex.Unlock() + return + } + fahe.reconciliationSequence++ + job := &frostActivationReconciliationJob{ + sequence: fahe.reconciliationSequence, + point: point, + } + fahe.reconciliationDesired = job + if fahe.reconciliationActiveCancel != nil { + fahe.reconciliationActiveCancel() + } + fahe.reconciliationMutex.Unlock() + select { + case fahe.reconciliationWake <- struct{}{}: + default: + } +} + +func (fahe *frostActivationHandshakeExporter) cachedReconciliation( + point FrostPreSignFinality, +) *frostActivationReconciliationCache { + fahe.reconciliationMutex.Lock() + defer fahe.reconciliationMutex.Unlock() + if fahe.reconciliationCompleted == nil || + fahe.reconciliationCompleted.point != point { + return nil + } + cache := *fahe.reconciliationCompleted + cache.readiness.Journal = &cache.journal + cache.readiness.Inventory = &cache.inventory + return &cache +} + +func (fahe *frostActivationHandshakeExporter) reconcileActivationState( + ctx context.Context, + finality FrostPreSignFinality, +) (*frostActivationReconciliationCache, error) { + if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint( + ctx, + finality, + ); err != nil { + return nil, fmt.Errorf("cannot verify FROST activation point: [%w]", err) + } + readinessSnapshot, err := fahe.readiness.verifyFrostProductionSignerReadiness( + ctx, + finality, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot verify production FROST signer readiness: [%w]", + err, + ) + } + journalSnapshot := readinessSnapshot.Journal + inventorySnapshot := readinessSnapshot.Inventory + if journalSnapshot == nil || inventorySnapshot == nil { + return nil, fmt.Errorf( + "production FROST signer readiness snapshot is incomplete", + ) + } + if err := fahe.validateActivationJournalSnapshot( + journalSnapshot, + finality, + ); err != nil { + return nil, err + } + if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint( + ctx, + finality, + ); err != nil { + return nil, fmt.Errorf( + "FROST activation point changed during readiness reconciliation: [%w]", + err, + ) + } + stamp, err := fahe.tryJournalStamp() + if err != nil { + return nil, fmt.Errorf( + "cannot read canonical FROST retained-group journal after reconciliation: [%w]", + err, + ) + } + if !frostActivationStampMatchesSnapshot( + stamp, + journalSnapshot, + finality, + ) { + return nil, fmt.Errorf( + "canonical FROST retained-group journal changed after reconciliation", + ) + } + cache := &frostActivationReconciliationCache{ + point: finality, + journal: *journalSnapshot, + inventory: *inventorySnapshot, + interactiveSigningReady: readinessSnapshot.InteractiveSigningReady, + readiness: *readinessSnapshot, + stamp: stamp, + } + cache.readiness.Journal = &cache.journal + cache.readiness.Inventory = &cache.inventory + return cache, nil +} + +func (fahe *frostActivationHandshakeExporter) validateActivationJournalSnapshot( + journalSnapshot *frostRetainedGroupJournalSnapshot, + finality FrostPreSignFinality, +) error { + if journalSnapshot == nil { + return fmt.Errorf("canonical FROST retained-group journal snapshot is nil") + } + journalManifest := fahe.manifest.CanonicalJournal + quarantineManifest := fahe.manifest.QuarantineJournal + if journalSnapshot.Schema != frostRetainedGroupJournalSnapshotSchema || + journalSnapshot.BindingHash != fahe.bindingHash || + !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || + journalSnapshot.StoreID != journalManifest.StoreID || + journalSnapshot.StoreFingerprint != journalManifest.StoreFingerprint || + journalSnapshot.ClusterFingerprint != journalManifest.ClusterFingerprint || + journalSnapshot.SnapshotGeneration < journalManifest.MinimumGeneration || + journalSnapshot.QuarantineProtocolID != quarantineManifest.ProtocolID || + journalSnapshot.QuarantineStoreID != quarantineManifest.StoreID || + journalSnapshot.QuarantineStoreFingerprint != quarantineManifest.StoreFingerprint || + journalSnapshot.QuarantineClusterFingerprint != quarantineManifest.ClusterFingerprint || + journalSnapshot.QuarantineGeneration < quarantineManifest.MinimumGeneration || + journalSnapshot.QuarantineRoot == [32]byte{} || + journalSnapshot.QuarantineActiveRoot == [32]byte{} || + journalSnapshot.QuarantineTombstoneRoot == [32]byte{} || + journalSnapshot.CheckpointMinimumSequence != + quarantineManifest.CheckpointMinimumSequence || + journalSnapshot.CheckpointPredecessorHash != + quarantineManifest.CheckpointPredecessorHash || + journalSnapshot.CheckpointSequence < + quarantineManifest.CheckpointMinimumSequence || + journalSnapshot.CheckpointCertificateHash == [32]byte{} || + journalSnapshot.CheckpointHistoryRoot == [32]byte{} || + journalSnapshot.QuarantineCount != 0 { + return fmt.Errorf( + "canonical FROST retained-group journal is not activation-ready", + ) + } + return nil +} + +func (fahe *frostActivationHandshakeExporter) tryJournalStamp() ( + frostActivationJournalStamp, + error, +) { + if !fahe.journal.mutex.TryLock() { + return frostActivationJournalStamp{}, errFrostActivationJournalBusy + } + defer fahe.journal.mutex.Unlock() + return fahe.journalStampLocked() +} + +func (fahe *frostActivationHandshakeExporter) journalStampLocked() ( + frostActivationJournalStamp, + error, +) { + journal := fahe.journal + if journal.closed || + journal.metadata.BindingHash != fahe.bindingHash || + journal.quarantineMetadata.BindingHash != fahe.bindingHash || + journal.state.BindingHash != fahe.bindingHash || + journal.quarantineState.BindingHash != fahe.bindingHash || + journal.checkpointState.BindingHash != fahe.bindingHash { + return frostActivationJournalStamp{}, fmt.Errorf( + "canonical FROST retained-group journal binding is not live", + ) + } + return frostActivationJournalStamp{ + bindingHash: fahe.bindingHash, + canonicalPoint: journal.state.CurrentPoint, + canonicalGeneration: journal.state.SnapshotGeneration, + canonicalBatchRoot: journal.state.BatchRoot, + canonicalInventory: journal.state.InventoryRoot, + quarantinePoint: journal.quarantineState.CurrentPoint, + quarantineGeneration: journal.quarantineState.Generation, + quarantineBatchRoot: journal.quarantineState.BatchRoot, + quarantineRoot: journal.quarantineState.Root, + quarantineActiveRoot: journal.quarantineState.ActiveRoot, + quarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, + checkpointSequence: journal.checkpointState.Sequence, + checkpointHash: journal.checkpointState.CertificateHash, + checkpointHistoryRoot: journal.checkpointState.HistoryRoot, + }, nil +} + +func frostActivationStampMatchesSnapshot( + stamp frostActivationJournalStamp, + snapshot *frostRetainedGroupJournalSnapshot, + point FrostPreSignFinality, +) bool { + return snapshot != nil && + stamp.bindingHash == snapshot.BindingHash && + stamp.canonicalPoint == point && + stamp.quarantinePoint == point && + stamp.canonicalGeneration == snapshot.SnapshotGeneration && + stamp.canonicalBatchRoot == snapshot.BatchRoot && + stamp.canonicalInventory == snapshot.InventoryRoot && + stamp.quarantineGeneration == snapshot.QuarantineGeneration && + stamp.quarantineRoot == snapshot.QuarantineRoot && + stamp.quarantineActiveRoot == snapshot.QuarantineActiveRoot && + stamp.quarantineTombstoneRoot == snapshot.QuarantineTombstoneRoot && + stamp.checkpointSequence == snapshot.CheckpointSequence && + stamp.checkpointHash == snapshot.CheckpointCertificateHash && + stamp.checkpointHistoryRoot == snapshot.CheckpointHistoryRoot +} + +func (fahe *frostActivationHandshakeExporter) verifyActivationPointQuick( + ctx context.Context, + point FrostPreSignFinality, +) error { + quickContext, cancel := context.WithTimeout( + ctx, + frostActivationHandshakeQuickCheckTimeout, + ) + defer cancel() + return fahe.pointVerifier.VerifyFrostPreSignActivationPoint( + quickContext, + point, + ) +} + func (fahe *frostActivationHandshakeExporter) serveHTTP( responseWriter http.ResponseWriter, request *http.Request, @@ -367,9 +830,20 @@ func (fahe *frostActivationHandshakeExporter) serveHTTP( http.Error(responseWriter, "invalid request", http.StatusBadRequest) return } - handshake, err := fahe.attest(request.Context(), handshakeRequest) + attestationContext, cancel := context.WithTimeout( + request.Context(), + frostActivationHandshakeRequestTimeout, + ) + defer cancel() + handshake, err := fahe.attest(attestationContext, handshakeRequest) if err != nil { logger.Warnf("refusing FROST activation handshake: [%v]", err) + if errors.Is(err, errFrostActivationReconciliationPending) { + responseWriter.Header().Set( + "Retry-After", + frostActivationHandshakeRetryAfter, + ) + } http.Error(responseWriter, "activation state is not ready", http.StatusServiceUnavailable) return } @@ -396,6 +870,29 @@ func (fahe *frostActivationHandshakeExporter) attest( if err != nil || manifestHash != fahe.manifest.ManifestHash { return nil, fmt.Errorf("FROST activation challenge manifest hash mismatch") } + bindingHash, err := parseFrostActivationHex32(request.Challenge.BindingHash) + if err != nil || bindingHash != fahe.bindingHash { + return nil, fmt.Errorf("FROST activation challenge binding hash mismatch") + } + checkpointFloorHash, err := parseFrostActivationHex32( + request.Challenge.CheckpointFloor.CertificateHash, + ) + checkpointFloor := FrostRetainedGroupCheckpointCursor{ + Sequence: request.Challenge.CheckpointFloor.Sequence, + CertificateHash: checkpointFloorHash, + } + if err != nil || + request.Challenge.CheckpointFloor.CertificateHash != + frostActivationHex32(checkpointFloorHash) || + checkpointFloor.Sequence < + fahe.manifest.QuarantineJournal.CheckpointMinimumSequence || + checkpointFloor.Sequence > + frostRetainedGroupMaximumCanonicalJSONInteger || + checkpointFloorHash == [32]byte{} { + return nil, fmt.Errorf( + "FROST activation challenge checkpoint floor is invalid", + ) + } blockHash, err := parseFrostActivationHex32(request.Challenge.EthereumPoint.BlockHash) if err != nil || request.Challenge.EthereumPoint.BlockNumber == 0 { return nil, fmt.Errorf("FROST activation challenge Ethereum point is invalid") @@ -404,18 +901,32 @@ func (fahe *frostActivationHandshakeExporter) attest( BlockNumber: request.Challenge.EthereumPoint.BlockNumber, BlockHash: blockHash, } - if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { - return nil, fmt.Errorf("cannot verify FROST activation point: [%w]", err) + reconciliation := fahe.cachedReconciliation(finality) + if reconciliation == nil { + fahe.queueReconciliation(finality, false) + return nil, errFrostActivationReconciliationPending } - readinessSnapshot, err := fahe.readiness.verifyFrostProductionSignerReadiness(ctx, finality) - if err != nil { - return nil, fmt.Errorf("cannot verify production FROST signer readiness: [%w]", err) + liveStamp, stampErr := fahe.tryJournalStamp() + if errors.Is(stampErr, errFrostActivationJournalBusy) { + return nil, errFrostActivationReconciliationPending } - journalSnapshot := readinessSnapshot.Journal - nativeSignerSnapshot := readinessSnapshot.Inventory - if journalSnapshot == nil || nativeSignerSnapshot == nil { - return nil, fmt.Errorf("production FROST signer readiness snapshot is incomplete") + if stampErr != nil || liveStamp != reconciliation.stamp { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: canonical or quarantine journal generation changed", + errFrostActivationReconciliationPending, + ) } + if err := fahe.verifyActivationPointQuick(ctx, finality); err != nil { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: cannot verify cached FROST activation point: [%v]", + errFrostActivationReconciliationPending, + err, + ) + } + journalSnapshot := &reconciliation.journal + nativeSignerSnapshot := &reconciliation.inventory if !nativeSignerSnapshot.ExternalRollbackAnchorBound || nativeSignerSnapshot.TrustCertificateSequence == 0 || nativeSignerSnapshot.TrustCertificateDigest == [32]byte{} || @@ -448,19 +959,38 @@ func (fahe *frostActivationHandshakeExporter) attest( ) } journalManifest := fahe.manifest.CanonicalJournal - quarantineManifest := fahe.manifest.QuarantineJournal - if !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || - journalSnapshot.StoreID != journalManifest.StoreID || - journalSnapshot.StoreFingerprint != journalManifest.StoreFingerprint || - journalSnapshot.ClusterFingerprint != journalManifest.ClusterFingerprint || - journalSnapshot.SnapshotGeneration < journalManifest.MinimumGeneration || - journalSnapshot.QuarantineProtocolID != quarantineManifest.ProtocolID || - journalSnapshot.QuarantineStoreID != quarantineManifest.StoreID || - journalSnapshot.QuarantineStoreFingerprint != quarantineManifest.StoreFingerprint || - journalSnapshot.QuarantineClusterFingerprint != quarantineManifest.ClusterFingerprint || - journalSnapshot.QuarantineGeneration < quarantineManifest.MinimumGeneration || - journalSnapshot.QuarantineRoot == [32]byte{} || journalSnapshot.QuarantineCount != 0 { - return nil, fmt.Errorf("canonical FROST retained-group journal is not activation-ready") + if !fahe.journal.mutex.TryLock() { + return nil, fmt.Errorf( + "%w: %v", + errFrostActivationReconciliationPending, + errFrostActivationJournalBusy, + ) + } + ancestryStamp, ancestryStampErr := fahe.journalStampLocked() + if ancestryStampErr != nil || ancestryStamp != reconciliation.stamp { + fahe.journal.mutex.Unlock() + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: canonical or quarantine journal generation changed before checkpoint ancestry", + errFrostActivationReconciliationPending, + ) + } + checkpointAncestry, ancestryErr := + fahe.journal.checkpointAncestryFrom(checkpointFloor) + fahe.journal.mutex.Unlock() + if ancestryErr != nil { + return nil, fmt.Errorf( + "checkpoint ancestry rejected the external transparency floor: [%w]", + ancestryErr, + ) + } + wireCheckpointAncestry := make( + []frostRetainedGroupWireCheckpointCertificate, + len(checkpointAncestry), + ) + for index, certificate := range checkpointAncestry { + wireCheckpointAncestry[index] = + frostRetainedGroupCheckpointCertificateToWire(certificate) } outboxSnapshot, err := fahe.outbox.activationSnapshot() if err != nil { @@ -470,9 +1000,6 @@ func (fahe *frostActivationHandshakeExporter) attest( outboxSnapshot.QuarantineCount != 0 { return nil, fmt.Errorf("durable Bitcoin outbox is not activation-ready") } - if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { - return nil, fmt.Errorf("FROST activation point changed during readiness reconciliation: [%w]", err) - } durableSessionStoreFingerprint, err := fahe.storeBinding.verify() if err != nil { return nil, fmt.Errorf( @@ -507,6 +1034,7 @@ func (fahe *frostActivationHandshakeExporter) attest( StoreID: journalSnapshot.StoreID, StoreFingerprint: frostActivationHex32(journalSnapshot.StoreFingerprint), ClusterFingerprint: frostActivationHex32(journalSnapshot.ClusterFingerprint), + BindingHash: frostActivationHex32(journalSnapshot.BindingHash), Checkpoint: frostActivationEthereumPoint{ BlockNumber: journalManifest.Checkpoint.BlockNumber, BlockHash: frostActivationHex32(journalManifest.Checkpoint.BlockHash), @@ -516,6 +1044,7 @@ func (fahe *frostActivationHandshakeExporter) attest( SourceTrustDomainID: journalManifest.SourceTrustDomainID, SourceEndpointFingerprint: frostActivationHex32(journalManifest.SourceEndpointFingerprint), SourceOperatorFingerprint: frostActivationHex32(journalManifest.SourceOperatorFingerprint), + SourceIdentity: frostRetainedGroupIdentityToWire(journalManifest.SourceIdentity), Generation: journalSnapshot.SnapshotGeneration, Complete: true, }, @@ -525,10 +1054,36 @@ func (fahe *frostActivationHandshakeExporter) attest( StoreFingerprint: frostActivationHex32(journalSnapshot.QuarantineStoreFingerprint), ClusterFingerprint: frostActivationHex32(journalSnapshot.QuarantineClusterFingerprint), Root: frostActivationHex32(journalSnapshot.QuarantineRoot), + ActiveRoot: frostActivationHex32(journalSnapshot.QuarantineActiveRoot), + TombstoneRoot: frostActivationHex32(journalSnapshot.QuarantineTombstoneRoot), Generation: journalSnapshot.QuarantineGeneration, CurrentQuarantineCount: journalSnapshot.QuarantineCount, + TombstoneCount: journalSnapshot.QuarantineTombstoneCount, Complete: true, }, + CheckpointJournal: frostActivationCheckpointJournalState{ + ManifestMinimumSequence: journalSnapshot.CheckpointMinimumSequence, + ManifestPredecessorHash: frostActivationHex32( + journalSnapshot.CheckpointPredecessorHash, + ), + ChallengeFloor: request.Challenge.CheckpointFloor, + DurableHead: frostRetainedGroupWireCheckpointCursor{ + Sequence: journalSnapshot.CheckpointSequence, + CertificateHash: frostActivationHex32( + journalSnapshot.CheckpointCertificateHash, + ), + }, + Point: request.Challenge.EthereumPoint, + HistoryRoot: frostActivationHex32(journalSnapshot.CheckpointHistoryRoot), + CanonicalGeneration: journalSnapshot.SnapshotGeneration, + CanonicalInventoryRoot: frostActivationHex32(journalSnapshot.InventoryRoot), + QuarantineGeneration: journalSnapshot.QuarantineGeneration, + QuarantineEventRoot: frostActivationHex32(journalSnapshot.QuarantineRoot), + QuarantineActiveRoot: frostActivationHex32(journalSnapshot.QuarantineActiveRoot), + QuarantineTombstoneRoot: frostActivationHex32(journalSnapshot.QuarantineTombstoneRoot), + Ancestry: wireCheckpointAncestry, + Complete: true, + }, NativeSignerState: frostActivationNativeSignerState{ Schema: nativeSignerSnapshot.Schema, StoreFingerprint: frostActivationHex32(nativeSignerSnapshot.StoreFingerprint), @@ -551,21 +1106,33 @@ func (fahe *frostActivationHandshakeExporter) attest( AnchorRotationWarning: nativeSignerSnapshot.AnchorRotationWarning, Complete: true, }, - InteractiveSigningReady: readinessSnapshot.InteractiveSigningReady, + InteractiveSigningReady: reconciliation.interactiveSigningReady, FinalizedReservationReadbackEnforced: true, ExactTransactionAuthorizationRootEnforced: true, - NonceShareGateEnforced: readinessSnapshot.InteractiveSigningReady && + NonceShareGateEnforced: reconciliation.interactiveSigningReady && nativeSignerSnapshot.StateGeneration > 0 && nativeSignerSnapshot.StateCommitment != [32]byte{}, DurableBitcoinOutboxRecovered: outboxSnapshot.Recovered, QuarantineFailClosed: journalSnapshot.QuarantineCount == 0, } state.Healthy = frostActivationHandshakeHealthy(state) + if err := verifyFrostActivationCheckpointHandshakeState( + fahe.manifest, + fahe.bindingHash, + request.Challenge, + state, + ); err != nil { + return nil, fmt.Errorf( + "cannot self-verify FROST activation checkpoint proof: [%w]", + err, + ) + } payload := frostActivationHandshakePayload{ Schema: frostActivationHandshakeSchema, Kind: "frost-signer", Nonce: request.Challenge.Nonce, ManifestHash: request.Challenge.ManifestHash, + BindingHash: request.Challenge.BindingHash, EthereumPoint: request.Challenge.EthereumPoint, State: state, } @@ -575,7 +1142,57 @@ func (fahe *frostActivationHandshakeExporter) attest( if err != nil { return nil, err } - signature := ed25519.Sign(fahe.privateKey, signatureTranscript) + if err := fahe.verifyActivationPointQuick(ctx, finality); err != nil { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: cached FROST activation point changed before signing: [%v]", + errFrostActivationReconciliationPending, + err, + ) + } + if err := fahe.readiness.verifyFrostProductionSignerReadinessUnchanged( + ctx, + &reconciliation.readiness, + ); err != nil { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: cached FROST signer readiness changed before signing: [%v]", + errFrostActivationReconciliationPending, + err, + ) + } + var signature []byte + journalChanged := false + err = fahe.outbox.withUnchangedActivationSnapshot( + outboxSnapshot, + func() error { + if !fahe.journal.mutex.TryLock() { + return fmt.Errorf( + "%w: %v", + errFrostActivationReconciliationPending, + errFrostActivationJournalBusy, + ) + } + defer fahe.journal.mutex.Unlock() + signingStamp, stampErr := fahe.journalStampLocked() + if stampErr != nil || signingStamp != reconciliation.stamp || + !fahe.journal.checkpointDescendsFrom(checkpointFloor) { + journalChanged = true + return fmt.Errorf( + "%w: canonical or quarantine journal generation changed before signing", + errFrostActivationReconciliationPending, + ) + } + signature = ed25519.Sign(fahe.privateKey, signatureTranscript) + return nil + }, + ) + if journalChanged { + fahe.queueReconciliation(finality, true) + } + if err != nil { + return nil, err + } return &frostActivationSignedHandshake{ Payload: payload, SignerPublicKeySPKI: fahe.publicKeySPKI, @@ -617,7 +1234,173 @@ func frostActivationHandshakeSignatureTranscript( return result, nil } +func verifyFrostActivationCheckpointHandshakeState( + manifest FrostPreSignActivationRuntimeManifest, + bindingHash [32]byte, + challenge frostActivationChallenge, + state frostActivationHandshakeState, +) error { + checkpoint := state.CheckpointJournal + if !checkpoint.Complete || + checkpoint.ManifestMinimumSequence != + manifest.QuarantineJournal.CheckpointMinimumSequence || + checkpoint.ManifestPredecessorHash != + frostActivationHex32( + manifest.QuarantineJournal.CheckpointPredecessorHash, + ) || + checkpoint.ChallengeFloor != challenge.CheckpointFloor || + checkpoint.Point != challenge.EthereumPoint || + checkpoint.Point != state.CanonicalJournal.Current || + checkpoint.Point != state.FrostWalletGroupInventory.Point || + checkpoint.CanonicalGeneration != + state.CanonicalJournal.Generation || + checkpoint.CanonicalGeneration != + state.FrostWalletGroupInventory.SnapshotGeneration || + checkpoint.CanonicalInventoryRoot != + state.FrostWalletGroupInventory.InventoryRoot || + checkpoint.QuarantineGeneration != + state.QuarantineJournal.Generation || + checkpoint.QuarantineEventRoot != + state.QuarantineJournal.Root || + checkpoint.QuarantineActiveRoot != + state.QuarantineJournal.ActiveRoot || + checkpoint.QuarantineTombstoneRoot != + state.QuarantineJournal.TombstoneRoot { + return fmt.Errorf( + "FROST activation checkpoint proof differs from the surrounding handshake state", + ) + } + parseCursor := func( + name string, + wire frostRetainedGroupWireCheckpointCursor, + ) (FrostRetainedGroupCheckpointCursor, error) { + certificateHash, err := parseFrostActivationHex32( + wire.CertificateHash, + ) + if err != nil || + wire.CertificateHash != + frostActivationHex32(certificateHash) { + return FrostRetainedGroupCheckpointCursor{}, fmt.Errorf( + "invalid %s checkpoint cursor", + name, + ) + } + return FrostRetainedGroupCheckpointCursor{ + Sequence: wire.Sequence, + CertificateHash: certificateHash, + }, nil + } + floor, err := parseCursor("floor", checkpoint.ChallengeFloor) + if err != nil { + return err + } + durableHead, err := parseCursor("durable head", checkpoint.DurableHead) + if err != nil { + return err + } + pointHash, err := parseFrostActivationHex32(checkpoint.Point.BlockHash) + if err != nil || + checkpoint.Point.BlockHash != frostActivationHex32(pointHash) { + return fmt.Errorf("invalid FROST checkpoint proof point") + } + parseRoot := func(name string, value string) ([32]byte, error) { + root, err := parseFrostActivationHex32(value) + if err != nil || value != frostActivationHex32(root) { + return [32]byte{}, fmt.Errorf( + "invalid FROST checkpoint proof %s", + name, + ) + } + return root, nil + } + historyRoot, err := parseRoot("history root", checkpoint.HistoryRoot) + if err != nil { + return err + } + canonicalInventoryRoot, err := parseRoot( + "canonical inventory root", + checkpoint.CanonicalInventoryRoot, + ) + if err != nil { + return err + } + quarantineEventRoot, err := parseRoot( + "quarantine event root", + checkpoint.QuarantineEventRoot, + ) + if err != nil { + return err + } + quarantineActiveRoot, err := parseRoot( + "quarantine active root", + checkpoint.QuarantineActiveRoot, + ) + if err != nil { + return err + } + quarantineTombstoneRoot, err := parseRoot( + "quarantine tombstone root", + checkpoint.QuarantineTombstoneRoot, + ) + if err != nil { + return err + } + certificates := make( + []FrostRetainedGroupCheckpointCertificate, + len(checkpoint.Ancestry), + ) + for index, wireCertificate := range checkpoint.Ancestry { + certificate, err := + frostRetainedGroupCheckpointCertificateFromWire( + wireCertificate, + ) + if err != nil { + return fmt.Errorf( + "invalid FROST checkpoint proof certificate [%d]: [%w]", + index, + err, + ) + } + certificates[index] = certificate + } + return VerifyFrostRetainedGroupCheckpointProof( + bindingHash, + manifest, + floor, + FrostRetainedGroupCheckpointCommitment{ + DurableHead: durableHead, + Point: FrostPreSignFinality{ + BlockNumber: checkpoint.Point.BlockNumber, + BlockHash: pointHash, + }, + HistoryRoot: historyRoot, + CanonicalGeneration: checkpoint.CanonicalGeneration, + CanonicalInventoryRoot: canonicalInventoryRoot, + QuarantineGeneration: checkpoint.QuarantineGeneration, + QuarantineEventRoot: quarantineEventRoot, + QuarantineActiveRoot: quarantineActiveRoot, + QuarantineTombstoneRoot: quarantineTombstoneRoot, + }, + certificates, + ) +} + func decodeStrictFrostActivationJSON(data []byte, target interface{}) error { + if err := validateUniqueFrostActivationJSONKeys(data); err != nil { + return err + } + var decoded interface{} + shapeDecoder := json.NewDecoder(bytes.NewReader(data)) + shapeDecoder.UseNumber() + if err := shapeDecoder.Decode(&decoded); err != nil { + return err + } + if err := validateExactFrostActivationJSONShape( + decoded, + reflect.TypeOf(target), + ); err != nil { + return err + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() decoder.UseNumber() @@ -630,17 +1413,208 @@ func decodeStrictFrostActivationJSON(data []byte, target interface{}) error { return nil } +func validateUniqueFrostActivationJSONKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var readValue func() error + readValue = func() error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + return nil + } + switch delimiter { + case '{': + keys := make(map[string]struct{}) + foldedKeys := make(map[string]string) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("JSON object key is not a string") + } + for _, character := range key { + if character < 0x20 || character > 0x7e { + return fmt.Errorf( + "JSON object key [%s] is not printable ASCII", + key, + ) + } + } + if _, exists := keys[key]; exists { + return fmt.Errorf("JSON object contains duplicate key [%s]", key) + } + folded := strings.ToLower(key) + if existing, exists := foldedKeys[folded]; exists { + return fmt.Errorf( + "JSON object contains case-fold-equivalent keys [%s] and [%s]", + existing, + key, + ) + } + keys[key] = struct{}{} + foldedKeys[folded] = key + if err := readValue(); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return fmt.Errorf("JSON object is not closed") + } + case '[': + for decoder.More() { + if err := readValue(); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return fmt.Errorf("JSON array is not closed") + } + default: + return fmt.Errorf("unexpected JSON delimiter [%s]", delimiter) + } + return nil + } + if err := readValue(); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("JSON contains trailing data") + } + return err + } + return nil +} + +func validateExactFrostActivationJSONShape( + value interface{}, + targetType reflect.Type, +) error { + if targetType == nil { + return fmt.Errorf("JSON target type is nil") + } + for targetType.Kind() == reflect.Pointer { + targetType = targetType.Elem() + } + rawMessageType := reflect.TypeOf(json.RawMessage{}) + jsonUnmarshalerType := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() + if targetType == rawMessageType || targetType.Kind() == reflect.Interface { + return nil + } + if targetType.Implements(jsonUnmarshalerType) || + reflect.PointerTo(targetType).Implements(jsonUnmarshalerType) { + return fmt.Errorf("custom JSON unmarshal targets are not supported") + } + if value == nil { + return nil + } + if targetType.Kind() == reflect.Struct { + object, ok := value.(map[string]interface{}) + if !ok { + return nil + } + fields := make(map[string]reflect.Type) + for index := 0; index < targetType.NumField(); index++ { + field := targetType.Field(index) + if field.PkgPath != "" { + continue + } + tag := field.Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + fields[name] = field.Type + } + for key, item := range object { + fieldType, ok := fields[key] + if !ok { + return fmt.Errorf("JSON object contains non-exact or unknown key [%s]", key) + } + if err := validateExactFrostActivationJSONShape(item, fieldType); err != nil { + return fmt.Errorf("JSON key [%s]: [%w]", key, err) + } + } + return nil + } + if targetType.Kind() == reflect.Slice || targetType.Kind() == reflect.Array { + if targetType.Kind() == reflect.Slice && + targetType.Elem().Kind() == reflect.Uint8 { + return nil + } + items, ok := value.([]interface{}) + if !ok { + return nil + } + for index, item := range items { + if err := validateExactFrostActivationJSONShape( + item, + targetType.Elem(), + ); err != nil { + return fmt.Errorf("JSON array item [%d]: [%w]", index, err) + } + } + return nil + } + if targetType.Kind() == reflect.Map { + object, ok := value.(map[string]interface{}) + if !ok { + return nil + } + for key, item := range object { + if err := validateExactFrostActivationJSONShape( + item, + targetType.Elem(), + ); err != nil { + return fmt.Errorf("JSON map key [%s]: [%w]", key, err) + } + } + } + return nil +} + func canonicalFrostActivationValue(value interface{}) ([]byte, error) { + if raw, ok := value.(json.RawMessage); ok { + return canonicalFrostActivationJSON(raw) + } + if raw, ok := value.(*json.RawMessage); ok { + if raw == nil { + return nil, fmt.Errorf("canonical JSON raw message is nil") + } + return canonicalFrostActivationJSON(*raw) + } encoded, err := json.Marshal(value) if err != nil { return nil, err } + return canonicalFrostActivationJSON(encoded) +} + +func canonicalFrostActivationJSON(encoded []byte) ([]byte, error) { + if err := validateUniqueFrostActivationJSONKeys(encoded); err != nil { + return nil, err + } decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.UseNumber() var decoded interface{} if err := decoder.Decode(&decoded); err != nil { return nil, err } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("JSON contains trailing data") + } buffer := bytes.NewBuffer(nil) if err := writeCanonicalFrostActivationJSON(buffer, decoded); err != nil { return nil, err diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index a2f16fdf6d..50c7b001a6 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "net" @@ -18,6 +19,8 @@ import ( "path/filepath" "reflect" "sort" + "strings" + "sync" "testing" "time" @@ -25,7 +28,203 @@ import ( "github.com/keep-network/keep-core/pkg/chain" ) +func TestFrostActivationHandshakeExporter_CheckpointRecoveryReentry( + t *testing.T, +) { + point := FrostPreSignFinality{ + BlockNumber: 11, + BlockHash: [32]byte{0x11}, + } + + t.Run("requeues authenticated progress", func(t *testing.T) { + job := &frostActivationReconciliationJob{ + sequence: 7, + point: point, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationSequence: 7, + reconciliationActive: job, + } + hasDesired := exporter.completeReconciliationJob( + job, + nil, + fmt.Errorf( + "wrapped recovery result: %w", + errFrostRetainedGroupCheckpointRecoveryProgress, + ), + ) + if !hasDesired || + exporter.reconciliationActive != nil || + exporter.reconciliationCompleted != nil || + exporter.reconciliationSequence != 8 || + exporter.reconciliationDesired == nil || + exporter.reconciliationDesired.sequence != 8 || + exporter.reconciliationDesired.point != point { + t.Fatalf( + "authenticated checkpoint progress was not deterministically requeued: %+v", + exporter, + ) + } + }) + + t.Run("does not retry an ordinary failure", func(t *testing.T) { + job := &frostActivationReconciliationJob{ + sequence: 7, + point: point, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationSequence: 7, + reconciliationActive: job, + } + if exporter.completeReconciliationJob( + job, + nil, + errors.New("ordinary failure"), + ) || + exporter.reconciliationDesired != nil || + exporter.reconciliationSequence != 7 { + t.Fatalf( + "ordinary reconciliation failure was automatically requeued: %+v", + exporter, + ) + } + }) + + t.Run("preserves a newer request", func(t *testing.T) { + job := &frostActivationReconciliationJob{ + sequence: 7, + point: point, + } + newerPoint := FrostPreSignFinality{ + BlockNumber: 12, + BlockHash: [32]byte{0x12}, + } + newerJob := &frostActivationReconciliationJob{ + sequence: 8, + point: newerPoint, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationSequence: 8, + reconciliationActive: job, + reconciliationDesired: newerJob, + } + if !exporter.completeReconciliationJob( + job, + nil, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) || + exporter.reconciliationSequence != 8 || + exporter.reconciliationDesired != newerJob { + t.Fatalf( + "checkpoint progress overwrote the newer reconciliation request: %+v", + exporter, + ) + } + }) +} + +func TestFrostActivationHandshakeExporter_CheckpointRecoveryWorkerLoopsUntilComplete( + t *testing.T, +) { + point := FrostPreSignFinality{ + BlockNumber: 11, + BlockHash: [32]byte{0x11}, + } + job := &frostActivationReconciliationJob{ + sequence: 1, + point: point, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationWake: make(chan struct{}, 1), + reconciliationSequence: 1, + reconciliationDesired: job, + } + thirdAttemptStarted := make(chan struct{}) + releaseThirdAttempt := make(chan struct{}) + attempts := 0 + reconcile := func( + ctx context.Context, + actualPoint FrostPreSignFinality, + ) (*frostActivationReconciliationCache, error) { + attempts++ + if actualPoint != point { + return nil, fmt.Errorf("worker reconciled an unexpected point") + } + if attempts <= 2 { + return nil, fmt.Errorf( + "bounded page [%d]: %w", + attempts, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) + } + close(thirdAttemptStarted) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-releaseThirdAttempt: + } + return &frostActivationReconciliationCache{ + point: point, + }, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + workerDone := make(chan struct{}) + go func() { + exporter.runReconciliationWorker(ctx, reconcile) + close(workerDone) + }() + exporter.reconciliationWake <- struct{}{} + + select { + case <-thirdAttemptStarted: + case <-time.After(3 * time.Second): + cancel() + <-workerDone + t.Fatal("worker did not re-enter checkpoint recovery") + } + exporter.reconciliationMutex.Lock() + if exporter.reconciliationCompleted != nil || + exporter.reconciliationActive == nil || + exporter.reconciliationActive.sequence != 3 || + exporter.reconciliationSequence != 3 { + exporter.reconciliationMutex.Unlock() + cancel() + close(releaseThirdAttempt) + <-workerDone + t.Fatalf( + "worker published health before checkpoint recovery completed: %+v", + exporter, + ) + } + exporter.reconciliationMutex.Unlock() + + close(releaseThirdAttempt) + deadline := time.Now().Add(3 * time.Second) + for exporter.cachedReconciliation(point) == nil { + if time.Now().After(deadline) { + cancel() + <-workerDone + t.Fatal("worker did not publish completed reconciliation") + } + time.Sleep(time.Millisecond) + } + cancel() + select { + case <-workerDone: + case <-time.After(3 * time.Second): + t.Fatal("checkpoint recovery worker did not stop") + } + if attempts != 3 { + t.Fatalf( + "worker made [%d] attempts, expected two progress pages and completion", + attempts, + ) + } +} + type testFrostActivationPointVerifier struct { + mutex sync.Mutex err error point FrostPreSignFinality calls uint64 @@ -35,32 +234,128 @@ func (tfapv *testFrostActivationPointVerifier) VerifyFrostPreSignActivationPoint ctx context.Context, point FrostPreSignFinality, ) error { + tfapv.mutex.Lock() + defer tfapv.mutex.Unlock() tfapv.point = point tfapv.calls++ return tfapv.err } +func (tfapv *testFrostActivationPointVerifier) snapshot() ( + FrostPreSignFinality, + uint64, +) { + tfapv.mutex.Lock() + defer tfapv.mutex.Unlock() + return tfapv.point, tfapv.calls +} + +func (tfapv *testFrostActivationPointVerifier) setError(err error) { + tfapv.mutex.Lock() + defer tfapv.mutex.Unlock() + tfapv.err = err +} + type testFrostRetainedGroupHistorySource struct { - manifest FrostRetainedGroupCanonicalJournalManifest - target FrostPreSignFinality + mutex sync.Mutex + manifest FrostRetainedGroupCanonicalJournalManifest + bindingHash [32]byte + checkpointHead FrostRetainedGroupCheckpointCursor + historyRoot [32]byte + target FrostPreSignFinality + readCalls uint64 + readStarted chan struct{} + readRelease <-chan struct{} + readDeadline time.Time + hasDeadline bool + readOnce sync.Once } type testFrostProductionSignerReadiness struct { - journal *frostRetainedGroupJournal - interactive bool - err error - calls uint64 + mutex sync.Mutex + journal *frostRetainedGroupJournal + interactive bool + err error + calls uint64 + inventory frostNativeSignerInventorySnapshot + unchangedStarted chan struct{} + unchangedRelease <-chan struct{} + unchangedOnce sync.Once +} + +func testFrostProductionSignerInventorySnapshot() frostNativeSignerInventorySnapshot { + return frostNativeSignerInventorySnapshot{ + Schema: "tbtc-signer-retained-key-package-inventory/v1", + StoreFingerprint: testFrostDurableSessionStoreIdentity().Fingerprint, + StateGeneration: 7, + StateCommitment: [32]byte{0x31}, + PreviousStateCommitment: [32]byte{0x30}, + StateImageDigest: [32]byte{0x33}, + InventoryCommitment: [32]byte{0x32}, + ExternalRollbackAnchorBound: true, + TrustCertificateSequence: 3, + TrustCertificateDigest: [32]byte{0x34}, + AnchorServiceEpoch: 1, + CertifiedFloorRevision: 1, + CertifiedFloorGeneration: 1, + CurrentAnchorRevision: 1, + RestartableRevisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + RestartableGenerationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries - + 6, + AnchorRotationWarning: false, + } +} + +func (readiness *testFrostProductionSignerReadiness) snapshot() ( + bool, + error, + frostNativeSignerInventorySnapshot, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.calls++ + inventory := readiness.inventory + if inventory.Schema == "" { + inventory = testFrostProductionSignerInventorySnapshot() + } + return readiness.interactive, readiness.err, inventory +} + +func (readiness *testFrostProductionSignerReadiness) setInteractive( + interactive bool, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.interactive = interactive +} + +func (readiness *testFrostProductionSignerReadiness) setInventory( + inventory frostNativeSignerInventorySnapshot, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.inventory = inventory +} + +func (readiness *testFrostProductionSignerReadiness) blockUnchangedVerification( + started chan struct{}, + release <-chan struct{}, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.unchangedStarted = started + readiness.unchangedRelease = release } func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSignerReadiness( ctx context.Context, point FrostPreSignFinality, ) (*frostProductionSignerReadinessSnapshot, error) { - readiness.calls++ - if readiness.err != nil { - return nil, readiness.err + interactive, readinessErr, inventory := readiness.snapshot() + if readinessErr != nil { + return nil, readinessErr } - if !readiness.interactive { + if !interactive { return nil, fmt.Errorf("interactive signer is not ready") } journalSnapshot, err := readiness.journal.reconcile(ctx, point) @@ -68,44 +363,78 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner return nil, err } return &frostProductionSignerReadinessSnapshot{ - Journal: journalSnapshot, - Inventory: &frostNativeSignerInventorySnapshot{ - Schema: "tbtc-signer-retained-key-package-inventory/v1", - StoreFingerprint: testFrostDurableSessionStoreIdentity().Fingerprint, - StateGeneration: 7, - StateCommitment: [32]byte{0x31}, - PreviousStateCommitment: [32]byte{0x30}, - StateImageDigest: [32]byte{0x33}, - InventoryCommitment: [32]byte{0x32}, - ExternalRollbackAnchorBound: true, - TrustCertificateSequence: 3, - TrustCertificateDigest: [32]byte{0x34}, - AnchorServiceEpoch: 1, - CertifiedFloorRevision: 1, - CertifiedFloorGeneration: 1, - CurrentAnchorRevision: 1, - RestartableRevisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, - RestartableGenerationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries - - 6, - AnchorRotationWarning: false, - }, + Journal: journalSnapshot, + Inventory: &inventory, InteractiveSigningReady: true, }, nil } +func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSignerReadinessUnchanged( + ctx context.Context, + expected *frostProductionSignerReadinessSnapshot, +) error { + if ctx == nil || expected == nil || expected.Inventory == nil || + !expected.InteractiveSigningReady { + return fmt.Errorf("cached signer readiness is incomplete") + } + readiness.mutex.Lock() + unchangedStarted := readiness.unchangedStarted + unchangedRelease := readiness.unchangedRelease + readiness.mutex.Unlock() + if unchangedStarted != nil { + readiness.unchangedOnce.Do(func() { + close(unchangedStarted) + }) + } + if unchangedRelease != nil { + select { + case <-ctx.Done(): + return ctx.Err() + case <-unchangedRelease: + } + } + interactive, readinessErr, inventory := readiness.snapshot() + if readinessErr != nil { + return readinessErr + } + if !interactive { + return fmt.Errorf("interactive signer is not ready") + } + if inventory != *expected.Inventory { + return fmt.Errorf("native signer state changed since reconciliation") + } + return nil +} + +func (source *testFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( + _ FrostPreSignActivationProfile, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) error { + if runtimeManifest.CanonicalJournal.DescriptorSetHash != + source.manifest.DescriptorSetHash { + return fmt.Errorf("descriptor set mismatch") + } + return nil +} + +func (source *testFrostRetainedGroupHistorySource) FrostRetainedGroupProtocolBindingHash() ( + [32]byte, + error, +) { + return source.manifest.DescriptorSetHash, nil +} + func (source *testFrostRetainedGroupHistorySource) Identity( context.Context, ) (FrostRetainedGroupHistoryIdentity, error) { - return FrostRetainedGroupHistoryIdentity{ - TrustDomainID: source.manifest.SourceTrustDomainID, - EndpointFingerprint: source.manifest.SourceEndpointFingerprint, - OperatorFingerprint: source.manifest.SourceOperatorFingerprint, - }, nil + return source.manifest.SourceIdentity, nil } func (source *testFrostRetainedGroupHistorySource) FinalizedHead( context.Context, ) (FrostPreSignFinality, error) { + source.mutex.Lock() + defer source.mutex.Unlock() return source.target, nil } @@ -117,19 +446,73 @@ func (source *testFrostRetainedGroupHistorySource) VerifyPoint( } func (source *testFrostRetainedGroupHistorySource) ReadCompleteHistory( - _ context.Context, + ctx context.Context, from FrostPreSignFinality, to FrostPreSignFinality, + checkpointAfter FrostRetainedGroupCheckpointCursor, ) (*FrostRetainedGroupHistory, error) { + source.mutex.Lock() + source.readCalls++ + readStarted := source.readStarted + readRelease := source.readRelease + historyRoot := source.historyRoot + checkpointHead := source.checkpointHead + source.readDeadline, source.hasDeadline = ctx.Deadline() + source.mutex.Unlock() + if readStarted != nil { + source.readOnce.Do(func() { + close(readStarted) + }) + } + if readRelease != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-readRelease: + } + } return &FrostRetainedGroupHistory{ - From: from, - To: to, - Complete: true, - EmptyAtFrom: true, - DescriptorSetHash: source.manifest.DescriptorSetHash, + From: from, + To: to, + HistoryRoot: historyRoot, + CheckpointAfter: checkpointAfter, + Checkpoints: []FrostRetainedGroupCheckpointCertificate{}, + CheckpointChainRoot: frostRetainedGroupCheckpointChainRoot( + source.bindingHash, + checkpointAfter, + nil, + ), + CheckpointTipHash: checkpointHead.CertificateHash, + CheckpointComplete: true, + Complete: true, + EmptyAtFrom: true, + DescriptorSetHash: source.manifest.DescriptorSetHash, }, nil } +func (source *testFrostRetainedGroupHistorySource) readCallCount() uint64 { + source.mutex.Lock() + defer source.mutex.Unlock() + return source.readCalls +} + +func (source *testFrostRetainedGroupHistorySource) reconciliationDeadline() ( + time.Time, + bool, +) { + source.mutex.Lock() + defer source.mutex.Unlock() + return source.readDeadline, source.hasDeadline +} + +func (source *testFrostRetainedGroupHistorySource) setTarget( + target FrostPreSignFinality, +) { + source.mutex.Lock() + defer source.mutex.Unlock() + source.target = target +} + func (source *testFrostRetainedGroupHistorySource) ResolveOperatorID( context.Context, chain.Address, @@ -182,6 +565,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { verifier, testFrostDurableSessionStoreBinding(t), outbox, + journal, readiness, ) if err != nil { @@ -200,29 +584,54 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { Challenge: frostActivationChallenge{ Nonce: frostActivationHex32(nonce), ManifestHash: frostActivationHex32(manifest.ManifestHash), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, }, } response := postTestFrostActivationHandshake(t, endpoint, request) - defer response.Body.Close() - if response.StatusCode != http.StatusOK { + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { body, _ := io.ReadAll(response.Body) - t.Fatalf("unexpected status [%d]: %s", response.StatusCode, body) + response.Body.Close() + t.Fatalf( + "initial asynchronous response was [%d] Retry-After [%s]: %s", + response.StatusCode, + response.Header.Get("Retry-After"), + body, + ) } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() handshake := &frostActivationSignedHandshake{} if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { t.Fatal(err) } + verifiedPoint, verifierCalls := verifier.snapshot() if handshake.Payload.Kind != "frost-signer" || handshake.Payload.Nonce != request.Challenge.Nonce || handshake.Payload.ManifestHash != request.Challenge.ManifestHash || + handshake.Payload.BindingHash != request.Challenge.BindingHash || + handshake.Payload.State.CanonicalJournal.BindingHash != + request.Challenge.BindingHash || !handshake.Payload.State.Healthy || !handshake.Payload.State.InteractiveSigningReady || !handshake.Payload.State.NonceShareGateEnforced || !handshake.Payload.State.DurableBitcoinOutboxRecovered || - verifier.point.BlockNumber != point.BlockNumber || - frostActivationHex32(verifier.point.BlockHash) != point.BlockHash || - verifier.calls != 2 { + verifiedPoint.BlockNumber != point.BlockNumber || + frostActivationHex32(verifiedPoint.BlockHash) != point.BlockHash || + verifierCalls != 4 { t.Fatalf("unexpected handshake: %+v", handshake) } canonicalPayload, err := canonicalFrostActivationValue(handshake.Payload) @@ -237,9 +646,26 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { if err != nil || !ed25519.Verify(publicKey, signatureTranscript, signature) { t.Fatal("handshake signature did not verify over canonical payload") } + if len(handshake.Payload.State.CheckpointJournal.Ancestry) != 1 { + t.Fatalf( + "exact-head checkpoint proof contains [%d] certificates", + len(handshake.Payload.State.CheckpointJournal.Ancestry), + ) + } + if err := verifyFrostActivationCheckpointHandshakeState( + manifest, + journal.metadata.BindingHash, + request.Challenge, + handshake.Payload.State, + ); err != nil { + t.Fatalf( + "independent exact-head checkpoint proof verification failed: [%v]", + err, + ) + } assertFrostActivationObjectKeys(t, handshake.Payload.State, []string{ "authorizationRegistryAddress", "bitcoinOutboxProtocolID", "canonicalJournal", - "completeRouterAddress", "durableBitcoinOutboxRecovered", "durableSessionStoreFingerprint", + "checkpointJournal", "completeRouterAddress", "durableBitcoinOutboxRecovered", "durableSessionStoreFingerprint", "exactTransactionAuthorizationRootEnforced", "finalizedReservationReadbackEnforced", "frostWalletGroupInventory", "healthy", "maximumGroupSize", "nonceShareGateEnforced", "interactiveSigningReady", "nativeSignerState", @@ -252,13 +678,14 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "snapshotGeneration", "walletCount", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.CanonicalJournal, []string{ - "checkpoint", "clusterFingerprint", "complete", "current", "descriptorSetHash", - "generation", "sourceEndpointFingerprint", "sourceOperatorFingerprint", - "sourceTrustDomainID", "storeFingerprint", "storeID", + "bindingHash", "checkpoint", "clusterFingerprint", "complete", "current", + "descriptorSetHash", "generation", "sourceEndpointFingerprint", "sourceOperatorFingerprint", + "sourceIdentity", "sourceTrustDomainID", "storeFingerprint", "storeID", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.QuarantineJournal, []string{ - "clusterFingerprint", "complete", "currentQuarantineCount", "generation", - "protocolID", "root", "storeFingerprint", "storeID", + "activeRoot", "clusterFingerprint", "complete", "currentQuarantineCount", + "generation", "protocolID", "root", "storeFingerprint", "storeID", + "tombstoneCount", "tombstoneRoot", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.NativeSignerState, []string{ "anchorRotationWarning", "anchorServiceEpoch", "certifiedFloorGeneration", @@ -270,6 +697,467 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "stateImageDigest", "storeFingerprint", "trustCertificateDigest", "trustCertificateSequence", }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.CheckpointJournal, []string{ + "ancestry", "canonicalGeneration", "canonicalInventoryRoot", "challengeFloor", + "complete", "durableHead", "historyRoot", "manifestMinimumSequence", + "manifestPredecessorHash", "point", "quarantineActiveRoot", + "quarantineEventRoot", "quarantineGeneration", "quarantineTombstoneRoot", + }) + unknownFloor := request + unknownFloor.Challenge.CheckpointFloor.CertificateHash = + frostActivationHex32([32]byte{0xff}) + unknownResponse := postTestFrostActivationHandshake( + t, + endpoint, + unknownFloor, + ) + defer unknownResponse.Body.Close() + if unknownResponse.StatusCode != http.StatusServiceUnavailable || + unknownResponse.Header.Get("Retry-After") != "" { + t.Fatalf( + "unknown external checkpoint floor returned [%d] with retry [%s]", + unknownResponse.StatusCode, + unknownResponse.Header.Get("Retry-After"), + ) + } +} + +func TestFrostActivationHandshakeExporter_AttestsInclusiveCheckpointAncestry( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, journal, source, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + externalFloor := request.Challenge.CheckpointFloor + nextPoint := FrostPreSignFinality{ + BlockNumber: point.BlockNumber + 1, + BlockHash: [32]byte{0x45}, + } + journal.mutex.Lock() + journal.state.CurrentPoint = nextPoint + journal.quarantineState.CurrentPoint = nextPoint + var err error + journal.state.InventoryRoot, _, _, _, err = + frostRetainedGroupInventoryRoot(journal.state) + journal.mutex.Unlock() + if err != nil { + t.Fatal(err) + } + source.setTarget(nextPoint) + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: nextPoint.BlockNumber, + BlockHash: frostActivationHex32(nextPoint.BlockHash), + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) + request.Challenge.CheckpointFloor = externalFloor + + response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != + frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf( + "initial ancestry reconciliation returned [%d]", + response.StatusCode, + ) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + if len(handshake.Payload.State.CheckpointJournal.Ancestry) != 2 { + t.Fatalf( + "checkpoint proof contains [%d] certificates, expected floor and head", + len(handshake.Payload.State.CheckpointJournal.Ancestry), + ) + } + if err := verifyFrostActivationCheckpointHandshakeState( + exporter.manifest, + journal.metadata.BindingHash, + request.Challenge, + handshake.Payload.State, + ); err != nil { + t.Fatalf( + "independent descendant checkpoint proof verification failed: [%v]", + err, + ) + } + + missingFloor := handshake.Payload.State + missingFloor.CheckpointJournal.Ancestry = + append( + []frostRetainedGroupWireCheckpointCertificate{}, + missingFloor.CheckpointJournal.Ancestry[1:]..., + ) + if err := verifyFrostActivationCheckpointHandshakeState( + exporter.manifest, + journal.metadata.BindingHash, + request.Challenge, + missingFloor, + ); err == nil { + t.Fatal("checkpoint proof without its external floor was accepted") + } + wrongRoot := handshake.Payload.State + wrongRoot.CheckpointJournal.HistoryRoot = + frostActivationHex32([32]byte{0xff}) + if err := verifyFrostActivationCheckpointHandshakeState( + exporter.manifest, + journal.metadata.BindingHash, + request.Challenge, + wrongRoot, + ); err == nil { + t.Fatal("checkpoint proof with a different tail root was accepted") + } +} + +func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSigning( + t *testing.T, +) { + testCases := map[string]func(*testFrostProductionSignerReadiness){ + "native state advances": func( + readiness *testFrostProductionSignerReadiness, + ) { + inventory := testFrostProductionSignerInventorySnapshot() + inventory.StateGeneration++ + inventory.PreviousStateCommitment = inventory.StateCommitment + inventory.StateCommitment = [32]byte{0x41} + inventory.StateImageDigest = [32]byte{0x42} + inventory.InventoryCommitment = [32]byte{0x43} + inventory.RestartableGenerationHeadroom-- + readiness.setInventory(inventory) + }, + "native anchor advances": func( + readiness *testFrostProductionSignerReadiness, + ) { + inventory := testFrostProductionSignerInventorySnapshot() + inventory.CurrentAnchorRevision++ + inventory.RestartableRevisionHeadroom-- + readiness.setInventory(inventory) + }, + "interactive readiness changes": func( + readiness *testFrostProductionSignerReadiness, + ) { + readiness.setInteractive(false) + }, + } + + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, _, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + readiness, ok := + exporter.readiness.(*testFrostProductionSignerReadiness) + if !ok { + t.Fatal("unexpected production signer readiness verifier") + } + + response := postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + + mutate(readiness) + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != + frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf( + "obsolete cached signer state returned status [%d] with retry [%s]", + response.StatusCode, + response.Header.Get("Retry-After"), + ) + } + response.Body.Close() + + readiness.setInteractive(true) + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + if !handshake.Payload.State.Healthy { + t.Fatal("fresh stable signer state did not recover healthy attestation") + } + if name == "native state advances" && + (handshake.Payload.State.NativeSignerState.StateGeneration != 8 || + handshake.Payload.State.NativeSignerState.StateCommitment != + frostActivationHex32([32]byte{0x41})) { + t.Fatalf( + "reconciled attestation did not use the current native state: %+v", + handshake.Payload.State.NativeSignerState, + ) + } + if name == "native anchor advances" && + (handshake.Payload.State.NativeSignerState.CurrentAnchorRevision != 2 || + handshake.Payload.State.NativeSignerState.RestartableRevisionHeadroom != + FrostNativeSignerAnchorMaximumHistoryEvents-1) { + t.Fatalf( + "reconciled attestation did not use the current native anchor: %+v", + handshake.Payload.State.NativeSignerState, + ) + } + }) + } +} + +func TestFrostActivationHandshakeExporter_RevalidatesOutboxStateBeforeSigning( + t *testing.T, +) { + testCases := map[string]func(*bitcoinBroadcastOutbox){ + "quarantine": func(outbox *bitcoinBroadcastOutbox) { + transactionHash := bitcoin.Hash{0x91} + outbox.records[transactionHash] = &bitcoinBroadcastOutboxRecord{ + TransactionHash: transactionHash, + Authorization: bitcoinBroadcastAuthorization{ + ReservationID: [32]byte{0x92}, + }, + Quarantine: &bitcoinBroadcastQuarantine{}, + } + }, + "ambiguous reservation": func(outbox *bitcoinBroadcastOutbox) { + reservationID := [32]byte{0xa1} + for _, transactionHash := range []bitcoin.Hash{ + {0xa2}, + {0xa3}, + } { + outbox.records[transactionHash] = &bitcoinBroadcastOutboxRecord{ + TransactionHash: transactionHash, + Authorization: bitcoinBroadcastAuthorization{ + ReservationID: reservationID, + }, + Confirmation: &bitcoinBroadcastConfirmation{ + Canonical: true, + }, + } + } + }, + } + + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, _, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + readiness, ok := + exporter.readiness.(*testFrostProductionSignerReadiness) + if !ok { + t.Fatal("unexpected production signer readiness verifier") + } + + response := postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + + unchangedStarted := make(chan struct{}) + unchangedRelease := make(chan struct{}) + readiness.blockUnchangedVerification( + unchangedStarted, + unchangedRelease, + ) + type attestationResult struct { + handshake *frostActivationSignedHandshake + err error + } + result := make(chan attestationResult, 1) + go func() { + handshake, err := exporter.attest( + context.Background(), + &request, + ) + result <- attestationResult{handshake: handshake, err: err} + }() + + select { + case <-unchangedStarted: + case <-time.After(time.Second): + t.Fatal("signing-boundary readiness verification did not start") + } + exporter.outbox.mutex.Lock() + mutate(exporter.outbox) + exporter.outbox.mutex.Unlock() + close(unchangedRelease) + + var obsolete attestationResult + select { + case obsolete = <-result: + case <-time.After(time.Second): + t.Fatal("activation attestation did not complete") + } + if obsolete.err == nil || obsolete.handshake != nil { + t.Fatal("activation signed state from an obsolete outbox snapshot") + } + if !strings.Contains( + obsolete.err.Error(), + "outbox activation state changed before signing", + ) { + t.Fatalf("unexpected obsolete outbox error: [%v]", obsolete.err) + } + + exporter.outbox.mutex.Lock() + exporter.outbox.records = + make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord) + exporter.outbox.mutex.Unlock() + handshake, err := exporter.attest(context.Background(), &request) + if err != nil { + t.Fatalf("healthy outbox did not recover signing: [%v]", err) + } + if handshake == nil || !handshake.Payload.State.Healthy { + t.Fatal("healthy stable outbox did not produce a healthy attestation") + } + }) + } +} + +func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + _, journal, source, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + raisedRecord := FrostRetainedGroupQuarantineRaisedRecord{ + QuarantineID: [32]byte{0x51}, + WalletID: [32]byte{0x52}, + EvidenceHash: [32]byte{0x53}, + Reason: "resolved quarantine", + RecoveryRequired: true, + RaisedAt: FrostRetainedGroupEventPoint{ + BlockNumber: 100, + BlockHash: [32]byte{0x64}, + TransactionHash: [32]byte{0xa1}, + TransactionIndex: 1, + LogIndex: 1, + }, + } + liftedAt := FrostRetainedGroupEventPoint{ + BlockNumber: 120, + BlockHash: [32]byte{0x78}, + TransactionHash: [32]byte{0xa2}, + TransactionIndex: 1, + LogIndex: 1, + } + certificateHash := [32]byte{0x54} + quarantine := frostRetainedGroupQuarantineState{ + RaisedRecord: raisedRecord, + Status: frostRetainedGroupQuarantineLifted, + LiftCertificateHash: certificateHash, + LiftedAt: liftedAt, + } + tombstone := frostRetainedGroupQuarantineTombstone{ + QuarantineID: raisedRecord.QuarantineID, + WalletID: raisedRecord.WalletID, + LiftCertificateHash: certificateHash, + LiftedAt: liftedAt, + ResolutionEvidenceHash: [32]byte{0x55}, + ResolutionFinality: FrostPreSignFinality{ + BlockNumber: 110, + BlockHash: [32]byte{0x6e}, + }, + } + journal.quarantineState.Generation = 2 + journal.quarantineState.Quarantines = + []frostRetainedGroupQuarantineState{quarantine} + journal.quarantineState.Tombstones = + []frostRetainedGroupQuarantineTombstone{tombstone} + var err error + journal.quarantineState.ActiveRoot, err = + frostRetainedGroupQuarantineActiveRoot( + journal.metadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{ + raisedRecord.QuarantineID: quarantine, + }, + ) + if err != nil { + t.Fatal(err) + } + journal.quarantineState.TombstoneRoot, err = + frostRetainedGroupQuarantineTombstoneRoot( + journal.metadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{ + raisedRecord.QuarantineID: tombstone, + }, + ) + if err != nil { + t.Fatal(err) + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointPolicy.MinimumSequence, + ) + + response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != + frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("initial tombstone reconciliation returned [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + attestation := handshake.Payload.State.QuarantineJournal + if attestation.CurrentQuarantineCount != 0 || + attestation.TombstoneCount != 1 || + attestation.TombstoneRoot != + frostActivationHex32(journal.quarantineState.TombstoneRoot) { + t.Fatalf("tombstoned ready state was not attested: %+v", attestation) + } } func assertFrostActivationObjectKeys( @@ -342,6 +1230,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { &testFrostActivationPointVerifier{}, testFrostDurableSessionStoreBinding(t), outbox, + journal, readiness, ) if err != nil { @@ -359,20 +1248,76 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { Challenge: frostActivationChallenge{ Nonce: frostActivationHex32([32]byte{0x22}), ManifestHash: frostActivationHex32(manifest.ManifestHash), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, }, } response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf( + "initial reconciliation did not return retryable service unavailable", + ) + } + response.Body.Close() + awaitTestFrostActivationReconciliation( + t, + exporter, + FrostPreSignFinality{ + BlockNumber: point.BlockNumber, + BlockHash: [32]byte{0x11}, + }, + ) + response = postTestFrostActivationHandshake(t, endpoint, request) defer response.Body.Close() if response.StatusCode != http.StatusServiceUnavailable { t.Fatalf("quarantined outbox returned status [%d]", response.StatusCode) } + if response.Header.Get("Retry-After") != "" { + t.Fatal("non-reconciliation readiness failure advertised a retry interval") + } response.Body.Close() outbox.mutex.Lock() outbox.records = make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord) outbox.mutex.Unlock() - readiness.interactive = false + readiness.setInteractive(false) + journal.mutex.Lock() + journal.state.SnapshotGeneration++ + var rootErr error + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) + journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + response = postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + settleDeadline := time.Now().Add(2 * time.Second) + for { + exporter.reconciliationMutex.Lock() + idle := exporter.reconciliationDesired == nil && + exporter.reconciliationActive == nil + cached := exporter.reconciliationCompleted != nil + exporter.reconciliationMutex.Unlock() + if idle { + if cached { + t.Fatal("unready interactive signer cached reconciliation state") + } + break + } + if time.Now().After(settleDeadline) { + t.Fatal("background reconciliation did not settle") + } + time.Sleep(5 * time.Millisecond) + } response = postTestFrostActivationHandshake(t, endpoint, request) defer response.Body.Close() if response.StatusCode != http.StatusServiceUnavailable { @@ -380,6 +1325,320 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { } } +func TestFrostActivationHandshakeExporter_RejectsUnboundOrLegacyChallenge( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + _, _, source, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + + testCases := map[string]func(*frostActivationHandshakeRequest){ + "legacy v1 schema": func(request *frostActivationHandshakeRequest) { + request.Schema = "tbtc-p2tr-production-activation-handshake/v1" + }, + "legacy v2 schema": func(request *frostActivationHandshakeRequest) { + request.Schema = "tbtc-p2tr-production-activation-handshake/v2" + }, + "different binding": func(request *frostActivationHandshakeRequest) { + request.Challenge.BindingHash = frostActivationHex32([32]byte{0xff}) + }, + "missing checkpoint floor": func(request *frostActivationHandshakeRequest) { + request.Challenge.CheckpointFloor = + frostRetainedGroupWireCheckpointCursor{} + }, + "uncertified manifest predecessor": func( + request *frostActivationHandshakeRequest, + ) { + request.Challenge.CheckpointFloor = + frostRetainedGroupWireCheckpointCursor{ + Sequence: 0, + CertificateHash: frostActivationHex32([32]byte{}), + } + }, + } + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + candidate := request + mutate(&candidate) + response := postTestFrostActivationHandshake(t, endpoint, candidate) + defer response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("unbound challenge returned [%d]", response.StatusCode) + } + if response.Header.Get("Retry-After") != "" { + t.Fatal("invalid transcript was treated as pending reconciliation") + } + }) + } + if source.readCallCount() != 0 { + t.Fatal("invalid transcript started retained-history reconciliation") + } +} + +func TestFrostActivationHandshakeExporter_ReconciliationIsAsynchronousAndGenerationBound( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + _, journal, source, verifier, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + release := make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + source.mutex.Lock() + source.readStarted = make(chan struct{}) + readStarted := source.readStarted + source.readRelease = release + source.mutex.Unlock() + + startedAt := time.Now() + response := postTestFrostActivationHandshake(t, endpoint, request) + elapsed := time.Since(startedAt) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("initial reconciliation response was not retryable: [%d]", response.StatusCode) + } + response.Body.Close() + if elapsed >= time.Second { + t.Fatalf("reconciliation held the HTTP request for [%v]", elapsed) + } + select { + case <-readStarted: + case <-time.After(time.Second): + t.Fatal("background reconciliation did not start") + } + reconciliationDeadline, hasDeadline := source.reconciliationDeadline() + remaining := time.Until(reconciliationDeadline) + if !hasDeadline || remaining <= 0 || + remaining > frostActivationHandshakeReconciliationTimeout { + t.Fatalf( + "background reconciliation deadline is not bounded: [%v] [%v]", + hasDeadline, + remaining, + ) + } + + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("in-flight reconciliation response was not retryable: [%d]", response.StatusCode) + } + response.Body.Close() + if source.readCallCount() != 1 { + t.Fatalf( + "same-point requests started [%d] reconciliations", + source.readCallCount(), + ) + } + + close(release) + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + if source.readCallCount() != 1 { + t.Fatalf("completed exact-point cache was not reused") + } + + journal.mutex.Lock() + response = postTestFrostActivationHandshake(t, endpoint, request) + journal.mutex.Unlock() + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("busy live-state check was not retryable: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + if source.readCallCount() != 1 { + t.Fatal("a transient live-state lock invalidated the completed cache") + } + + journal.mutex.Lock() + journal.state.SnapshotGeneration++ + canonicalGeneration := journal.state.SnapshotGeneration + canonicalPoint := FrostPreSignFinality{ + BlockNumber: point.BlockNumber + 1, + BlockHash: [32]byte{0x45}, + } + journal.state.CurrentPoint = canonicalPoint + journal.quarantineState.CurrentPoint = canonicalPoint + var rootErr error + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) + journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + source.setTarget(canonicalPoint) + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: canonicalPoint.BlockNumber, + BlockHash: frostActivationHex32(canonicalPoint.BlockHash), + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("canonical generation drift reused stale cache: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if handshake.Payload.State.CanonicalJournal.Generation != canonicalGeneration { + t.Fatalf( + "reconciled generation is [%d], expected [%d]", + handshake.Payload.State.CanonicalJournal.Generation, + canonicalGeneration, + ) + } + + journal.mutex.Lock() + journal.quarantineState.Generation++ + quarantineGeneration := journal.quarantineState.Generation + quarantinePoint := FrostPreSignFinality{ + BlockNumber: canonicalPoint.BlockNumber + 1, + BlockHash: [32]byte{0x46}, + } + journal.state.CurrentPoint = quarantinePoint + journal.quarantineState.CurrentPoint = quarantinePoint + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) + journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + source.setTarget(quarantinePoint) + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: quarantinePoint.BlockNumber, + BlockHash: frostActivationHex32(quarantinePoint.BlockHash), + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("quarantine generation drift reused stale cache: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + handshake = &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if handshake.Payload.State.QuarantineJournal.Generation != quarantineGeneration { + t.Fatalf( + "reconciled quarantine generation is [%d], expected [%d]", + handshake.Payload.State.QuarantineJournal.Generation, + quarantineGeneration, + ) + } + + nextPoint := FrostPreSignFinality{ + BlockNumber: quarantinePoint.BlockNumber + 1, + BlockHash: [32]byte{0x47}, + } + source.setTarget(nextPoint) + journal.mutex.Lock() + journal.state.CurrentPoint = nextPoint + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) + journal.quarantineState.CurrentPoint = nextPoint + journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: nextPoint.BlockNumber, + BlockHash: frostActivationHex32(nextPoint.BlockHash), + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("different finality point reused stale cache: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + if source.readCallCount() != 4 { + t.Fatalf( + "expected one reconciliation per cache invalidation, got [%d]", + source.readCallCount(), + ) + } + + verifier.setError(fmt.Errorf("exact point no longer canonical")) + response = postTestFrostActivationHandshake(t, endpoint, request) + defer response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + t.Fatalf("failed quick point check signed cached state: [%d]", response.StatusCode) + } +} + func TestCanonicalFrostActivationValue_MatchesRuntimeOrdering(t *testing.T) { value := map[string]interface{}{ "state": map[string]interface{}{"healthy": true, "count": 3}, @@ -396,11 +1655,74 @@ func TestCanonicalFrostActivationValue_MatchesRuntimeOrdering(t *testing.T) { } } +func TestDecodeStrictFrostActivationJSON_RejectsAmbiguousObjectKeys( + t *testing.T, +) { + type nested struct { + Count uint64 `json:"count"` + } + type payload struct { + Schema string `json:"schema"` + Nested nested `json:"nested"` + } + testCases := map[string]string{ + "duplicate exact key": `{"schema":"v1","schema":"v2","nested":{"count":1}}`, + "case-insensitive field alias": `{"Schema":"v1","nested":{"count":1}}`, + "case-fold-equivalent keys": `{"schema":"v1","SCHEMA":"v2","nested":{"count":1}}`, + "nested duplicate key": `{"schema":"v1","nested":{"count":1,"count":2}}`, + "nested field alias": `{"schema":"v1","nested":{"Count":1}}`, + "non-ASCII field alias": `{"Å¿chema":"v1","nested":{"count":1}}`, + } + for name, encoded := range testCases { + t.Run(name, func(t *testing.T) { + target := payload{} + if err := decodeStrictFrostActivationJSON( + []byte(encoded), + &target, + ); err == nil { + t.Fatal("expected ambiguous JSON to be rejected") + } + }) + } + + target := payload{} + if err := decodeStrictFrostActivationJSON( + []byte(`{"schema":"v1","nested":{"count":1}}`), + &target, + ); err != nil { + t.Fatalf("expected exact JSON keys to remain valid: [%v]", err) + } + if target.Schema != "v1" || target.Nested.Count != 1 { + t.Fatalf("unexpected exact JSON decode: [%+v]", target) + } +} + +func TestCanonicalFrostActivationValue_RejectsDuplicateRawMessageKeys( + t *testing.T, +) { + _, err := canonicalFrostActivationValue( + json.RawMessage(`{"schema":"v1","schema":"v2"}`), + ) + if err == nil || !strings.Contains(err.Error(), "duplicate key") { + t.Fatalf("expected duplicate raw-message key rejection, got [%v]", err) + } +} + func testFrostActivationRuntimeManifest( keyHash [32]byte, ) FrostPreSignActivationRuntimeManifest { + checkpointAuthorities, _, _ := + testFrostActivationCheckpointCredentials() + sourceIdentity := testFrostRetainedGroupCompleteIdentity() return FrostPreSignActivationRuntimeManifest{ ManifestHash: [32]byte{0x10}, + ActivationAuthorityKeyHash: [32]byte{0x30}, + VerifierOperatorFingerprint: [32]byte{0x31}, + HandshakeOperatorFingerprint: [32]byte{0x37}, + DomainChainID: [32]byte{31: 0x01}, + GenesisBlockHash: [32]byte{0x32}, + ProfileHash: [32]byte{0x33}, + ImplementationSetHash: [32]byte{0x34}, SignerProtocolID: [32]byte{0x11}, ReservationProtocolID: [32]byte{0x12}, BitcoinOutboxProtocolID: [32]byte{0x13}, @@ -421,13 +1743,26 @@ func testFrostActivationRuntimeManifest( BlockHash: [32]byte{0x20}, }, DescriptorSetHash: [32]byte{0x21}, - SourceTrustDomainID: "independent-journal-source", - SourceEndpointFingerprint: [32]byte{0x22}, - SourceOperatorFingerprint: [32]byte{0x23}, + SourceTrustDomainID: sourceIdentity.TrustDomainID, + SourceEndpointFingerprint: sourceIdentity.EndpointFingerprint, + SourceOperatorFingerprint: sourceIdentity.OperatorFingerprint, + SourceIdentity: sourceIdentity, MinimumGeneration: 1, }, QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ - ProtocolID: [32]byte{0x25}, + ProtocolID: [32]byte{0x25}, + LiftProtocolID: [32]byte{0x35}, + TombstoneProtocolID: [32]byte{0x36}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, + LiftAuthorityThreshold: 2, + LiftAuthorities: []FrostRetainedGroupAuthority{ + {AuthorityID: "lift-1", PublicKeySPKIHash: [32]byte{0x43}}, + {AuthorityID: "lift-2", PublicKeySPKIHash: [32]byte{0x44}}, + {AuthorityID: "lift-3", PublicKeySPKIHash: [32]byte{0x45}}, + }, StoreID: "quarantine-store-id", StoreFingerprint: [32]byte{0x26}, ClusterFingerprint: [32]byte{0x27}, @@ -436,6 +1771,200 @@ func testFrostActivationRuntimeManifest( } } +func testFrostActivationCheckpointCredentials() ( + []FrostRetainedGroupAuthority, + []ed25519.PrivateKey, + []string, +) { + authorities := make([]FrostRetainedGroupAuthority, 3) + privateKeys := make([]ed25519.PrivateKey, 3) + publicKeySPKIs := make([]string, 3) + for index := range authorities { + seed := make([]byte, ed25519.SeedSize) + seed[0] = byte(0x70 + index) + privateKey := ed25519.NewKeyFromSeed(seed) + publicKeyDER, err := x509.MarshalPKIXPublicKey( + privateKey.Public(), + ) + if err != nil { + panic(err) + } + authorities[index] = FrostRetainedGroupAuthority{ + AuthorityID: fmt.Sprintf( + "checkpoint-%d", + index+1, + ), + PublicKeySPKIHash: sha256.Sum256(publicKeyDER), + } + privateKeys[index] = privateKey + publicKeySPKIs[index] = + base64.StdEncoding.EncodeToString(publicKeyDER) + } + return authorities, privateKeys, publicKeySPKIs +} + +func testFrostActivationCheckpointCertificate( + t *testing.T, + policy frostRetainedGroupCheckpointPolicy, + sequence uint64, + previousHash [32]byte, + commitment FrostRetainedGroupCheckpointCommitment, +) (FrostRetainedGroupCheckpointCertificate, [32]byte) { + t.Helper() + body := FrostRetainedGroupCheckpointBody{ + Schema: frostRetainedGroupCheckpointBodySchema, + ProtocolBindingHash: policy.ProtocolBindingHash, + ManifestHash: policy.ManifestHash, + ProfileHash: policy.ProfileHash, + ImplementationSetHash: policy.ImplementationSetHash, + ChainID: policy.ChainID, + DomainChainID: policy.DomainChainID, + GenesisBlockHash: policy.GenesisBlockHash, + AuthoritySetHash: policy.AuthoritySetHash, + Sequence: sequence, + PreviousCertificateHash: previousHash, + Point: commitment.Point, + HistoryRoot: commitment.HistoryRoot, + CanonicalGeneration: commitment.CanonicalGeneration, + CanonicalInventoryRoot: commitment.CanonicalInventoryRoot, + QuarantineGeneration: commitment.QuarantineGeneration, + QuarantineEventRoot: commitment.QuarantineEventRoot, + QuarantineActiveRoot: commitment.QuarantineActiveRoot, + QuarantineTombstoneRoot: commitment.QuarantineTombstoneRoot, + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(body) + if err != nil { + t.Fatal(err) + } + authorities, privateKeys, publicKeySPKIs := + testFrostActivationCheckpointCredentials() + if len(authorities) != len(policy.Authorities) { + t.Fatal("test checkpoint authority count differs from policy") + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupCheckpointSignature, + policy.AuthorityThreshold, + ) + for index := range signatures { + if authorities[index] != policy.Authorities[index] { + t.Fatal("test checkpoint authority differs from policy") + } + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: authorities[index].AuthorityID, + SignerPublicKeySPKI: publicKeySPKIs[index], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign( + privateKeys[index], + signatureHash[:], + ), + ), + } + } + certificate := FrostRetainedGroupCheckpointCertificate{ + Schema: frostRetainedGroupCheckpointCertificateSchema, + Body: body, + BodyHash: bodyHash, + Signatures: signatures, + } + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + t.Fatal(err) + } + return certificate, certificateHash +} + +func startTestFrostActivationHandshakeExporter( + t *testing.T, + point frostActivationEthereumPoint, +) ( + *frostActivationHandshakeExporter, + *frostRetainedGroupJournal, + *testFrostRetainedGroupHistorySource, + *testFrostActivationPointVerifier, + string, + frostActivationHandshakeRequest, +) { + t.Helper() + directory := t.TempDir() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + privateKeyPath := filepath.Join(directory, "attestation-key.pem") + if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyDER, + }), 0600); err != nil { + t.Fatal(err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + manifest := testFrostActivationRuntimeManifest(sha256.Sum256(publicKeyDER)) + journal := testFrostRetainedGroupJournal(t, manifest, point) + source, ok := journal.source.(*testFrostRetainedGroupHistorySource) + if !ok { + t.Fatal("unexpected retained-group history source") + } + endpoint := testLoopbackEndpoint(t) + verifier := &testFrostActivationPointVerifier{} + outbox := &bitcoinBroadcastOutbox{ + records: make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord), + recovered: true, + } + readiness := &testFrostProductionSignerReadiness{ + journal: journal, + interactive: true, + } + exporter, err := newFrostActivationHandshakeExporter( + endpoint, + privateKeyPath, + manifest, + verifier, + testFrostDurableSessionStoreBinding(t), + outbox, + journal, + readiness, + ) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := exporter.start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + _ = exporter.close() + }) + return exporter, journal, source, verifier, endpoint, frostActivationHandshakeRequest{ + Schema: frostActivationHandshakeSchema, + Challenge: frostActivationChallenge{ + Nonce: frostActivationHex32([32]byte{0x77}), + ManifestHash: frostActivationHex32(manifest.ManifestHash), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), + EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, + }, + } +} + func testFrostRetainedGroupJournal( t *testing.T, manifest FrostPreSignActivationRuntimeManifest, @@ -447,23 +1976,90 @@ func testFrostRetainedGroupJournal( t.Fatal(err) } target := FrostPreSignFinality{BlockNumber: point.BlockNumber, BlockHash: blockHash} + bindingHash := [32]byte{0x28} state := frostRetainedGroupJournalState{ Schema: frostRetainedGroupJournalStateSchema, + BindingHash: bindingHash, CurrentPoint: target, SnapshotGeneration: 9, Wallets: []frostRetainedGroupWalletState{}, } quarantineRoot := sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)) + liftPolicy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + manifest, + ) + if err != nil { + t.Fatal(err) + } + activeRoot, err := frostRetainedGroupQuarantineActiveRoot( + bindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + t.Fatal(err) + } + tombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + bindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + t.Fatal(err) + } state.InventoryRoot, _, _, _, err = frostRetainedGroupInventoryRoot(state) if err != nil { t.Fatal(err) } + checkpointPolicy, err := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + manifest, + ) + if err != nil { + t.Fatal(err) + } + historyRoot, err := frostRetainedGroupTestHistoryRoot( + bindingHash, + manifest.CanonicalJournal.Checkpoint, + target, + nil, + ) + if err != nil { + t.Fatal(err) + } + checkpointCertificate, checkpointHash := + testFrostActivationCheckpointCertificate( + t, + checkpointPolicy, + manifest.QuarantineJournal.CheckpointMinimumSequence, + manifest.QuarantineJournal.CheckpointPredecessorHash, + FrostRetainedGroupCheckpointCommitment{ + Point: target, + HistoryRoot: historyRoot, + CanonicalGeneration: state.SnapshotGeneration, + CanonicalInventoryRoot: state.InventoryRoot, + QuarantineGeneration: 0, + QuarantineEventRoot: quarantineRoot, + QuarantineActiveRoot: activeRoot, + QuarantineTombstoneRoot: tombstoneRoot, + }, + ) + checkpointHead := FrostRetainedGroupCheckpointCursor{ + Sequence: manifest.QuarantineJournal.CheckpointMinimumSequence, + CertificateHash: checkpointHash, + } source := &testFrostRetainedGroupHistorySource{ - manifest: manifest.CanonicalJournal, - target: target, + manifest: manifest.CanonicalJournal, + bindingHash: bindingHash, + checkpointHead: checkpointHead, + historyRoot: historyRoot, + target: target, } return &frostRetainedGroupJournal{ metadata: frostRetainedGroupJournalMetadata{ + Schema: frostRetainedGroupJournalMetadataSchema, + ManifestHash: manifest.ManifestHash, + BindingHash: bindingHash, StoreID: manifest.CanonicalJournal.StoreID, StoreFingerprint: manifest.CanonicalJournal.StoreFingerprint, ClusterFingerprint: manifest.CanonicalJournal.ClusterFingerprint, @@ -472,11 +2068,21 @@ func testFrostRetainedGroupJournal( SourceTrustDomainID: manifest.CanonicalJournal.SourceTrustDomainID, SourceEndpointFingerprint: manifest.CanonicalJournal.SourceEndpointFingerprint, SourceOperatorFingerprint: manifest.CanonicalJournal.SourceOperatorFingerprint, + SourceIdentity: manifest.CanonicalJournal.SourceIdentity, }, quarantineMetadata: frostRetainedGroupQuarantineMetadata{ - Schema: frostRetainedGroupQuarantineMetadataSchema, - ManifestHash: manifest.ManifestHash, - ProtocolID: manifest.QuarantineJournal.ProtocolID, + Schema: frostRetainedGroupQuarantineMetadataSchema, + ManifestHash: manifest.ManifestHash, + BindingHash: bindingHash, + ProtocolID: manifest.QuarantineJournal.ProtocolID, + LiftProtocolID: manifest.QuarantineJournal.LiftProtocolID, + TombstoneProtocolID: manifest.QuarantineJournal.TombstoneProtocolID, + LiftAuthoritySetHash: liftPolicy.AuthoritySetHash, + LiftAuthorityThreshold: liftPolicy.AuthorityThreshold, + LiftAuthorities: append( + []FrostRetainedGroupAuthority{}, + liftPolicy.Authorities..., + ), StoreID: manifest.QuarantineJournal.StoreID, StoreFingerprint: manifest.QuarantineJournal.StoreFingerprint, ClusterFingerprint: manifest.QuarantineJournal.ClusterFingerprint, @@ -488,15 +2094,124 @@ func testFrostRetainedGroupJournal( walletRegistry: &walletRegistry{walletCache: make(map[string]*walletCacheValue)}, operatorAddress: chain.Address("0x01"), state: state, + liftPolicy: liftPolicy, + liftCertificates: make(map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate), + checkpointPolicy: checkpointPolicy, + checkpointState: frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: bindingHash, + Sequence: checkpointHead.Sequence, + CertificateHash: checkpointHead.CertificateHash, + Point: target, + HistoryRoot: historyRoot, + CanonicalGeneration: state.SnapshotGeneration, + CanonicalInventoryRoot: state.InventoryRoot, + QuarantineGeneration: 0, + QuarantineEventRoot: quarantineRoot, + QuarantineActiveRoot: activeRoot, + QuarantineTombstoneRoot: tombstoneRoot, + }, + checkpointCertificates: map[uint64]FrostRetainedGroupCheckpointCertificate{ + checkpointHead.Sequence: checkpointCertificate, + }, + checkpointHashes: map[uint64][32]byte{ + checkpointHead.Sequence: checkpointHead.CertificateHash, + }, quarantineState: frostRetainedGroupQuarantineJournalState{ - Schema: frostRetainedGroupQuarantineStateSchema, - CurrentPoint: target, - Root: quarantineRoot, - Quarantines: []frostRetainedGroupQuarantineState{}, + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: bindingHash, + CurrentPoint: target, + Root: quarantineRoot, + ActiveRoot: activeRoot, + TombstoneRoot: tombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, }, } } +func recertifyTestFrostActivationJournal( + t *testing.T, + journal *frostRetainedGroupJournal, + source *testFrostRetainedGroupHistorySource, + request *frostActivationHandshakeRequest, + sequence uint64, +) { + t.Helper() + journal.mutex.Lock() + historyRoot, err := frostRetainedGroupTestHistoryRoot( + journal.metadata.BindingHash, + journal.metadata.Checkpoint, + journal.state.CurrentPoint, + nil, + ) + if err != nil { + journal.mutex.Unlock() + t.Fatal(err) + } + commitment := FrostRetainedGroupCheckpointCommitment{ + Point: journal.state.CurrentPoint, + HistoryRoot: historyRoot, + CanonicalGeneration: journal.state.SnapshotGeneration, + CanonicalInventoryRoot: journal.state.InventoryRoot, + QuarantineGeneration: journal.quarantineState.Generation, + QuarantineEventRoot: journal.quarantineState.Root, + QuarantineActiveRoot: journal.quarantineState.ActiveRoot, + QuarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, + } + previousHash := journal.checkpointPolicy.PredecessorHash + if sequence > journal.checkpointPolicy.MinimumSequence { + var exists bool + previousHash, exists = + journal.checkpointHashes[sequence-1] + if !exists { + journal.mutex.Unlock() + t.Fatalf( + "test checkpoint predecessor [%d] is missing", + sequence-1, + ) + } + } + certificate, certificateHash := + testFrostActivationCheckpointCertificate( + t, + journal.checkpointPolicy, + sequence, + previousHash, + commitment, + ) + journal.checkpointState = frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: journal.metadata.BindingHash, + Sequence: sequence, + CertificateHash: certificateHash, + Point: journal.state.CurrentPoint, + HistoryRoot: historyRoot, + CanonicalGeneration: journal.state.SnapshotGeneration, + CanonicalInventoryRoot: journal.state.InventoryRoot, + QuarantineGeneration: journal.quarantineState.Generation, + QuarantineEventRoot: journal.quarantineState.Root, + QuarantineActiveRoot: journal.quarantineState.ActiveRoot, + QuarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, + } + journal.checkpointCertificates[sequence] = certificate + journal.checkpointHashes[sequence] = certificateHash + journal.mutex.Unlock() + + source.mutex.Lock() + source.historyRoot = historyRoot + source.checkpointHead = FrostRetainedGroupCheckpointCursor{ + Sequence: sequence, + CertificateHash: certificateHash, + } + source.mutex.Unlock() + request.Challenge.CheckpointFloor = + frostRetainedGroupWireCheckpointCursor{ + Sequence: sequence, + CertificateHash: frostActivationHex32(certificateHash), + } +} + func testLoopbackEndpoint(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -532,3 +2247,46 @@ func postTestFrostActivationHandshake( } return response } + +func awaitTestFrostActivationHandshake( + t *testing.T, + endpoint string, + request frostActivationHandshakeRequest, + status int, +) *http.Response { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode == status { + return response + } + body, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable || + time.Now().After(deadline) { + t.Fatalf( + "handshake did not reach status [%d]; last status [%d]: %s", + status, + response.StatusCode, + body, + ) + } + time.Sleep(10 * time.Millisecond) + } +} + +func awaitTestFrostActivationReconciliation( + t *testing.T, + exporter *frostActivationHandshakeExporter, + point FrostPreSignFinality, +) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for exporter.cachedReconciliation(point) == nil { + if time.Now().After(deadline) { + t.Fatal("activation reconciliation did not complete") + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index 894e16f140..2d463d6cf0 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -310,6 +310,9 @@ type frostProductionSignerReadinessSnapshot struct { Journal *frostRetainedGroupJournalSnapshot Inventory *frostNativeSignerInventorySnapshot InteractiveSigningReady bool + + inventoryExpectations []frostNativeSignerInventoryExpectation + registryRevision uint64 } type frostProductionSignerReadinessVerifier interface { @@ -357,22 +360,13 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead if err != nil { return nil, err } - firstInventory, err := readiness.inventoryBinding.verify(ctx, expected) - if err != nil { - return nil, err - } - secondInventory, err := readiness.inventoryBinding.verify(ctx, expected) + inventory, err := readiness.verifyStableFrostProductionSignerInventory( + ctx, + expected, + ) if err != nil { return nil, err } - if *firstInventory != *secondInventory { - return nil, fmt.Errorf("native signer state changed during readiness reconciliation") - } - if err := validateFrostNativeSignerAnchorReadinessHeadroom( - secondInventory, - ); err != nil { - return nil, err - } if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( registryRevision, ) { @@ -385,11 +379,85 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead } return &frostProductionSignerReadinessSnapshot{ Journal: journalSnapshot, - Inventory: secondInventory, + Inventory: inventory, InteractiveSigningReady: true, + inventoryExpectations: expected, + registryRevision: registryRevision, }, nil } +func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerReadinessUnchanged( + ctx context.Context, + expected *frostProductionSignerReadinessSnapshot, +) error { + if readiness == nil || readiness.interactiveSigningReady == nil || + readiness.journal == nil || readiness.inventoryBinding == nil || + ctx == nil || expected == nil || expected.Inventory == nil || + !expected.InteractiveSigningReady { + return fmt.Errorf("cached FROST production signer readiness is incomplete") + } + if !readiness.interactiveSigningReady() { + return fmt.Errorf("interactive FROST signing engine is not ready") + } + if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( + expected.registryRevision, + ) { + return fmt.Errorf( + "local FROST signer registry changed since readiness reconciliation", + ) + } + inventory, err := readiness.verifyStableFrostProductionSignerInventory( + ctx, + expected.inventoryExpectations, + ) + if err != nil { + return err + } + if *inventory != *expected.Inventory { + return fmt.Errorf( + "native signer state changed since readiness reconciliation", + ) + } + if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( + expected.registryRevision, + ) { + return fmt.Errorf( + "local FROST signer registry changed during readiness revalidation", + ) + } + if !readiness.interactiveSigningReady() { + return fmt.Errorf( + "interactive FROST signing engine became unavailable during readiness revalidation", + ) + } + return nil +} + +func (readiness *frostProductionSignerReadiness) verifyStableFrostProductionSignerInventory( + ctx context.Context, + expected []frostNativeSignerInventoryExpectation, +) (*frostNativeSignerInventorySnapshot, error) { + firstInventory, err := readiness.inventoryBinding.verify(ctx, expected) + if err != nil { + return nil, err + } + secondInventory, err := readiness.inventoryBinding.verify(ctx, expected) + if err != nil { + return nil, err + } + if *firstInventory != *secondInventory { + return nil, fmt.Errorf( + "native signer state changed during readiness verification", + ) + } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + secondInventory, + ); err != nil { + return nil, err + } + return secondInventory, nil +} + func validateFrostNativeSignerAnchorReadinessHeadroom( inventory *frostNativeSignerInventorySnapshot, ) error { diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index fc4a3bd3d4..07c2e4cd13 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1283,6 +1283,7 @@ type FrostPreSignAuthorizationConfigurator interface { string, string, string, + FrostPreSignEthereumEvidenceVerifier, ) (*FrostPreSignActivationProfile, error) } @@ -1448,11 +1449,15 @@ 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 @@ -2066,6 +2071,12 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) collectSeatAttestations( if !tfpsag.membershipValidator.IsValidMembership(seat, message.SenderPublicKey()) { return } + // The wallet broadcast topic is reused across proposals. Reject stale + // (including replayed) attestations before claiming the authenticated + // seat so they cannot suppress that seat's current attestation. + if !bytes.Equal(payload.Digest, proposal.Digest[:]) { + return + } if !claimFrostPreSignRemoteSeat( seat, len(proposal.WalletMembersIDs), diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index cbb4c7428c..0aa852b08f 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -1,16 +1,21 @@ package tbtc import ( + "bytes" "context" + "crypto/sha512" "encoding/hex" "fmt" "math/big" "strings" "sync" "testing" + "time" + "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -671,6 +676,197 @@ func TestClaimFrostPreSignRemoteSeat_BoundsAdmissionPerAuthenticatedSeat( } } +func TestThresholdFrostPreSignAuthorizationGate_RejectsStaleDigestBeforeSeatAdmission( + t *testing.T, +) { + localChain := Connect() + remoteChain := Connect() + localSigning := &testFrostPreSignFixedSignatureSigning{ + Signing: localChain.Signing(), + } + remoteSigning := &testFrostPreSignFixedSignatureSigning{ + Signing: remoteChain.Signing(), + } + proposal := &FrostPreSignAuthorizationProposal{ + Digest: [32]byte{0x11}, + WalletMembersIDs: []uint32{1, 2}, + } + staleDigest := proposal.Digest + staleDigest[0] ^= 0xff + staleSignature, err := remoteSigning.Sign(staleDigest[:]) + if err != nil { + t.Fatal(err) + } + currentSignature, err := remoteSigning.Sign(proposal.Digest[:]) + if err != nil { + t.Fatal(err) + } + remotePublicKey := remoteSigning.PublicKey() + channel := &testFrostPreSignAuthorizationBroadcastChannel{ + messages: []net.Message{ + &testFrostPreSignAuthorizationNetworkMessage{ + publicKey: remotePublicKey, + payload: &frostPreSignAuthorizationMessage{ + SenderIDValue: 2, + Digest: staleDigest[:], + PublicKey: remotePublicKey, + Signature: staleSignature, + }, + }, + &testFrostPreSignAuthorizationNetworkMessage{ + publicKey: remotePublicKey, + payload: &frostPreSignAuthorizationMessage{ + SenderIDValue: 2, + Digest: proposal.Digest[:], + PublicKey: remotePublicKey, + Signature: currentSignature, + }, + }, + }, + } + operators := []chain.Address{ + localSigning.Address(), + remoteSigning.Address(), + } + gate := &thresholdFrostPreSignAuthorizationGate{ + signing: localSigning, + broadcastChannel: channel, + membershipValidator: group.NewMembershipValidator( + &testutils.MockLogger{}, + operators, + localSigning, + ), + wallet: wallet{ + signingGroupOperators: operators, + }, + localMemberIndexes: []group.MemberIndex{1}, + threshold: 2, + } + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + attestation, err := gate.collectSeatAttestations(ctx, proposal) + if err != nil { + t.Fatalf("current attestation was suppressed by stale replay: [%v]", err) + } + if len(attestation.SigningMemberIndices) != 2 || + attestation.SigningMemberIndices[0] != 1 || + attestation.SigningMemberIndices[1] != 2 { + t.Fatalf("unexpected seat attestation: [%+v]", attestation) + } +} + +type testFrostPreSignFixedSignatureSigning struct { + chain.Signing +} + +func (signing *testFrostPreSignFixedSignatureSigning) Sign( + message []byte, +) ([]byte, error) { + return testFrostPreSignFixedSignature( + signing.PublicKey(), + message, + ), nil +} + +func (signing *testFrostPreSignFixedSignatureSigning) Verify( + message []byte, + signature []byte, +) (bool, error) { + return signing.VerifyWithPublicKey( + message, + signature, + signing.PublicKey(), + ) +} + +func (*testFrostPreSignFixedSignatureSigning) VerifyWithPublicKey( + message []byte, + signature []byte, + publicKey []byte, +) (bool, error) { + return bytes.Equal( + signature, + testFrostPreSignFixedSignature(publicKey, message), + ), nil +} + +func testFrostPreSignFixedSignature( + publicKey []byte, + message []byte, +) []byte { + payload := make([]byte, 0, len(publicKey)+len(message)) + payload = append(payload, publicKey...) + payload = append(payload, message...) + digest := sha512.Sum512(payload) + return append(digest[:], byte(0)) +} + +type testFrostPreSignAuthorizationBroadcastChannel struct { + messages []net.Message +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) Name() string { + return "test-frost-pre-sign-authorization" +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) Send( + context.Context, + net.TaggedMarshaler, + ...net.RetransmissionStrategy, +) error { + return nil +} + +func (channel *testFrostPreSignAuthorizationBroadcastChannel) Recv( + ctx context.Context, + handler func(net.Message), +) { + for _, message := range channel.messages { + select { + case <-ctx.Done(): + return + default: + handler(message) + } + } +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) SetUnmarshaler( + func() net.TaggedUnmarshaler, +) { +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) SetFilter( + net.BroadcastChannelFilter, +) error { + return nil +} + +type testFrostPreSignAuthorizationNetworkMessage struct { + publicKey []byte + payload *frostPreSignAuthorizationMessage +} + +func (*testFrostPreSignAuthorizationNetworkMessage) TransportSenderID() net.TransportIdentifier { + return nil +} + +func (message *testFrostPreSignAuthorizationNetworkMessage) SenderPublicKey() []byte { + return message.publicKey +} + +func (message *testFrostPreSignAuthorizationNetworkMessage) Payload() interface{} { + return message.payload +} + +func (*testFrostPreSignAuthorizationNetworkMessage) Type() string { + return "test-frost-pre-sign-authorization" +} + +func (*testFrostPreSignAuthorizationNetworkMessage) Seqno() uint64 { + return 0 +} + type testFrostPreSignAuthorizationGate struct { mutex sync.Mutex authorizeErr error diff --git a/pkg/tbtc/frost_primary_ethereum_client.go b/pkg/tbtc/frost_primary_ethereum_client.go new file mode 100644 index 0000000000..68cd9ec9fe --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_client.go @@ -0,0 +1,235 @@ +package tbtc + +import ( + "context" + "math/big" + "time" + + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" +) + +// FrostPrimaryEthereumClient is the guarded primary Ethereum client exposed +// to the chain package. All finite request methods apply the configured +// transport timeout even when their caller supplies context.Background. +type FrostPrimaryEthereumClient interface { + ethutil.EthereumClient + ChainID(context.Context) (*big.Int, error) + Client() *rpc.Client + FrostPrimaryEthereumRequestTimeout() time.Duration +} + +type frostPrimaryEthereumTimeoutClient struct { + client *ethclient.Client + requestTimeout time.Duration +} + +var _ FrostPrimaryEthereumClient = (*frostPrimaryEthereumTimeoutClient)(nil) + +func (client *frostPrimaryEthereumTimeoutClient) FrostPrimaryEthereumRequestTimeout() time.Duration { + return client.requestTimeout +} + +func (client *frostPrimaryEthereumTimeoutClient) Client() *rpc.Client { + return client.client.Client() +} + +func (client *frostPrimaryEthereumTimeoutClient) SubscribeNewHead( + ctx context.Context, + channel chan<- *types.Header, +) (geth.Subscription, error) { + return client.client.SubscribeNewHead(ctx, channel) +} + +func (client *frostPrimaryEthereumTimeoutClient) SubscribeFilterLogs( + ctx context.Context, + query geth.FilterQuery, + channel chan<- types.Log, +) (geth.Subscription, error) { + return client.client.SubscribeFilterLogs(ctx, query, channel) +} + +func (client *frostPrimaryEthereumTimeoutClient) requestContext( + ctx context.Context, +) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(ctx, client.requestTimeout) +} + +func (client *frostPrimaryEthereumTimeoutClient) ChainID( + ctx context.Context, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.ChainID(ctx) +} + +func (client *frostPrimaryEthereumTimeoutClient) BlockByHash( + ctx context.Context, + hash common.Hash, +) (*types.Block, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.BlockByHash(ctx, hash) +} + +func (client *frostPrimaryEthereumTimeoutClient) BlockByNumber( + ctx context.Context, + number *big.Int, +) (*types.Block, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.BlockByNumber(ctx, number) +} + +func (client *frostPrimaryEthereumTimeoutClient) HeaderByHash( + ctx context.Context, + hash common.Hash, +) (*types.Header, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.HeaderByHash(ctx, hash) +} + +func (client *frostPrimaryEthereumTimeoutClient) HeaderByNumber( + ctx context.Context, + number *big.Int, +) (*types.Header, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.HeaderByNumber(ctx, number) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionCount( + ctx context.Context, + blockHash common.Hash, +) (uint, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionCount(ctx, blockHash) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionInBlock( + ctx context.Context, + blockHash common.Hash, + index uint, +) (*types.Transaction, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionInBlock(ctx, blockHash, index) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionByHash( + ctx context.Context, + transactionHash common.Hash, +) (*types.Transaction, bool, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionByHash(ctx, transactionHash) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionReceipt( + ctx context.Context, + transactionHash common.Hash, +) (*types.Receipt, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionReceipt(ctx, transactionHash) +} + +func (client *frostPrimaryEthereumTimeoutClient) BalanceAt( + ctx context.Context, + account common.Address, + blockNumber *big.Int, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.BalanceAt(ctx, account, blockNumber) +} + +func (client *frostPrimaryEthereumTimeoutClient) CodeAt( + ctx context.Context, + account common.Address, + blockNumber *big.Int, +) ([]byte, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.CodeAt(ctx, account, blockNumber) +} + +func (client *frostPrimaryEthereumTimeoutClient) CallContract( + ctx context.Context, + message geth.CallMsg, + blockNumber *big.Int, +) ([]byte, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.CallContract(ctx, message, blockNumber) +} + +func (client *frostPrimaryEthereumTimeoutClient) PendingCodeAt( + ctx context.Context, + account common.Address, +) ([]byte, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.PendingCodeAt(ctx, account) +} + +func (client *frostPrimaryEthereumTimeoutClient) PendingNonceAt( + ctx context.Context, + account common.Address, +) (uint64, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.PendingNonceAt(ctx, account) +} + +func (client *frostPrimaryEthereumTimeoutClient) SuggestGasPrice( + ctx context.Context, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.SuggestGasPrice(ctx) +} + +func (client *frostPrimaryEthereumTimeoutClient) SuggestGasTipCap( + ctx context.Context, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.SuggestGasTipCap(ctx) +} + +func (client *frostPrimaryEthereumTimeoutClient) EstimateGas( + ctx context.Context, + message geth.CallMsg, +) (uint64, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.EstimateGas(ctx, message) +} + +func (client *frostPrimaryEthereumTimeoutClient) SendTransaction( + ctx context.Context, + transaction *types.Transaction, +) error { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.SendTransaction(ctx, transaction) +} + +func (client *frostPrimaryEthereumTimeoutClient) FilterLogs( + ctx context.Context, + query geth.FilterQuery, +) ([]types.Log, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.FilterLogs(ctx, query) +} diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go new file mode 100644 index 0000000000..2243ff40cb --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -0,0 +1,1183 @@ +package tbtc + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/http" + "net/http/httptrace" + "net/netip" + "net/url" + "path" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/gorilla/websocket" +) + +const ( + frostPrimaryEthereumTLSExporterLabel = "EXPORTER-tbtc-frost-primary-ethereum-v1" + frostPrimaryEthereumTLSExporterContextDomain = "tbtc-frost-primary-ethereum-tls-exporter-context/v1\x00" + frostPrimaryEthereumPeerIdentityDomain = "tbtc-frost-primary-ethereum-peer-identity/v1\x00" + frostPrimaryEthereumPeerChannelDomain = "tbtc-frost-primary-ethereum-peer-channel/v1\x00" + frostPrimaryEthereumMaximumSeenPeers = 4096 +) + +// FrostPrimaryEthereumTransportConfig configures the primary Ethereum +// transport used by a FROST-enabled node. The transport accepts only direct +// TLS 1.3 HTTP/1.1 connections and freezes the first complete DNS answer. +type FrostPrimaryEthereumTransportConfig struct { + URL string + RequestTimeout time.Duration + TLSRootCAs *x509.CertPool + Resolver *net.Resolver +} + +// FrostPrimaryEthereumTransport owns the exact RPC client used by the primary +// Ethereum chain handle. Every HTTP connection and every WebSocket reconnect +// is TLS-verified and recorded before it can reach go-ethereum. +type FrostPrimaryEthereumTransport struct { + mutex sync.RWMutex + endpoint frostRetainedGroupResolvedEndpoint + resolver frostRetainedGroupResolver + timeout time.Duration + rootCAs *x509.CertPool + rpcClient *rpc.Client + client FrostPrimaryEthereumClient + chainID uint64 + httpTransport *http.Transport + seenPeers map[[32]byte]frostTransportPeerIdentity + livePeers map[[32]byte]uint64 + policy *frostPrimaryRetainedSeparationPolicy + closed bool +} + +type frostTransportPeerIdentity struct { + remoteIP netip.Addr + leafCertificateHash [32]byte + leafSPKIHash [32]byte + spiffeAuthorities []string + tlsExporterValueHash [32]byte +} + +type frostPrimaryTrackedRawConnection struct { + net.Conn + transport *FrostPrimaryEthereumTransport + mutex sync.Mutex + peerKey [32]byte + active bool + closeOnce sync.Once +} + +type frostPrimaryEthereumHTTPRoundTripper struct { + base *http.Transport + transport *FrostPrimaryEthereumTransport + endpoint frostRetainedGroupResolvedEndpoint +} + +type frostPrimaryRetainedEndpointPolicy struct { + endpoint frostRetainedGroupResolvedEndpoint + identity FrostRetainedGroupEndpointIdentity +} + +type frostPrimaryRetainedSeparationPolicy struct { + mutex sync.RWMutex + primary frostRetainedGroupResolvedEndpoint + retained map[string]frostPrimaryRetainedEndpointPolicy + primaryPeers map[[32]byte]frostTransportPeerIdentity + retainedPeers map[string]map[[32]byte]frostTransportPeerIdentity + failure error +} + +// NewFrostPrimaryEthereumTransport creates and probes a primary Ethereum +// client whose actual network channels can be bound to the retained endpoint +// independence policy. +func NewFrostPrimaryEthereumTransport( + ctx context.Context, + config FrostPrimaryEthereumTransportConfig, +) (*FrostPrimaryEthereumTransport, error) { + resolver := frostRetainedGroupResolver(config.Resolver) + if resolver == nil { + resolver = net.DefaultResolver + } + return newFrostPrimaryEthereumTransport(ctx, config, resolver) +} + +func newFrostPrimaryEthereumTransport( + ctx context.Context, + config FrostPrimaryEthereumTransportConfig, + resolver frostRetainedGroupResolver, +) (*FrostPrimaryEthereumTransport, error) { + if ctx == nil { + return nil, fmt.Errorf("primary Ethereum transport context is nil") + } + timeout := config.RequestTimeout + if timeout == 0 { + timeout = frostRetainedGroupDefaultTimeout + } + if timeout < time.Second || timeout > time.Minute { + return nil, fmt.Errorf( + "primary Ethereum request timeout is outside supported bounds", + ) + } + endpointURL, err := validateFrostPrimaryEthereumTLSEndpoint(config.URL) + if err != nil { + return nil, fmt.Errorf("invalid primary Ethereum endpoint: [%w]", err) + } + if resolver == nil { + return nil, fmt.Errorf("primary Ethereum resolver is nil") + } + resolveContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + endpoint, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + endpointURL, + resolver, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve primary Ethereum endpoint: [%w]", err) + } + var roots *x509.CertPool + if config.TLSRootCAs != nil { + roots = config.TLSRootCAs.Clone() + } + transport := &FrostPrimaryEthereumTransport{ + endpoint: endpoint, + resolver: resolver, + timeout: timeout, + rootCAs: roots, + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } + + var rpcClient *rpc.Client + switch endpoint.endpoint.Scheme { + case "https": + base := &http.Transport{ + Proxy: nil, + DialTLSContext: transport.dialTLSContext, + DisableCompression: true, + ForceAttemptHTTP2: false, + MaxIdleConns: 16, + MaxIdleConnsPerHost: 16, + MaxConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + ResponseHeaderTimeout: timeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: 32 * 1024, + } + transport.httpTransport = base + roundTripper := &frostPrimaryEthereumHTTPRoundTripper{ + base: base, + transport: transport, + endpoint: endpoint, + } + httpClient := &http.Client{ + Transport: roundTripper, + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf("primary Ethereum redirects are forbidden") + }, + } + rpcClient, err = rpc.DialOptions( + ctx, + endpoint.canonical, + rpc.WithHTTPClient(httpClient), + ) + case "wss": + dialer := websocket.Dialer{ + NetDialTLSContext: transport.dialTLSContext, + Proxy: nil, + HandshakeTimeout: timeout, + EnableCompression: false, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + } + rpcClient, err = rpc.DialOptions( + ctx, + endpoint.canonical, + rpc.WithWebsocketDialer(dialer), + ) + default: + err = fmt.Errorf("primary Ethereum endpoint scheme is unsupported") + } + if err != nil { + transport.closeConnections() + return nil, fmt.Errorf("cannot dial guarded primary Ethereum endpoint: [%w]", err) + } + transport.rpcClient = rpcClient + transport.client = &frostPrimaryEthereumTimeoutClient{ + client: ethclient.NewClient(rpcClient), + requestTimeout: timeout, + } + + probeContext, probeCancel := context.WithTimeout(ctx, timeout) + defer probeCancel() + chainID, err := transport.client.ChainID(probeContext) + if err != nil { + transport.Close() + return nil, fmt.Errorf( + "cannot probe guarded primary Ethereum endpoint: [%w]", + err, + ) + } + if chainID == nil || !chainID.IsUint64() || chainID.Sign() <= 0 { + transport.Close() + return nil, fmt.Errorf( + "guarded primary Ethereum endpoint returned an invalid chain ID", + ) + } + transport.mutex.Lock() + transport.chainID = chainID.Uint64() + transport.mutex.Unlock() + transport.mutex.RLock() + hasPeer := len(transport.seenPeers) > 0 + transport.mutex.RUnlock() + if !hasPeer { + transport.Close() + return nil, fmt.Errorf( + "guarded primary Ethereum probe has no authenticated peer", + ) + } + return transport, nil +} + +// ChainID returns the positive chain ID authenticated during the guarded +// transport's startup probe. It remains available after Close so startup +// wiring never falls back to the static Developer network sentinel. +func (transport *FrostPrimaryEthereumTransport) ChainID() uint64 { + if transport == nil { + return 0 + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + return transport.chainID +} + +// Client returns the exact client whose connections are guarded by this +// transport. It must be passed to ethereum.ConnectWithClient. +func (transport *FrostPrimaryEthereumTransport) Client() FrostPrimaryEthereumClient { + if transport == nil { + return nil + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + if transport.closed { + return nil + } + return transport.client +} + +// Close closes the primary RPC client and all idle HTTP connections. +func (transport *FrostPrimaryEthereumTransport) Close() { + if transport == nil { + return + } + transport.mutex.Lock() + if transport.closed { + transport.mutex.Unlock() + return + } + transport.closed = true + rpcClient := transport.rpcClient + httpTransport := transport.httpTransport + transport.mutex.Unlock() + if rpcClient != nil { + rpcClient.Close() + } + if httpTransport != nil { + httpTransport.CloseIdleConnections() + } +} + +func (transport *FrostPrimaryEthereumTransport) closeConnections() { + if transport == nil || transport.httpTransport == nil { + return + } + transport.httpTransport.CloseIdleConnections() +} + +func validateFrostPrimaryEthereumTLSEndpoint(raw string) (*url.URL, error) { + if raw == "" || raw != strings.TrimSpace(raw) { + return nil, fmt.Errorf("URL is empty or has surrounding whitespace") + } + endpoint, err := url.Parse(raw) + if err != nil || endpoint.Host == "" || endpoint.User != nil || + endpoint.Fragment != "" || endpoint.Opaque != "" || + endpoint.RawPath != "" || endpoint.ForceQuery || + strings.Contains(raw, "\\") { + return nil, fmt.Errorf("URL is not an unambiguous TLS endpoint") + } + if endpoint.Scheme != "https" && endpoint.Scheme != "wss" { + return nil, fmt.Errorf("URL must use HTTPS or WSS") + } + hostname := endpoint.Hostname() + if hostname == "" || hostname != strings.ToLower(hostname) || + strings.HasSuffix(hostname, ".") || + !validFrostRetainedGroupEndpointHostname(hostname) { + return nil, fmt.Errorf("URL hostname is not canonical") + } + port := endpoint.Port() + if port == "" { + port = "443" + } else { + parsedPort, parseErr := strconv.ParseUint(port, 10, 16) + if parseErr != nil || parsedPort == 0 || + strconv.FormatUint(parsedPort, 10) != port { + return nil, fmt.Errorf("URL port is not canonical") + } + } + endpoint.Host = net.JoinHostPort(hostname, port) + if endpoint.Path == "" { + endpoint.Path = "/" + } + if !strings.HasPrefix(endpoint.Path, "/") || + path.Clean(endpoint.Path) != endpoint.Path || + (endpoint.Path != "/" && strings.HasSuffix(endpoint.Path, "/")) || + strings.Contains(endpoint.Path, "//") || + endpoint.EscapedPath() != endpoint.Path { + return nil, fmt.Errorf("URL path is not canonical") + } + if endpoint.RawQuery != "" { + query, parseErr := url.ParseQuery(endpoint.RawQuery) + if parseErr != nil || query.Encode() != endpoint.RawQuery { + return nil, fmt.Errorf("URL query is not canonical") + } + } + return endpoint, nil +} + +func (transport *FrostPrimaryEthereumTransport) tlsConfig() *tls.Config { + var roots *x509.CertPool + if transport.rootCAs != nil { + roots = transport.rootCAs.Clone() + } + return &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + ServerName: transport.endpoint.endpoint.Hostname(), + RootCAs: roots, + NextProtos: []string{"http/1.1"}, + } +} + +func (transport *FrostPrimaryEthereumTransport) dialTLSContext( + ctx context.Context, + network string, + address string, +) (net.Conn, error) { + if transport == nil || ctx == nil || + !strings.HasPrefix(network, "tcp") { + return nil, fmt.Errorf("primary Ethereum TLS dial is invalid") + } + host, port, err := net.SplitHostPort(address) + if err != nil || host != transport.endpoint.endpoint.Hostname() || + port != transport.endpoint.endpoint.Port() { + return nil, fmt.Errorf( + "primary Ethereum transport attempted an unpinned endpoint", + ) + } + dialContext, dialCancel := context.WithTimeout(ctx, transport.timeout) + defer dialCancel() + dialer := &net.Dialer{KeepAlive: 30 * time.Second} + var lastErr error + for index, pinned := range transport.endpoint.addresses { + deadline, ok := dialContext.Deadline() + if !ok { + return nil, fmt.Errorf( + "primary Ethereum TLS dial has no bounded deadline", + ) + } + remaining := time.Until(deadline) + if remaining <= 0 { + lastErr = dialContext.Err() + break + } + attemptsRemaining := len(transport.endpoint.addresses) - index + attemptContext, attemptCancel := context.WithTimeout( + dialContext, + remaining/time.Duration(attemptsRemaining), + ) + raw, dialErr := dialer.DialContext( + attemptContext, + network, + net.JoinHostPort(pinned.String(), port), + ) + if dialErr != nil { + attemptCancel() + lastErr = dialErr + continue + } + tracked := &frostPrimaryTrackedRawConnection{ + Conn: raw, + transport: transport, + } + tlsConnection := tls.Client(tracked, transport.tlsConfig()) + if handshakeErr := tlsConnection.HandshakeContext( + attemptContext, + ); handshakeErr != nil { + _ = tlsConnection.Close() + attemptCancel() + lastErr = handshakeErr + continue + } + attemptCancel() + state := tlsConnection.ConnectionState() + if verifyErr := verifyFrostPrimaryEthereumTLSConnection( + state, + transport.endpoint, + ); verifyErr != nil { + _ = tlsConnection.Close() + return nil, verifyErr + } + peer, peerErr := frostTransportPeerIdentityFromTLS( + transport.endpoint, + tlsConnection.RemoteAddr(), + state, + ) + if peerErr != nil { + _ = tlsConnection.Close() + return nil, peerErr + } + identityKey := frostTransportPeerIdentityKey(peer) + if recordErr := transport.recordPeer( + identityKey, + peer, + ); recordErr != nil { + _ = tlsConnection.Close() + return nil, recordErr + } + tracked.activate(frostTransportPeerChannelKey(peer)) + return tlsConnection, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("primary Ethereum endpoint has no pinned addresses") + } + return nil, fmt.Errorf( + "cannot connect to a pinned primary Ethereum address: [%w]", + lastErr, + ) +} + +func verifyFrostPrimaryEthereumTLSConnection( + state tls.ConnectionState, + endpoint frostRetainedGroupResolvedEndpoint, +) error { + if endpoint.endpoint == nil || len(state.VerifiedChains) == 0 || + len(state.PeerCertificates) == 0 { + return fmt.Errorf("primary Ethereum TLS peer is not PKIX-verified") + } + if state.Version != tls.VersionTLS13 || + state.NegotiatedProtocol != "http/1.1" || + !state.NegotiatedProtocolIsMutual { + return fmt.Errorf("primary Ethereum TLS protocol profile mismatch") + } + if err := state.PeerCertificates[0].VerifyHostname( + endpoint.endpoint.Hostname(), + ); err != nil { + return fmt.Errorf("primary Ethereum TLS hostname mismatch: [%w]", err) + } + return nil +} + +func frostTransportPeerIdentityFromTLS( + endpoint frostRetainedGroupResolvedEndpoint, + remote net.Addr, + state tls.ConnectionState, +) (frostTransportPeerIdentity, error) { + if endpoint.endpoint == nil || remote == nil || + len(state.PeerCertificates) == 0 || len(state.VerifiedChains) == 0 || + !state.HandshakeComplete || state.Version != tls.VersionTLS13 || + state.NegotiatedProtocol != "http/1.1" { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer observation is incomplete") + } + remoteHost, _, err := net.SplitHostPort(remote.String()) + if err != nil { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer address is invalid") + } + remoteIP, err := netip.ParseAddr(remoteHost) + if err != nil || !remoteIP.IsValid() || remoteIP.Zone() != "" { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer IP is invalid") + } + remoteIP = remoteIP.Unmap() + found := false + for _, pinned := range endpoint.addresses { + found = found || pinned == remoteIP + } + if !found { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer IP is outside the frozen address set") + } + leaf := state.PeerCertificates[0] + authorities := make([]string, 0) + seenAuthorities := make(map[string]bool) + for _, serviceIdentity := range leaf.URIs { + if serviceIdentity == nil || serviceIdentity.Scheme != "spiffe" { + continue + } + if err := validateFrostRetainedGroupServiceIdentity( + serviceIdentity.String(), + ); err != nil { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer has an invalid SPIFFE identity: [%w]", err) + } + authority := serviceIdentity.Hostname() + if !seenAuthorities[authority] { + seenAuthorities[authority] = true + authorities = append(authorities, authority) + } + } + sort.Strings(authorities) + leafSPKIHash := sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + contextTranscript := frostRetainedGroupIdentityTranscript( + frostPrimaryEthereumTLSExporterContextDomain, + ) + contextTranscript.text("endpoint", endpoint.canonical) + contextTranscript.text("remoteIP", remoteIP.String()) + contextTranscript.bytes32("leafSpkiHash", leafSPKIHash) + exporterContext := contextTranscript.sum() + exporterValue, err := state.ExportKeyingMaterial( + frostPrimaryEthereumTLSExporterLabel, + exporterContext[:], + 32, + ) + if err != nil { + return frostTransportPeerIdentity{}, + fmt.Errorf("cannot derive primary Ethereum TLS exporter: [%w]", err) + } + return frostTransportPeerIdentity{ + remoteIP: remoteIP, + leafCertificateHash: sha256.Sum256(leaf.Raw), + leafSPKIHash: leafSPKIHash, + spiffeAuthorities: authorities, + tlsExporterValueHash: sha256.Sum256(exporterValue), + }, nil +} + +func frostTransportPeerIdentityKey( + peer frostTransportPeerIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostPrimaryEthereumPeerIdentityDomain, + ) + transcript.text("remoteIP", peer.remoteIP.String()) + transcript.bytes32("leafCertificateHash", peer.leafCertificateHash) + transcript.bytes32("leafSpkiHash", peer.leafSPKIHash) + transcript.uint64( + "spiffeAuthorityCount", + uint64(len(peer.spiffeAuthorities)), + ) + for index, authority := range peer.spiffeAuthorities { + transcript.text( + fmt.Sprintf("spiffeAuthority[%d]", index), + authority, + ) + } + return transcript.sum() +} + +func frostTransportPeerChannelKey( + peer frostTransportPeerIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostPrimaryEthereumPeerChannelDomain, + ) + transcript.bytes32( + "peerIdentityKey", + frostTransportPeerIdentityKey(peer), + ) + transcript.bytes32( + "tlsExporterValueHash", + peer.tlsExporterValueHash, + ) + return transcript.sum() +} + +func frostTransportStablePeerIdentity( + peer frostTransportPeerIdentity, +) frostTransportPeerIdentity { + peer.spiffeAuthorities = append([]string{}, peer.spiffeAuthorities...) + peer.tlsExporterValueHash = [32]byte{} + return peer +} + +func (transport *FrostPrimaryEthereumTransport) recordPeer( + key [32]byte, + peer frostTransportPeerIdentity, +) error { + identityKey := frostTransportPeerIdentityKey(peer) + if key != identityKey { + return fmt.Errorf("primary Ethereum peer identity key mismatch") + } + channelKey := frostTransportPeerChannelKey(peer) + stablePeer := frostTransportStablePeerIdentity(peer) + + transport.mutex.Lock() + defer transport.mutex.Unlock() + if transport.closed { + return fmt.Errorf("primary Ethereum transport is closed") + } + if _, exists := transport.seenPeers[identityKey]; !exists && + len(transport.seenPeers) >= frostPrimaryEthereumMaximumSeenPeers { + return fmt.Errorf( + "primary Ethereum peer history limit exceeded", + ) + } + transport.seenPeers[identityKey] = stablePeer + if transport.policy != nil { + if err := transport.policy.registerPrimaryPeer( + identityKey, + stablePeer, + ); err != nil { + return err + } + } + transport.livePeers[channelKey]++ + return nil +} + +func (connection *frostPrimaryTrackedRawConnection) activate(key [32]byte) { + connection.mutex.Lock() + connection.peerKey = key + connection.active = true + connection.mutex.Unlock() +} + +func (connection *frostPrimaryTrackedRawConnection) Close() error { + err := connection.Conn.Close() + connection.closeOnce.Do(func() { + connection.mutex.Lock() + key := connection.peerKey + active := connection.active + connection.mutex.Unlock() + if active && connection.transport != nil { + connection.transport.releaseLivePeer(key) + } + }) + return err +} + +func (transport *FrostPrimaryEthereumTransport) releaseLivePeer( + key [32]byte, +) { + transport.mutex.Lock() + defer transport.mutex.Unlock() + count := transport.livePeers[key] + if count <= 1 { + delete(transport.livePeers, key) + return + } + transport.livePeers[key] = count - 1 +} + +func (roundTripper *frostPrimaryEthereumHTTPRoundTripper) RoundTrip( + request *http.Request, +) (*http.Response, error) { + if roundTripper == nil || roundTripper.base == nil || + roundTripper.transport == nil || request == nil || + request.URL == nil || request.Method != http.MethodPost || + request.URL.String() != roundTripper.endpoint.canonical || + (request.Host != "" && request.Host != roundTripper.endpoint.endpoint.Host) || + request.Header.Get("Accept-Encoding") != "" { + return nil, fmt.Errorf("primary Ethereum HTTP request escaped its pinned target") + } + var ( + connectionMutex sync.Mutex + connection net.Conn + ) + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + if info.Conn != nil { + connectionMutex.Lock() + connection = info.Conn + connectionMutex.Unlock() + } + }, + } + request = request.WithContext( + httptrace.WithClientTrace(request.Context(), trace), + ) + response, err := roundTripper.base.RoundTrip(request) + if err != nil { + return nil, err + } + connectionMutex.Lock() + actual := connection + connectionMutex.Unlock() + if actual == nil { + _ = response.Body.Close() + return nil, fmt.Errorf( + "primary Ethereum response has no exact connection observation", + ) + } + tlsConnection, ok := actual.(*tls.Conn) + if !ok || response.TLS == nil { + _ = response.Body.Close() + return nil, fmt.Errorf( + "primary Ethereum response did not use the guarded TLS connection", + ) + } + if err := verifyFrostPrimaryEthereumTLSConnection( + *response.TLS, + roundTripper.endpoint, + ); err != nil { + _ = response.Body.Close() + return nil, err + } + peer, err := frostTransportPeerIdentityFromTLS( + roundTripper.endpoint, + tlsConnection.RemoteAddr(), + *response.TLS, + ) + if err != nil { + _ = response.Body.Close() + return nil, err + } + if err := roundTripper.transport.verifySeenPeer( + peer, + ); err != nil { + _ = response.Body.Close() + return nil, err + } + if response.Uncompressed || + (response.Header.Get("Content-Encoding") != "" && + response.Header.Get("Content-Encoding") != "identity") { + _ = response.Body.Close() + return nil, fmt.Errorf( + "primary Ethereum response transformation is forbidden", + ) + } + return response, nil +} + +func (transport *FrostPrimaryEthereumTransport) verifySeenPeer( + peer frostTransportPeerIdentity, +) error { + identityKey := frostTransportPeerIdentityKey(peer) + channelKey := frostTransportPeerChannelKey(peer) + + transport.mutex.RLock() + defer transport.mutex.RUnlock() + if transport.closed { + return fmt.Errorf("primary Ethereum transport is closed") + } + if _, exists := transport.seenPeers[identityKey]; !exists || + transport.livePeers[channelKey] == 0 { + return fmt.Errorf( + "primary Ethereum request used an unauthenticated TLS channel", + ) + } + return nil +} + +func (transport *FrostPrimaryEthereumTransport) bindRetainedEndpoints( + exportEndpoint frostRetainedGroupResolvedEndpoint, + exportIdentity FrostRetainedGroupEndpointIdentity, + verifierEndpoint frostRetainedGroupResolvedEndpoint, + verifierIdentity FrostRetainedGroupEndpointIdentity, +) (*frostPrimaryRetainedSeparationPolicy, error) { + if transport == nil { + return nil, fmt.Errorf("primary Ethereum transport is nil") + } + policy, err := newFrostPrimaryRetainedSeparationPolicy( + transport.endpoint, + exportEndpoint, + exportIdentity, + verifierEndpoint, + verifierIdentity, + ) + if err != nil { + return nil, err + } + transport.mutex.Lock() + defer transport.mutex.Unlock() + if transport.closed || transport.policy != nil || + len(transport.seenPeers) == 0 { + return nil, fmt.Errorf( + "primary Ethereum transport cannot bind retained endpoints", + ) + } + for key, peer := range transport.seenPeers { + if err := policy.registerPrimaryPeer(key, peer); err != nil { + return nil, err + } + } + transport.policy = policy + return policy, nil +} + +func newFrostPrimaryRetainedSeparationPolicy( + primary frostRetainedGroupResolvedEndpoint, + exportEndpoint frostRetainedGroupResolvedEndpoint, + exportIdentity FrostRetainedGroupEndpointIdentity, + verifierEndpoint frostRetainedGroupResolvedEndpoint, + verifierIdentity FrostRetainedGroupEndpointIdentity, +) (*frostPrimaryRetainedSeparationPolicy, error) { + for role, value := range map[string]frostPrimaryRetainedEndpointPolicy{ + "retained-history-export": { + endpoint: exportEndpoint, + identity: exportIdentity, + }, + "retained-history-verifier": { + endpoint: verifierEndpoint, + identity: verifierIdentity, + }, + } { + if value.identity.Role != role || + !frostResolvedEndpointMatchesIdentity( + value.endpoint, + value.identity, + ) { + return nil, fmt.Errorf( + "retained endpoint policy differs from its identity", + ) + } + if frostRetainedGroupEndpointSetsOverlap(primary, value.endpoint) { + return nil, fmt.Errorf( + "primary Ethereum frozen endpoint aliases %s", + role, + ) + } + } + return &frostPrimaryRetainedSeparationPolicy{ + primary: primary, + retained: map[string]frostPrimaryRetainedEndpointPolicy{ + "retained-history-export": { + endpoint: exportEndpoint, + identity: exportIdentity, + }, + "retained-history-verifier": { + endpoint: verifierEndpoint, + identity: verifierIdentity, + }, + }, + primaryPeers: make(map[[32]byte]frostTransportPeerIdentity), + retainedPeers: map[string]map[[32]byte]frostTransportPeerIdentity{ + "retained-history-export": {}, + "retained-history-verifier": {}, + }, + }, nil +} + +func frostResolvedEndpointMatchesIdentity( + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, +) bool { + return endpoint.endpoint != nil && + endpoint.canonical == identity.CanonicalEndpoint && + endpoint.canonicalDNSName == identity.CanonicalDNSName && + endpoint.resolvedDNSName == identity.ResolvedDNSName && + endpoint.addressSetHash == identity.ResolvedAddressSetHash +} + +func (policy *frostPrimaryRetainedSeparationPolicy) registerPrimaryPeer( + key [32]byte, + peer frostTransportPeerIdentity, +) error { + policy.mutex.Lock() + defer policy.mutex.Unlock() + if policy.failure != nil { + return policy.failure + } + if _, exists := policy.primaryPeers[key]; !exists && + len(policy.primaryPeers) >= frostPrimaryEthereumMaximumSeenPeers { + policy.failure = fmt.Errorf( + "primary Ethereum policy peer history limit exceeded", + ) + return policy.failure + } + policy.primaryPeers[key] = peer + if !frostAddressSetContains(policy.primary.addresses, peer.remoteIP) { + policy.failure = fmt.Errorf( + "primary Ethereum actual peer is outside its frozen address set", + ) + return policy.failure + } + for role, retained := range policy.retained { + if err := frostPeerIndependentOfFrozenEndpoint( + "primary Ethereum", + peer, + role, + retained, + ); err != nil { + policy.failure = err + return err + } + for _, retainedPeer := range policy.retainedPeers[role] { + if err := frostTransportPeersIndependent( + "primary Ethereum", + peer, + role, + retainedPeer, + ); err != nil { + policy.failure = err + return err + } + } + } + return nil +} + +func (policy *frostPrimaryRetainedSeparationPolicy) registerRetainedPeer( + role string, + peer frostTransportPeerIdentity, +) error { + policy.mutex.Lock() + defer policy.mutex.Unlock() + if policy.failure != nil { + return policy.failure + } + retained, exists := policy.retained[role] + if !exists { + return fmt.Errorf("retained peer role is outside the separation policy") + } + peers := policy.retainedPeers[role] + key := frostTransportPeerIdentityKey(peer) + stablePeer := frostTransportStablePeerIdentity(peer) + if _, exists := peers[key]; !exists && + len(peers) >= frostPrimaryEthereumMaximumSeenPeers { + policy.failure = fmt.Errorf( + "%s peer history limit exceeded", + role, + ) + return policy.failure + } + peers[key] = stablePeer + if !frostAddressSetContains(retained.endpoint.addresses, stablePeer.remoteIP) || + stablePeer.leafSPKIHash != retained.identity.TLSLeafSPKIHash || + !frostStringSetContains( + stablePeer.spiffeAuthorities, + retained.identity.TrustDomainID, + ) { + policy.failure = fmt.Errorf( + "%s actual TLS peer differs from its frozen identity", + role, + ) + return policy.failure + } + if frostAddressSetContains(policy.primary.addresses, stablePeer.remoteIP) { + policy.failure = fmt.Errorf( + "%s actual peer aliases the primary Ethereum frozen address set", + role, + ) + return policy.failure + } + for _, primaryPeer := range policy.primaryPeers { + if err := frostTransportPeersIndependent( + role, + stablePeer, + "primary Ethereum", + primaryPeer, + ); err != nil { + policy.failure = err + return err + } + } + return nil +} + +func frostPeerIndependentOfFrozenEndpoint( + peerRole string, + peer frostTransportPeerIdentity, + frozenRole string, + frozen frostPrimaryRetainedEndpointPolicy, +) error { + if frostAddressSetContains(frozen.endpoint.addresses, peer.remoteIP) { + return fmt.Errorf( + "%s actual peer aliases %s frozen address set", + peerRole, + frozenRole, + ) + } + if peer.leafSPKIHash == frozen.identity.TLSLeafSPKIHash { + return fmt.Errorf( + "%s TLS leaf SPKI aliases %s", + peerRole, + frozenRole, + ) + } + if frostStringSetContains( + peer.spiffeAuthorities, + frozen.identity.TrustDomainID, + ) { + return fmt.Errorf( + "%s SPIFFE authority aliases %s", + peerRole, + frozenRole, + ) + } + return nil +} + +func frostTransportPeersIndependent( + leftRole string, + left frostTransportPeerIdentity, + rightRole string, + right frostTransportPeerIdentity, +) error { + if left.remoteIP == right.remoteIP { + return fmt.Errorf( + "%s actual peer IP aliases %s", + leftRole, + rightRole, + ) + } + if left.leafCertificateHash == right.leafCertificateHash { + return fmt.Errorf( + "%s TLS leaf certificate aliases %s", + leftRole, + rightRole, + ) + } + if left.leafSPKIHash == right.leafSPKIHash { + return fmt.Errorf( + "%s TLS leaf SPKI aliases %s", + leftRole, + rightRole, + ) + } + for _, authority := range left.spiffeAuthorities { + if frostStringSetContains(right.spiffeAuthorities, authority) { + return fmt.Errorf( + "%s SPIFFE authority aliases %s", + leftRole, + rightRole, + ) + } + } + return nil +} + +func frostAddressSetContains(addresses []netip.Addr, target netip.Addr) bool { + for _, address := range addresses { + if address == target { + return true + } + } + return false +} + +func frostStringSetContains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func (policy *frostPrimaryRetainedSeparationPolicy) verify() error { + if policy == nil { + return fmt.Errorf("primary/retained separation policy is nil") + } + policy.mutex.RLock() + defer policy.mutex.RUnlock() + if policy.failure != nil { + return policy.failure + } + if len(policy.primaryPeers) == 0 { + return fmt.Errorf( + "primary/retained separation policy has no primary TLS peer", + ) + } + return nil +} + +func (transport *FrostPrimaryEthereumTransport) verifyIndependence( + ctx context.Context, + exportEndpoint frostRetainedGroupResolvedEndpoint, + verifierEndpoint frostRetainedGroupResolvedEndpoint, +) error { + if transport == nil || ctx == nil { + return fmt.Errorf("primary Ethereum transport verification is incomplete") + } + transport.mutex.RLock() + if transport.closed || transport.policy == nil { + transport.mutex.RUnlock() + return fmt.Errorf("primary Ethereum transport is not bound") + } + endpoint := transport.endpoint + resolver := transport.resolver + timeout := transport.timeout + policy := transport.policy + transport.mutex.RUnlock() + if frostRetainedGroupEndpointSetsOverlap(endpoint, exportEndpoint) || + frostRetainedGroupEndpointSetsOverlap(endpoint, verifierEndpoint) { + return fmt.Errorf( + "primary Ethereum frozen endpoint aliases a retained endpoint", + ) + } + resolveContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + current, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + endpoint.endpoint, + resolver, + ) + if err != nil { + return fmt.Errorf( + "cannot re-resolve primary Ethereum endpoint: [%w]", + err, + ) + } + if current.canonical != endpoint.canonical || + current.canonicalDNSName != endpoint.canonicalDNSName || + current.resolvedDNSName != endpoint.resolvedDNSName || + current.addressSetHash != endpoint.addressSetHash { + return fmt.Errorf("primary Ethereum DNS identity drifted") + } + if frostRetainedGroupEndpointSetsOverlap(current, exportEndpoint) || + frostRetainedGroupEndpointSetsOverlap(current, verifierEndpoint) { + return fmt.Errorf( + "primary Ethereum endpoint now aliases a retained endpoint", + ) + } + return policy.verify() +} + +func (transport *FrostPrimaryEthereumTransport) frozenEndpoint() ( + frostRetainedGroupResolvedEndpoint, + frostRetainedGroupResolver, + error, +) { + if transport == nil { + return frostRetainedGroupResolvedEndpoint{}, nil, + fmt.Errorf("primary Ethereum transport is nil") + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + if transport.closed || transport.endpoint.endpoint == nil || + transport.resolver == nil { + return frostRetainedGroupResolvedEndpoint{}, nil, + fmt.Errorf("primary Ethereum transport is incomplete") + } + endpoint := transport.endpoint + endpoint.addresses = append([]netip.Addr{}, endpoint.addresses...) + return endpoint, transport.resolver, nil +} + +func (transport *FrostPrimaryEthereumTransport) peerCounts() ( + seen int, + live uint64, +) { + if transport == nil { + return 0, 0 + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + for _, count := range transport.livePeers { + live += count + } + return len(transport.seenPeers), live +} diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go new file mode 100644 index 0000000000..c4fa850742 --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -0,0 +1,355 @@ +package tbtc + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" +) + +func testFrostTransportPeer(exporter byte) frostTransportPeerIdentity { + return frostTransportPeerIdentity{ + remoteIP: netip.MustParseAddr("192.0.2.1"), + leafCertificateHash: [32]byte{0x01}, + leafSPKIHash: [32]byte{0x02}, + spiffeAuthorities: []string{"primary.example"}, + tlsExporterValueHash: [32]byte{exporter}, + } +} + +func newTestFrostPrimaryEthereumTransport() *FrostPrimaryEthereumTransport { + return &FrostPrimaryEthereumTransport{ + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } +} + +func TestFrostTransportPeerKeysSeparateHistoryFromLiveChannel(t *testing.T) { + first := testFrostTransportPeer(0x11) + second := testFrostTransportPeer(0x22) + + if frostTransportPeerIdentityKey(first) != + frostTransportPeerIdentityKey(second) { + t.Fatal("TLS reconnect changed stable peer identity") + } + if frostTransportPeerChannelKey(first) == + frostTransportPeerChannelKey(second) { + t.Fatal("distinct TLS exporters produced the same live channel key") + } +} + +func TestFrostPrimaryEthereumTransportReconnectsDoNotExhaustHistory( + t *testing.T, +) { + transport := newTestFrostPrimaryEthereumTransport() + + for index := 0; index <= frostPrimaryEthereumMaximumSeenPeers; index++ { + peer := testFrostTransportPeer(byte(index)) + peer.tlsExporterValueHash = sha256.Sum256( + []byte(fmt.Sprintf("exporter-%d", index)), + ) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err != nil { + t.Fatalf("reconnect [%d] failed: [%v]", index, err) + } + transport.releaseLivePeer(frostTransportPeerChannelKey(peer)) + } + + seen, live := transport.peerCounts() + if seen != 1 || live != 0 { + t.Fatalf("unexpected peer counts [%d, %d]", seen, live) + } +} + +func TestFrostRetainedPeerReconnectsDoNotExhaustHistory(t *testing.T) { + primaryIP := netip.MustParseAddr("192.0.2.1") + retainedIP := netip.MustParseAddr("192.0.2.2") + retainedPeer := testFrostTransportPeer(0x11) + retainedPeer.remoteIP = retainedIP + retainedPeer.leafSPKIHash = [32]byte{0x44} + retainedPeer.spiffeAuthorities = []string{"retained.example"} + + policy := &frostPrimaryRetainedSeparationPolicy{ + primary: frostRetainedGroupResolvedEndpoint{ + addresses: []netip.Addr{primaryIP}, + }, + retained: map[string]frostPrimaryRetainedEndpointPolicy{ + "retained-history-export": { + endpoint: frostRetainedGroupResolvedEndpoint{ + addresses: []netip.Addr{retainedIP}, + }, + identity: FrostRetainedGroupEndpointIdentity{ + TrustDomainID: "retained.example", + TLSLeafSPKIHash: retainedPeer.leafSPKIHash, + }, + }, + }, + primaryPeers: map[[32]byte]frostTransportPeerIdentity{ + {0x01}: { + remoteIP: primaryIP, + leafCertificateHash: [32]byte{0x55}, + leafSPKIHash: [32]byte{0x66}, + spiffeAuthorities: []string{"primary.example"}, + }, + }, + retainedPeers: map[string]map[[32]byte]frostTransportPeerIdentity{ + "retained-history-export": {}, + }, + } + + for index := 0; index <= frostPrimaryEthereumMaximumSeenPeers; index++ { + retainedPeer.tlsExporterValueHash = sha256.Sum256( + []byte(fmt.Sprintf("retained-exporter-%d", index)), + ) + if err := policy.registerRetainedPeer( + "retained-history-export", + retainedPeer, + ); err != nil { + t.Fatalf("retained reconnect [%d] failed: [%v]", index, err) + } + } + + if actual := len( + policy.retainedPeers["retained-history-export"], + ); actual != 1 { + t.Fatalf("unexpected retained peer history size [%d]", actual) + } +} + +func TestFrostPrimaryEthereumTransportRejectsNewStablePeerPastLimit( + t *testing.T, +) { + transport := newTestFrostPrimaryEthereumTransport() + + for index := 0; index < frostPrimaryEthereumMaximumSeenPeers; index++ { + peer := testFrostTransportPeer(0x11) + peer.leafCertificateHash = sha256.Sum256( + []byte(fmt.Sprintf("certificate-%d", index)), + ) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err != nil { + t.Fatalf("stable peer [%d] failed: [%v]", index, err) + } + } + + peer := testFrostTransportPeer(0x11) + peer.leafCertificateHash = sha256.Sum256([]byte("one-too-many")) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err == nil { + t.Fatal("new stable peer beyond history limit was accepted") + } +} + +func TestFrostPrimaryEthereumTransportRequiresExactLiveChannel(t *testing.T) { + transport := newTestFrostPrimaryEthereumTransport() + peer := testFrostTransportPeer(0x11) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err != nil { + t.Fatal(err) + } + channelKey := frostTransportPeerChannelKey(peer) + + if err := transport.verifySeenPeer(peer); err != nil { + t.Fatalf("live channel rejected: [%v]", err) + } + + otherChannel := peer + otherChannel.tlsExporterValueHash = [32]byte{0x22} + if err := transport.verifySeenPeer(otherChannel); err == nil { + t.Fatal("unrecorded TLS channel accepted") + } + + transport.releaseLivePeer(channelKey) + if err := transport.verifySeenPeer(peer); err == nil { + t.Fatal("closed TLS channel accepted") + } +} + +func TestFrostPrimaryEthereumTransportTriesEveryPinnedAddressWithinDeadline( + t *testing.T, +) { + server, _, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + "spiffe://primary.example/rpc", + ) + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(endpoint.Host) + if err != nil { + t.Fatal(err) + } + + stalledListener, err := net.Listen( + "tcp6", + net.JoinHostPort("::1", port), + ) + if err != nil { + t.Fatal(err) + } + releaseStalledConnection := make(chan struct{}) + stalledConnectionDone := make(chan struct{}) + go func() { + defer close(stalledConnectionDone) + connection, acceptErr := stalledListener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + <-releaseStalledConnection + }() + t.Cleanup(func() { + close(releaseStalledConnection) + _ = stalledListener.Close() + <-stalledConnectionDone + }) + + transport := &FrostPrimaryEthereumTransport{ + endpoint: frostRetainedGroupResolvedEndpoint{ + endpoint: endpoint, + canonical: endpoint.String(), + addresses: []netip.Addr{ + netip.MustParseAddr("::1"), + netip.MustParseAddr("127.0.0.1"), + }, + }, + timeout: time.Second, + rootCAs: roots, + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + connection, err := transport.dialTLSContext(ctx, "tcp", endpoint.Host) + if err != nil { + t.Fatalf( + "healthy pinned address was starved by a stalled predecessor: [%v]", + err, + ) + } + if err := connection.Close(); err != nil { + t.Fatal(err) + } +} + +func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( + t *testing.T, +) { + service := &testFrostPrimaryEthereumStalledRPC{ + stalled: make(chan struct{}), + release: make(chan struct{}), + } + rpcServer := rpc.NewServer() + if err := rpcServer.RegisterName("eth", service); err != nil { + t.Fatal(err) + } + server, _, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + rpcServer.WebsocketHandler([]string{"*"}), + "spiffe://primary.example/rpc", + ) + transport, err := NewFrostPrimaryEthereumTransport( + context.Background(), + FrostPrimaryEthereumTransportConfig{ + URL: strings.Replace( + server.URL, + "https://", + "wss://", + 1, + ), + RequestTimeout: time.Second, + TLSRootCAs: roots, + }, + ) + if err != nil { + t.Fatal(err) + } + defer transport.Close() + if transport.ChainID() != 1 { + t.Fatalf( + "guarded transport did not retain probed chain ID: [%d]", + transport.ChainID(), + ) + } + + result := make(chan error, 1) + go func() { + _, err := transport.Client().ChainID(context.Background()) + result <- err + }() + select { + case <-service.stalled: + case <-time.After(time.Second): + close(service.release) + t.Fatal("post-probe WSS request did not reach the stalled provider") + } + + select { + case err := <-result: + close(service.release) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("stalled WSS request returned unexpected error: [%v]", err) + } + case <-time.After(3 * time.Second): + close(service.release) + err := <-result + t.Fatalf( + "stalled WSS request exceeded configured timeout; eventual result: [%v]", + err, + ) + } +} + +type testFrostPrimaryEthereumStalledRPC struct { + mutex sync.Mutex + calls int + stalled chan struct{} + release chan struct{} + once sync.Once +} + +func (service *testFrostPrimaryEthereumStalledRPC) ChainId( + ctx context.Context, +) (*hexutil.Big, error) { + service.mutex.Lock() + service.calls++ + call := service.calls + service.mutex.Unlock() + if call == 1 { + value := hexutil.Big(*big.NewInt(1)) + return &value, nil + } + service.once.Do(func() { + close(service.stalled) + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-service.release: + value := hexutil.Big(*big.NewInt(1)) + return &value, nil + } +} diff --git a/pkg/tbtc/frost_retained_group_checkpoint.go b/pkg/tbtc/frost_retained_group_checkpoint.go new file mode 100644 index 0000000000..e55f843bfc --- /dev/null +++ b/pkg/tbtc/frost_retained_group_checkpoint.go @@ -0,0 +1,1727 @@ +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "os" + "sort" + "strings" + + "github.com/decred/dcrd/dcrec/edwards/v2" +) + +const ( + frostRetainedGroupCheckpointBodySchema = "tbtc-frost-retained-group-checkpoint-body/v1" + frostRetainedGroupCheckpointCertificateSchema = "tbtc-frost-retained-group-checkpoint-certificate/v1" + frostRetainedGroupCheckpointMetadataSchema = "tbtc-frost-retained-group-checkpoint-metadata/v1" + frostRetainedGroupCheckpointStateSchema = "tbtc-frost-retained-group-checkpoint-state/v1" + + frostRetainedGroupCheckpointBodyDomain = "tbtc-frost-retained-group-checkpoint-body-v1\x00" + frostRetainedGroupCheckpointSignatureDomain = "tbtc-frost-retained-group-checkpoint-signature-v1\x00" + frostRetainedGroupCheckpointCertificateDomain = "tbtc-frost-retained-group-checkpoint-certificate-v1\x00" + frostRetainedGroupCheckpointChainDomain = "tbtc-frost-retained-group-checkpoint-chain-v1\x00" + + frostRetainedGroupCheckpointMetadataFile = "metadata.json" + frostRetainedGroupCheckpointStateFile = "state.json" + frostRetainedGroupCheckpointFilePrefix = "certificate-" + // A recovery page is deliberately bounded, while a complete certificate + // chain is not. The journal can durably advance through multiple pages and + // resume from the last authenticated certificate after a crash or timeout. + frostRetainedGroupMaximumCheckpointsPerPage = 256 + // Reconciliation durably publishes exactly one authenticated page before + // yielding an explicit progress result. The controller then re-enters with + // a fresh timeout from the new durable cursor, so total history is unbounded + // without making one reconciliation attempt unbounded. + frostRetainedGroupCheckpointPagesPerReconciliation = 1 + // Activation verifiers must obtain a fresh rollback-independent floor from + // the transparency channel. An arbitrarily stale caller-supplied floor + // cannot force the signer to allocate and serialize its entire lifetime + // history. This remains above the old 256-certificate recovery limit. + frostRetainedGroupMaximumHandshakeAncestry = 512 + // Canonical checkpoint proof bytes are accounted certificate-by-certificate + // before the aggregate handshake payload is materialized. This protects the + // signer from a small loopback request expanding into hundreds of megabytes + // of authority credentials and canonical-JSON working buffers. + frostRetainedGroupMaximumHandshakeProofBytes = 4 * 1024 * 1024 + frostRetainedGroupCheckpointDirectory = "checkpoints" +) + +// FrostRetainedGroupCheckpointCursor is the rollback-independent head after +// which a history source must return a cryptographically contiguous suffix. +// Sequence zero and a zero digest denote the manifest's sequence-one genesis +// predecessor. +type FrostRetainedGroupCheckpointCursor struct { + Sequence uint64 + CertificateHash [32]byte +} + +// FrostRetainedGroupCheckpointBody commits a quorum to one deterministic +// semantic retained-group state. Per-node batch roots are deliberately +// excluded: nodes can reconcile at different intervals while deriving the +// same inventory and quarantine roots from the same complete history. +type FrostRetainedGroupCheckpointBody struct { + Schema string + ProtocolBindingHash [32]byte + ManifestHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + ChainID uint64 + DomainChainID [32]byte + GenesisBlockHash [32]byte + AuthoritySetHash [32]byte + Sequence uint64 + PreviousCertificateHash [32]byte + Point FrostPreSignFinality + HistoryRoot [32]byte + CanonicalGeneration uint64 + CanonicalInventoryRoot [32]byte + QuarantineGeneration uint64 + QuarantineEventRoot [32]byte + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte +} + +type FrostRetainedGroupCheckpointSignature struct { + AuthorityID string + SignerPublicKeySPKI string + Signature string +} + +type FrostRetainedGroupCheckpointCertificate struct { + Schema string + Body FrostRetainedGroupCheckpointBody + BodyHash [32]byte + Signatures []FrostRetainedGroupCheckpointSignature +} + +// FrostRetainedGroupCheckpointCommitment is the exact durable semantic state +// that the tail of an externally verified checkpoint proof must certify. +type FrostRetainedGroupCheckpointCommitment struct { + DurableHead FrostRetainedGroupCheckpointCursor + Point FrostPreSignFinality + HistoryRoot [32]byte + CanonicalGeneration uint64 + CanonicalInventoryRoot [32]byte + QuarantineGeneration uint64 + QuarantineEventRoot [32]byte + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte +} + +type frostRetainedGroupWireCheckpointBody struct { + Schema string `json:"schema"` + ProtocolBindingHash string `json:"protocolBindingHash"` + ManifestHash string `json:"manifestHash"` + ProfileHash string `json:"profileHash"` + ImplementationSetHash string `json:"implementationSetHash"` + ChainID uint64 `json:"chainID"` + DomainChainID string `json:"domainChainID"` + GenesisBlockHash string `json:"genesisBlockHash"` + AuthoritySetHash string `json:"authoritySetHash"` + Sequence uint64 `json:"sequence"` + PreviousCertificateHash string `json:"previousCertificateHash"` + Point frostRetainedGroupWireFinality `json:"point"` + HistoryRoot string `json:"historyRoot"` + CanonicalGeneration uint64 `json:"canonicalGeneration"` + CanonicalInventoryRoot string `json:"canonicalInventoryRoot"` + QuarantineGeneration uint64 `json:"quarantineGeneration"` + QuarantineEventRoot string `json:"quarantineEventRoot"` + QuarantineActiveRoot string `json:"quarantineActiveRoot"` + QuarantineTombstoneRoot string `json:"quarantineTombstoneRoot"` +} + +type frostRetainedGroupWireCheckpointSignature struct { + AuthorityID string `json:"authorityID"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostRetainedGroupWireCheckpointCertificate struct { + Schema string `json:"schema"` + Body frostRetainedGroupWireCheckpointBody `json:"body"` + BodyHash string `json:"bodyHash"` + Signatures []frostRetainedGroupWireCheckpointSignature `json:"signatures"` +} + +type frostRetainedGroupCheckpointPolicy struct { + ProtocolBindingHash [32]byte + ManifestHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + ChainID uint64 + DomainChainID [32]byte + GenesisBlockHash [32]byte + AuthoritySetHash [32]byte + AuthorityThreshold uint64 + Authorities []FrostRetainedGroupAuthority + MinimumSequence uint64 + PredecessorHash [32]byte + CanonicalMinimum uint64 + QuarantineMinimum uint64 + LiftPolicy frostRetainedGroupQuarantineLiftPolicy +} + +type frostRetainedGroupCheckpointMetadata struct { + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` + AuthoritySetHash [32]byte `json:"authoritySetHash"` + AuthorityThreshold uint64 `json:"authorityThreshold"` + Authorities []FrostRetainedGroupAuthority `json:"authorities"` + MinimumSequence uint64 `json:"minimumSequence"` + PredecessorHash [32]byte `json:"predecessorHash"` +} + +type frostRetainedGroupCheckpointJournalState struct { + Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` + Sequence uint64 `json:"sequence"` + CertificateHash [32]byte `json:"certificateHash"` + Point FrostPreSignFinality `json:"point"` + HistoryRoot [32]byte `json:"historyRoot"` + CanonicalGeneration uint64 `json:"canonicalGeneration"` + CanonicalInventoryRoot [32]byte `json:"canonicalInventoryRoot"` + QuarantineGeneration uint64 `json:"quarantineGeneration"` + QuarantineEventRoot [32]byte `json:"quarantineEventRoot"` + QuarantineActiveRoot [32]byte `json:"quarantineActiveRoot"` + QuarantineTombstoneRoot [32]byte `json:"quarantineTombstoneRoot"` +} + +func frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) (frostRetainedGroupCheckpointPolicy, error) { + quarantine := runtimeManifest.QuarantineJournal + authoritySetHash, err := frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + quarantine.CheckpointAuthorityThreshold, + quarantine.CheckpointAuthorities, + ) + if err != nil { + return frostRetainedGroupCheckpointPolicy{}, err + } + if bindingHash == [32]byte{} || + runtimeManifest.ManifestHash == [32]byte{} || + runtimeManifest.ProfileHash == [32]byte{} || + runtimeManifest.ImplementationSetHash == [32]byte{} || + runtimeManifest.DomainChainID == [32]byte{} || + runtimeManifest.GenesisBlockHash == [32]byte{} || + quarantine.CheckpointMinimumSequence == 0 || + quarantine.CheckpointMinimumSequence > + frostRetainedGroupMaximumCanonicalJSONInteger || + (quarantine.CheckpointMinimumSequence == 1 && + quarantine.CheckpointPredecessorHash != [32]byte{}) || + (quarantine.CheckpointMinimumSequence > 1 && + quarantine.CheckpointPredecessorHash == [32]byte{}) { + return frostRetainedGroupCheckpointPolicy{}, fmt.Errorf( + "FROST retained-group checkpoint policy is incomplete", + ) + } + for _, value := range runtimeManifest.DomainChainID[:24] { + if value != 0 { + return frostRetainedGroupCheckpointPolicy{}, fmt.Errorf( + "FROST checkpoint chain ID exceeds uint64", + ) + } + } + chainID := uint64(0) + for _, value := range runtimeManifest.DomainChainID[24:] { + chainID = (chainID << 8) | uint64(value) + } + if chainID == 0 { + return frostRetainedGroupCheckpointPolicy{}, fmt.Errorf( + "FROST checkpoint chain ID is zero", + ) + } + liftPolicy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return frostRetainedGroupCheckpointPolicy{}, err + } + return frostRetainedGroupCheckpointPolicy{ + ProtocolBindingHash: bindingHash, + ManifestHash: runtimeManifest.ManifestHash, + ProfileHash: runtimeManifest.ProfileHash, + ImplementationSetHash: runtimeManifest.ImplementationSetHash, + ChainID: chainID, + DomainChainID: runtimeManifest.DomainChainID, + GenesisBlockHash: runtimeManifest.GenesisBlockHash, + AuthoritySetHash: authoritySetHash, + AuthorityThreshold: quarantine.CheckpointAuthorityThreshold, + Authorities: append( + []FrostRetainedGroupAuthority{}, + quarantine.CheckpointAuthorities..., + ), + MinimumSequence: quarantine.CheckpointMinimumSequence, + PredecessorHash: quarantine.CheckpointPredecessorHash, + CanonicalMinimum: runtimeManifest.CanonicalJournal.MinimumGeneration, + QuarantineMinimum: quarantine.MinimumGeneration, + LiftPolicy: liftPolicy, + }, nil +} + +func frostRetainedGroupCheckpointCertificateToWire( + certificate FrostRetainedGroupCheckpointCertificate, +) frostRetainedGroupWireCheckpointCertificate { + body := certificate.Body + signatures := make( + []frostRetainedGroupWireCheckpointSignature, + len(certificate.Signatures), + ) + for index, signature := range certificate.Signatures { + signatures[index] = frostRetainedGroupWireCheckpointSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return frostRetainedGroupWireCheckpointCertificate{ + Schema: certificate.Schema, + Body: frostRetainedGroupWireCheckpointBody{ + Schema: body.Schema, + ProtocolBindingHash: frostActivationHex32(body.ProtocolBindingHash), + ManifestHash: frostActivationHex32(body.ManifestHash), + ProfileHash: frostActivationHex32(body.ProfileHash), + ImplementationSetHash: frostActivationHex32(body.ImplementationSetHash), + ChainID: body.ChainID, + DomainChainID: frostActivationHex32(body.DomainChainID), + GenesisBlockHash: frostActivationHex32(body.GenesisBlockHash), + AuthoritySetHash: frostActivationHex32(body.AuthoritySetHash), + Sequence: body.Sequence, + PreviousCertificateHash: frostActivationHex32(body.PreviousCertificateHash), + Point: frostRetainedGroupFinalityToWire(body.Point), + HistoryRoot: frostActivationHex32(body.HistoryRoot), + CanonicalGeneration: body.CanonicalGeneration, + CanonicalInventoryRoot: frostActivationHex32(body.CanonicalInventoryRoot), + QuarantineGeneration: body.QuarantineGeneration, + QuarantineEventRoot: frostActivationHex32(body.QuarantineEventRoot), + QuarantineActiveRoot: frostActivationHex32(body.QuarantineActiveRoot), + QuarantineTombstoneRoot: frostActivationHex32(body.QuarantineTombstoneRoot), + }, + BodyHash: frostActivationHex32(certificate.BodyHash), + Signatures: signatures, + } +} + +func frostRetainedGroupCheckpointCertificateFromWire( + wire frostRetainedGroupWireCheckpointCertificate, +) (FrostRetainedGroupCheckpointCertificate, error) { + parse := func(name string, value string) ([32]byte, error) { + result, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf( + "invalid FROST checkpoint %s: [%w]", + name, + err, + ) + } + return result, nil + } + protocolBindingHash, err := parse( + "protocol binding hash", + wire.Body.ProtocolBindingHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + manifestHash, err := parse("manifest hash", wire.Body.ManifestHash) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + profileHash, err := parse("profile hash", wire.Body.ProfileHash) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + implementationSetHash, err := parse( + "implementation set hash", + wire.Body.ImplementationSetHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + domainChainID, err := parse("domain chain ID", wire.Body.DomainChainID) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + genesisBlockHash, err := parse( + "genesis block hash", + wire.Body.GenesisBlockHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + authoritySetHash, err := parse( + "authority set hash", + wire.Body.AuthoritySetHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + previousCertificateHash, err := parse( + "previous certificate hash", + wire.Body.PreviousCertificateHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + point, err := frostRetainedGroupFinalityFromWire(wire.Body.Point) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "invalid FROST checkpoint point: [%w]", + err, + ) + } + historyRoot, err := parse("history root", wire.Body.HistoryRoot) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + canonicalInventoryRoot, err := parse( + "canonical inventory root", + wire.Body.CanonicalInventoryRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + quarantineEventRoot, err := parse( + "quarantine event root", + wire.Body.QuarantineEventRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + quarantineActiveRoot, err := parse( + "quarantine active root", + wire.Body.QuarantineActiveRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + quarantineTombstoneRoot, err := parse( + "quarantine tombstone root", + wire.Body.QuarantineTombstoneRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + bodyHash, err := parse("body hash", wire.BodyHash) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + signatures := make( + []FrostRetainedGroupCheckpointSignature, + len(wire.Signatures), + ) + for index, signature := range wire.Signatures { + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return FrostRetainedGroupCheckpointCertificate{ + Schema: wire.Schema, + Body: FrostRetainedGroupCheckpointBody{ + Schema: wire.Body.Schema, + ProtocolBindingHash: protocolBindingHash, + ManifestHash: manifestHash, + ProfileHash: profileHash, + ImplementationSetHash: implementationSetHash, + ChainID: wire.Body.ChainID, + DomainChainID: domainChainID, + GenesisBlockHash: genesisBlockHash, + AuthoritySetHash: authoritySetHash, + Sequence: wire.Body.Sequence, + PreviousCertificateHash: previousCertificateHash, + Point: point, + HistoryRoot: historyRoot, + CanonicalGeneration: wire.Body.CanonicalGeneration, + CanonicalInventoryRoot: canonicalInventoryRoot, + QuarantineGeneration: wire.Body.QuarantineGeneration, + QuarantineEventRoot: quarantineEventRoot, + QuarantineActiveRoot: quarantineActiveRoot, + QuarantineTombstoneRoot: quarantineTombstoneRoot, + }, + BodyHash: bodyHash, + Signatures: signatures, + }, nil +} + +func frostRetainedGroupCheckpointBodyHash( + body FrostRetainedGroupCheckpointBody, +) ([32]byte, error) { + if body.Schema != frostRetainedGroupCheckpointBodySchema { + return [32]byte{}, fmt.Errorf( + "unsupported FROST checkpoint body schema", + ) + } + wire := frostRetainedGroupCheckpointCertificateToWire( + FrostRetainedGroupCheckpointCertificate{Body: body}, + ) + return frostRetainedGroupDomainHash( + frostRetainedGroupCheckpointBodyDomain, + wire.Body, + ) +} + +func frostRetainedGroupCheckpointSignatureHash( + bodyHash [32]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupCheckpointSignatureDomain)) + hasher.Write(bodyHash[:]) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostRetainedGroupCheckpointCertificateHash( + certificate FrostRetainedGroupCheckpointCertificate, +) ([32]byte, error) { + if certificate.Schema != + frostRetainedGroupCheckpointCertificateSchema { + return [32]byte{}, fmt.Errorf( + "unsupported FROST checkpoint certificate schema", + ) + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(certificate.Body) + if err != nil || bodyHash != certificate.BodyHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate body hash mismatch", + ) + } + // A checkpoint's durable identity must not depend on which valid quorum + // subset an aggregator happened to include. Otherwise, the same signed + // body can acquire multiple predecessor hashes and permanently fork + // journals that received different 2-of-3 or 3-of-3 encodings. + identity := struct { + Schema string `json:"schema"` + BodyHash string `json:"bodyHash"` + }{ + Schema: certificate.Schema, + BodyHash: frostActivationHex32(certificate.BodyHash), + } + return frostRetainedGroupDomainHash( + frostRetainedGroupCheckpointCertificateDomain, + identity, + ) +} + +func validateFrostRetainedGroupCheckpointCertificateShape( + policy frostRetainedGroupCheckpointPolicy, + certificate FrostRetainedGroupCheckpointCertificate, +) ([32]byte, error) { + if certificate.Schema != frostRetainedGroupCheckpointCertificateSchema || + certificate.BodyHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate has an unsupported schema", + ) + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(certificate.Body) + if err != nil || bodyHash != certificate.BodyHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate body hash mismatch", + ) + } + body := certificate.Body + if body.ProtocolBindingHash != policy.ProtocolBindingHash || + body.ManifestHash != policy.ManifestHash || + body.ProfileHash != policy.ProfileHash || + body.ImplementationSetHash != policy.ImplementationSetHash || + body.ChainID != policy.ChainID || + body.DomainChainID != policy.DomainChainID || + body.GenesisBlockHash != policy.GenesisBlockHash || + body.AuthoritySetHash != policy.AuthoritySetHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate differs from the signed production policy", + ) + } + if body.Sequence == 0 || + body.Sequence > frostRetainedGroupMaximumCanonicalJSONInteger || + body.Point.BlockNumber == 0 || + body.Point.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.Point.BlockHash == [32]byte{} || + body.HistoryRoot == [32]byte{} || + body.CanonicalGeneration < policy.CanonicalMinimum || + body.CanonicalGeneration > frostRetainedGroupMaximumCanonicalJSONInteger || + body.CanonicalInventoryRoot == [32]byte{} || + body.QuarantineGeneration < policy.QuarantineMinimum || + body.QuarantineGeneration > frostRetainedGroupMaximumCanonicalJSONInteger || + body.QuarantineEventRoot == [32]byte{} || + body.QuarantineActiveRoot == [32]byte{} || + body.QuarantineTombstoneRoot == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint body is incomplete or outside canonical bounds", + ) + } + if uint64(len(certificate.Signatures)) < policy.AuthorityThreshold || + len(certificate.Signatures) > len(policy.Authorities) { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate does not carry the required quorum", + ) + } + authorityByID := make( + map[string]FrostRetainedGroupAuthority, + len(policy.Authorities), + ) + for _, authority := range policy.Authorities { + authorityByID[authority.AuthorityID] = authority + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + previousID := "" + for index, signature := range certificate.Signatures { + if !validFrostRetainedGroupAuthorityID(signature.AuthorityID) || + (index > 0 && signature.AuthorityID <= previousID) { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint signatures are not strictly sorted and unique", + ) + } + previousID = signature.AuthorityID + authority, known := authorityByID[signature.AuthorityID] + if !known { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate contains unknown authority [%s]", + signature.AuthorityID, + ) + } + if len(signature.SignerPublicKeySPKI) > 2048 || + len(signature.Signature) > 128 { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] credential exceeds its bound", + signature.AuthorityID, + ) + } + publicKeyDER, err := base64.StdEncoding.Strict().DecodeString( + signature.SignerPublicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || + len(publicKeyDER) > 1024 || + base64.StdEncoding.EncodeToString(publicKeyDER) != + signature.SignerPublicKeySPKI || + sha256.Sum256(publicKeyDER) != authority.PublicKeySPKIHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] supplied an unpinned key", + signature.AuthorityID, + ) + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot parse FROST checkpoint authority [%s] key: [%w]", + signature.AuthorityID, + err, + ) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] key is not Ed25519", + signature.AuthorityID, + ) + } + if err := validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + publicKey, + ); err != nil { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] key is not a nonidentity prime-order Ed25519 point: [%w]", + signature.AuthorityID, + err, + ) + } + signatureBytes, err := base64.StdEncoding.Strict().DecodeString( + signature.Signature, + ) + if err != nil || len(signatureBytes) != ed25519.SignatureSize || + base64.StdEncoding.EncodeToString(signatureBytes) != + signature.Signature || + !ed25519.Verify(publicKey, signatureHash[:], signatureBytes) { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] signature is invalid", + signature.AuthorityID, + ) + } + } + certificateHash, err := frostRetainedGroupCheckpointCertificateHash( + certificate, + ) + if err != nil || certificateHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "cannot hash FROST checkpoint certificate: [%v]", + err, + ) + } + return certificateHash, nil +} + +func validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + publicKey ed25519.PublicKey, +) error { + if len(publicKey) != ed25519.PublicKeySize { + return fmt.Errorf("invalid Ed25519 public-key length") + } + parsed, err := edwards.ParsePubKey(publicKey) + if err != nil || + !bytes.Equal(parsed.Serialize(), publicKey) { + return fmt.Errorf("invalid or noncanonical Ed25519 point encoding") + } + if parsed.GetX().Sign() == 0 && + parsed.GetY().Cmp(big.NewInt(1)) == 0 { + return fmt.Errorf("Ed25519 identity point is forbidden") + } + curve := edwards.Edwards() + x, y := curve.ScalarMult( + parsed.GetX(), + parsed.GetY(), + curve.Params().N.Bytes(), + ) + if x == nil || y == nil || + x.Sign() != 0 || + y.Cmp(big.NewInt(1)) != 0 { + return fmt.Errorf("Ed25519 point is outside the prime-order subgroup") + } + return nil +} + +// VerifyFrostRetainedGroupCheckpointProof validates an inclusive certificate +// proof from a rollback-independent floor through the exact durable head. The +// floor cursor is supplied by an external transparency channel; the proof must +// include the corresponding full floor certificate even when floor and head +// are equal. +func VerifyFrostRetainedGroupCheckpointProof( + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, + floor FrostRetainedGroupCheckpointCursor, + commitment FrostRetainedGroupCheckpointCommitment, + certificates []FrostRetainedGroupCheckpointCertificate, +) error { + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return fmt.Errorf("invalid FROST checkpoint proof policy: [%w]", err) + } + if floor.Sequence < policy.MinimumSequence || + floor.Sequence > frostRetainedGroupMaximumCanonicalJSONInteger || + floor.CertificateHash == [32]byte{} || + commitment.DurableHead.Sequence < floor.Sequence || + commitment.DurableHead.Sequence > + frostRetainedGroupMaximumCanonicalJSONInteger || + commitment.DurableHead.CertificateHash == [32]byte{} || + len(certificates) == 0 || + uint64(len(certificates)) != + commitment.DurableHead.Sequence-floor.Sequence+1 || + uint64(len(certificates)) > + frostRetainedGroupMaximumHandshakeAncestry+1 { + return fmt.Errorf( + "FROST checkpoint proof bounds or cursors are invalid", + ) + } + var previousHash [32]byte + var previousBody FrostRetainedGroupCheckpointBody + for index, certificate := range certificates { + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + return fmt.Errorf( + "invalid FROST checkpoint proof certificate [%d]: [%w]", + index, + err, + ) + } + body := certificate.Body + if index == 0 { + if body.Sequence != floor.Sequence || + certificateHash != floor.CertificateHash { + return fmt.Errorf( + "FROST checkpoint proof does not contain the exact external floor", + ) + } + if body.Sequence == policy.MinimumSequence && + body.PreviousCertificateHash != policy.PredecessorHash { + return fmt.Errorf( + "FROST checkpoint proof floor does not extend the manifest predecessor", + ) + } + if body.Sequence > policy.MinimumSequence && + body.PreviousCertificateHash == [32]byte{} { + return fmt.Errorf( + "FROST checkpoint proof floor has no predecessor", + ) + } + } else if body.Sequence != previousBody.Sequence+1 || + body.PreviousCertificateHash != previousHash || + body.Point.BlockNumber <= previousBody.Point.BlockNumber || + body.CanonicalGeneration < + previousBody.CanonicalGeneration || + body.QuarantineGeneration < + previousBody.QuarantineGeneration { + return fmt.Errorf( + "FROST checkpoint proof has a gap, fork, rollback, or nonmonotonic successor", + ) + } + previousHash = certificateHash + previousBody = body + } + if _, err := frostRetainedGroupCheckpointProofCanonicalSize( + certificates, + frostRetainedGroupMaximumHandshakeProofBytes, + ); err != nil { + return err + } + + tail := certificates[len(certificates)-1].Body + if previousBody.Sequence != commitment.DurableHead.Sequence || + previousHash != commitment.DurableHead.CertificateHash || + tail.Point != commitment.Point || + tail.HistoryRoot != commitment.HistoryRoot || + tail.CanonicalGeneration != commitment.CanonicalGeneration || + tail.CanonicalInventoryRoot != + commitment.CanonicalInventoryRoot || + tail.QuarantineGeneration != commitment.QuarantineGeneration || + tail.QuarantineEventRoot != commitment.QuarantineEventRoot || + tail.QuarantineActiveRoot != commitment.QuarantineActiveRoot || + tail.QuarantineTombstoneRoot != + commitment.QuarantineTombstoneRoot { + return fmt.Errorf( + "FROST checkpoint proof tail differs from the exact durable commitment", + ) + } + return nil +} + +func frostRetainedGroupCheckpointProofCanonicalSize( + certificates []FrostRetainedGroupCheckpointCertificate, + maximum int, +) (int, error) { + if maximum <= 0 { + return 0, fmt.Errorf("FROST checkpoint proof byte limit is invalid") + } + size := 2 // JSON array brackets. + for index, certificate := range certificates { + encoded, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(certificate), + ) + if err != nil { + return 0, fmt.Errorf( + "cannot encode FROST checkpoint proof certificate [%d]: [%w]", + index, + err, + ) + } + delimiter := 0 + if index > 0 { + delimiter = 1 + } + if len(encoded) > maximum-size-delimiter { + return 0, fmt.Errorf( + "FROST checkpoint proof exceeds the canonical byte limit", + ) + } + size += delimiter + len(encoded) + } + return size, nil +} + +func frostRetainedGroupCheckpointChainRoot( + bindingHash [32]byte, + after FrostRetainedGroupCheckpointCursor, + certificateHashes [][32]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupCheckpointChainDomain)) + hasher.Write(bindingHash[:]) + sequence := [8]byte{} + for index := uint(0); index < 8; index++ { + sequence[7-index] = byte(after.Sequence >> (8 * index)) + } + hasher.Write(sequence[:]) + hasher.Write(after.CertificateHash[:]) + count := [8]byte{} + for index := uint(0); index < 8; index++ { + count[7-index] = byte(uint64(len(certificateHashes)) >> (8 * index)) + } + hasher.Write(count[:]) + for _, certificateHash := range certificateHashes { + hasher.Write(certificateHash[:]) + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func validateFrostRetainedGroupCheckpointSuffix( + policy frostRetainedGroupCheckpointPolicy, + after FrostRetainedGroupCheckpointCursor, + certificates []FrostRetainedGroupCheckpointCertificate, +) ([][32]byte, error) { + if after.Sequence+1 < after.Sequence || + after.Sequence+1 > frostRetainedGroupMaximumCanonicalJSONInteger { + return nil, fmt.Errorf("FROST checkpoint cursor overflows") + } + if after.Sequence == policy.MinimumSequence-1 { + if after.CertificateHash != policy.PredecessorHash { + return nil, fmt.Errorf( + "FROST checkpoint suffix does not start at the manifest transparency floor", + ) + } + } else if after.Sequence < policy.MinimumSequence || + after.CertificateHash == [32]byte{} { + return nil, fmt.Errorf( + "FROST checkpoint cursor is below the manifest transparency floor", + ) + } + if len(certificates) == 0 { + if after.Sequence < policy.MinimumSequence || + after.CertificateHash == [32]byte{} { + return nil, fmt.Errorf( + "fresh FROST checkpoint state requires the manifest-minimum certificate", + ) + } + return [][32]byte{}, nil + } + hashes := make([][32]byte, len(certificates)) + previousSequence := after.Sequence + previousHash := after.CertificateHash + var previousPoint FrostPreSignFinality + var previousCanonicalGeneration uint64 + var previousQuarantineGeneration uint64 + for index, certificate := range certificates { + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid FROST checkpoint certificate [%d]: [%w]", + index, + err, + ) + } + body := certificate.Body + if body.Sequence != previousSequence+1 || + body.PreviousCertificateHash != previousHash { + return nil, fmt.Errorf( + "FROST checkpoint sequence has a gap, fork, or rollback at [%d]", + body.Sequence, + ) + } + if index > 0 && + (body.Point.BlockNumber <= previousPoint.BlockNumber || + body.CanonicalGeneration < previousCanonicalGeneration || + body.QuarantineGeneration < previousQuarantineGeneration) { + return nil, fmt.Errorf( + "FROST checkpoint point or generation is not strictly monotonic", + ) + } + hashes[index] = certificateHash + previousSequence = body.Sequence + previousHash = certificateHash + previousPoint = body.Point + previousCanonicalGeneration = body.CanonicalGeneration + previousQuarantineGeneration = body.QuarantineGeneration + } + return hashes, nil +} + +func frostRetainedGroupCertifiedStateFromHistory( + policy frostRetainedGroupCheckpointPolicy, + from FrostPreSignFinality, + point FrostPreSignFinality, + mutations []FrostRetainedGroupMutation, +) (FrostRetainedGroupCheckpointBody, error) { + if point.BlockNumber <= from.BlockNumber || + point.BlockHash == [32]byte{} { + return FrostRetainedGroupCheckpointBody{}, fmt.Errorf( + "FROST checkpoint point is not above the empty history baseline", + ) + } + prefix := make([]FrostRetainedGroupMutation, 0, len(mutations)) + for _, mutation := range mutations { + if mutation.Point.BlockNumber > point.BlockNumber { + break + } + if mutation.Point.BlockNumber == point.BlockNumber && + mutation.Point.BlockHash != point.BlockHash { + return FrostRetainedGroupCheckpointBody{}, fmt.Errorf( + "FROST checkpoint point conflicts with mutation block hash", + ) + } + prefix = append(prefix, mutation) + } + canonical := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + BindingHash: policy.ProtocolBindingHash, + CurrentPoint: from, + Wallets: []frostRetainedGroupWalletState{}, + } + if err := applyFrostRetainedGroupMutations( + &canonical, + frostRetainedGroupCanonicalMutations(prefix), + ); err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + canonical.CurrentPoint = point + inventoryRoot, _, _, _, err := frostRetainedGroupInventoryRoot(canonical) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + emptyActiveRoot, err := frostRetainedGroupQuarantineActiveRoot( + policy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + emptyTombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + policy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + quarantine := frostRetainedGroupQuarantineJournalState{ + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: policy.ProtocolBindingHash, + CurrentPoint: from, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + ActiveRoot: emptyActiveRoot, + TombstoneRoot: emptyTombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, + } + if err := applyFrostRetainedGroupQuarantineMutations( + &quarantine, + frostRetainedGroupQuarantineMutations(prefix), + policy.LiftPolicy, + ); err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + quarantine.CurrentPoint = point + wireMutations := make( + []frostRetainedGroupWireMutation, + len(prefix), + ) + for index, mutation := range prefix { + wireMutations[index] = frostRetainedGroupMutationToWire(mutation) + } + query := frostRetainedGroupHistoryQuery{ + Schema: frostRetainedGroupHistoryRequestSchema, + BindingHash: frostActivationHex32(policy.ProtocolBindingHash), + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(point), + } + queryHash, err := frostRetainedGroupDomainHash( + frostRetainedGroupHistoryQueryDomain, + query, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + historyRoot, err := frostRetainedGroupHistoryRoot( + policy.ProtocolBindingHash, + queryHash, + wireMutations, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + return FrostRetainedGroupCheckpointBody{ + Point: point, + HistoryRoot: historyRoot, + CanonicalGeneration: canonical.SnapshotGeneration, + CanonicalInventoryRoot: inventoryRoot, + QuarantineGeneration: quarantine.Generation, + QuarantineEventRoot: quarantine.Root, + QuarantineActiveRoot: quarantine.ActiveRoot, + QuarantineTombstoneRoot: quarantine.TombstoneRoot, + }, nil +} + +func validateFrostRetainedGroupCheckpointSemantics( + policy frostRetainedGroupCheckpointPolicy, + history *FrostRetainedGroupHistory, + hashes [][32]byte, +) error { + if history == nil || len(history.Checkpoints) != len(hashes) || + len(history.Checkpoints) == 0 { + return fmt.Errorf( + "FROST checkpoint semantic history is incomplete", + ) + } + if history.CheckpointChainRoot != + frostRetainedGroupCheckpointChainRoot( + policy.ProtocolBindingHash, + history.CheckpointAfter, + hashes, + ) { + return fmt.Errorf( + "FROST history receipt does not bind the exact checkpoint suffix", + ) + } + for index, certificate := range history.Checkpoints { + expected, err := frostRetainedGroupCertifiedStateFromHistory( + policy, + history.From, + certificate.Body.Point, + history.Mutations, + ) + if err != nil { + return fmt.Errorf( + "cannot derive FROST checkpoint semantic state [%d]: [%w]", + index, + err, + ) + } + body := certificate.Body + if body.HistoryRoot != expected.HistoryRoot || + body.CanonicalGeneration != expected.CanonicalGeneration || + body.CanonicalInventoryRoot != expected.CanonicalInventoryRoot || + body.QuarantineGeneration != expected.QuarantineGeneration || + body.QuarantineEventRoot != expected.QuarantineEventRoot || + body.QuarantineActiveRoot != expected.QuarantineActiveRoot || + body.QuarantineTombstoneRoot != expected.QuarantineTombstoneRoot { + return fmt.Errorf( + "FROST checkpoint certificate [%d] does not commit the independently derived semantic state", + index, + ) + } + } + tail := history.Checkpoints[len(history.Checkpoints)-1] + if hashes[len(hashes)-1] != history.CheckpointTipHash { + return fmt.Errorf( + "FROST checkpoint tail digest differs from the receipt", + ) + } + if history.CheckpointComplete && + (tail.Body.Point != history.To || + tail.Body.HistoryRoot != history.HistoryRoot) { + return fmt.Errorf( + "FROST checkpoint tail does not bind the exact finalized target and receipt root", + ) + } + if !history.CheckpointComplete && + tail.Body.Point.BlockNumber >= history.To.BlockNumber { + return fmt.Errorf( + "nonfinal FROST checkpoint page does not precede the exact finalized target", + ) + } + return nil +} + +func frostRetainedGroupCheckpointFileName( + sequence uint64, + certificateHash [32]byte, +) string { + return fmt.Sprintf( + "%s%020d-%s%s", + frostRetainedGroupCheckpointFilePrefix, + sequence, + hex.EncodeToString(certificateHash[:]), + frostRetainedGroupJournalFileSuffix, + ) +} + +func frostRetainedGroupCheckpointStateFromCertificate( + bindingHash [32]byte, + certificate FrostRetainedGroupCheckpointCertificate, + certificateHash [32]byte, +) frostRetainedGroupCheckpointJournalState { + body := certificate.Body + return frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: bindingHash, + Sequence: body.Sequence, + CertificateHash: certificateHash, + Point: body.Point, + HistoryRoot: body.HistoryRoot, + CanonicalGeneration: body.CanonicalGeneration, + CanonicalInventoryRoot: body.CanonicalInventoryRoot, + QuarantineGeneration: body.QuarantineGeneration, + QuarantineEventRoot: body.QuarantineEventRoot, + QuarantineActiveRoot: body.QuarantineActiveRoot, + QuarantineTombstoneRoot: body.QuarantineTombstoneRoot, + } +} + +func equalFrostRetainedGroupCheckpointStates( + left frostRetainedGroupCheckpointJournalState, + right frostRetainedGroupCheckpointJournalState, +) bool { + return left == right +} + +func (frgj *frostRetainedGroupJournal) initializeCheckpointJournal() error { + if err := recoverFrostRetainedGroupJournalTemporaryFiles( + frgj.checkpointDirectory, + ); err != nil { + return fmt.Errorf( + "cannot recover interrupted FROST checkpoint persistence: [%w]", + err, + ) + } + entries, err := os.ReadDir(frgj.checkpointDirectory) + if err != nil { + return fmt.Errorf("cannot read FROST checkpoint journal: [%w]", err) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + metadataExists := false + stateExists := false + certificateNames := make([]string, 0) + for _, entry := range entries { + name := entry.Name() + if name == frostRetainedGroupJournalLockFile { + continue + } + if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() { + return fmt.Errorf("unsafe entry in FROST checkpoint journal: [%s]", name) + } + switch { + case name == frostRetainedGroupCheckpointMetadataFile: + metadataExists = true + case name == frostRetainedGroupCheckpointStateFile: + stateExists = true + case strings.HasPrefix(name, frostRetainedGroupCheckpointFilePrefix) && + strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): + certificateNames = append(certificateNames, name) + default: + return fmt.Errorf( + "unexpected file in FROST checkpoint journal: [%s]", + name, + ) + } + } + expectedMetadata := frostRetainedGroupCheckpointMetadata{ + Schema: frostRetainedGroupCheckpointMetadataSchema, + ManifestHash: frgj.checkpointPolicy.ManifestHash, + BindingHash: frgj.checkpointPolicy.ProtocolBindingHash, + AuthoritySetHash: frgj.checkpointPolicy.AuthoritySetHash, + AuthorityThreshold: frgj.checkpointPolicy.AuthorityThreshold, + Authorities: append( + []FrostRetainedGroupAuthority{}, + frgj.checkpointPolicy.Authorities..., + ), + MinimumSequence: frgj.checkpointPolicy.MinimumSequence, + PredecessorHash: frgj.checkpointPolicy.PredecessorHash, + } + if metadataExists { + storedMetadata := frostRetainedGroupCheckpointMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointMetadataFile, + &storedMetadata, + ); err != nil { + return fmt.Errorf( + "cannot read FROST checkpoint metadata: [%w]", + err, + ) + } + stored, storedErr := frostRetainedGroupCanonicalValue(storedMetadata) + expected, expectedErr := frostRetainedGroupCanonicalValue(expectedMetadata) + if storedErr != nil || expectedErr != nil || + !bytes.Equal(stored, expected) { + return fmt.Errorf( + "FROST checkpoint metadata differs from the signed manifest", + ) + } + } else { + if stateExists || len(certificateNames) != 0 { + return fmt.Errorf( + "FROST checkpoint journal has state without immutable metadata", + ) + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointMetadataFile, + expectedMetadata, + false, + ); err != nil { + return fmt.Errorf( + "cannot persist FROST checkpoint metadata: [%w]", + err, + ) + } + } + + initial := frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: frgj.checkpointPolicy.ProtocolBindingHash, + Sequence: frgj.checkpointPolicy.MinimumSequence - 1, + CertificateHash: frgj.checkpointPolicy.PredecessorHash, + } + storedState := initial + if stateExists { + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointStateFile, + &storedState, + ); err != nil { + return fmt.Errorf( + "cannot read FROST checkpoint journal state: [%w]", + err, + ) + } + if storedState.Schema != frostRetainedGroupCheckpointStateSchema || + storedState.BindingHash != + frgj.checkpointPolicy.ProtocolBindingHash { + return fmt.Errorf( + "unsupported or differently bound FROST checkpoint state", + ) + } + } + + rebuilt := initial + matchedStored := equalFrostRetainedGroupCheckpointStates( + storedState, + initial, + ) + for index, name := range certificateNames { + wire := frostRetainedGroupWireCheckpointCertificate{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + name, + &wire, + ); err != nil { + return fmt.Errorf( + "cannot read immutable FROST checkpoint certificate [%s]: [%w]", + name, + err, + ) + } + certificate, err := + frostRetainedGroupCheckpointCertificateFromWire(wire) + if err != nil { + return fmt.Errorf( + "cannot decode immutable FROST checkpoint certificate [%s]: [%w]", + name, + err, + ) + } + if rebuilt.Sequence >= frgj.checkpointPolicy.MinimumSequence && + (certificate.Body.Point.BlockNumber <= + rebuilt.Point.BlockNumber || + certificate.Body.CanonicalGeneration < + rebuilt.CanonicalGeneration || + certificate.Body.QuarantineGeneration < + rebuilt.QuarantineGeneration) { + return fmt.Errorf( + "immutable FROST checkpoint certificate [%s] is not monotonic", + name, + ) + } + hashes, err := validateFrostRetainedGroupCheckpointSuffix( + frgj.checkpointPolicy, + FrostRetainedGroupCheckpointCursor{ + Sequence: rebuilt.Sequence, + CertificateHash: rebuilt.CertificateHash, + }, + []FrostRetainedGroupCheckpointCertificate{certificate}, + ) + if err != nil { + return fmt.Errorf( + "invalid immutable FROST checkpoint certificate [%s]: [%w]", + name, + err, + ) + } + certificateHash := hashes[0] + expectedName := frostRetainedGroupCheckpointFileName( + certificate.Body.Sequence, + certificateHash, + ) + if name != expectedName { + return fmt.Errorf( + "immutable FROST checkpoint filename [%s] does not match its sequence and digest", + name, + ) + } + if index > 0 && + certificate.Body.Sequence != + frgj.checkpointPolicy.MinimumSequence+uint64(index) { + return fmt.Errorf( + "FROST checkpoint certificate sequence has a filesystem gap", + ) + } + frgj.checkpointCertificates[certificate.Body.Sequence] = certificate + frgj.checkpointHashes[certificate.Body.Sequence] = certificateHash + rebuilt = frostRetainedGroupCheckpointStateFromCertificate( + frgj.checkpointPolicy.ProtocolBindingHash, + certificate, + certificateHash, + ) + if rebuilt.Sequence == storedState.Sequence { + if !equalFrostRetainedGroupCheckpointStates( + rebuilt, + storedState, + ) { + return fmt.Errorf( + "FROST checkpoint state differs from its exact certificate prefix", + ) + } + matchedStored = true + } + } + if storedState.Sequence > rebuilt.Sequence || !matchedStored { + return fmt.Errorf( + "FROST checkpoint state has no exact immutable certificate prefix", + ) + } + if rebuilt.Sequence >= frgj.checkpointPolicy.MinimumSequence { + if err := frgj.validateCheckpointAgainstDurablePrefix(rebuilt); err != nil { + return fmt.Errorf( + "durable FROST checkpoint is not an exact prefix of the canonical and quarantine journals: [%w]", + err, + ) + } + } + frgj.checkpointState = rebuilt + if !stateExists || storedState.Sequence != rebuilt.Sequence { + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointStateFile, + rebuilt, + true, + ); err != nil { + return fmt.Errorf( + "cannot integrate orphan FROST checkpoint certificate: [%w]", + err, + ) + } + } + return nil +} + +func (frgj *frostRetainedGroupJournal) validateCheckpointAgainstDurableState( + checkpoint frostRetainedGroupCheckpointJournalState, +) error { + if checkpoint.Point != frgj.state.CurrentPoint || + checkpoint.Point != frgj.quarantineState.CurrentPoint || + checkpoint.CanonicalGeneration != frgj.state.SnapshotGeneration || + checkpoint.CanonicalInventoryRoot != frgj.state.InventoryRoot || + checkpoint.QuarantineGeneration != frgj.quarantineState.Generation || + checkpoint.QuarantineEventRoot != frgj.quarantineState.Root || + checkpoint.QuarantineActiveRoot != frgj.quarantineState.ActiveRoot || + checkpoint.QuarantineTombstoneRoot != + frgj.quarantineState.TombstoneRoot { + return fmt.Errorf( + "checkpoint roots or generations differ from durable semantic state", + ) + } + return nil +} + +func (frgj *frostRetainedGroupJournal) validateCheckpointAgainstDurablePrefix( + checkpoint frostRetainedGroupCheckpointJournalState, +) error { + if checkpoint.Point.BlockNumber > frgj.state.CurrentPoint.BlockNumber || + checkpoint.Point.BlockNumber > + frgj.quarantineState.CurrentPoint.BlockNumber { + return fmt.Errorf( + "checkpoint is ahead of a durable semantic journal", + ) + } + mutations := append( + cloneFrostRetainedGroupMutations(frgj.mutations), + cloneFrostRetainedGroupMutations(frgj.quarantineMutations)..., + ) + sort.Slice(mutations, func(i, j int) bool { + return compareFrostRetainedGroupEventPoints( + mutations[i].Point, + mutations[j].Point, + ) < 0 + }) + for index := 1; index < len(mutations); index++ { + if compareFrostRetainedGroupEventPoints( + mutations[index-1].Point, + mutations[index].Point, + ) >= 0 { + return fmt.Errorf( + "durable semantic journals overlap or disagree in event order", + ) + } + } + expected, err := frostRetainedGroupCertifiedStateFromHistory( + frgj.checkpointPolicy, + frgj.metadata.Checkpoint, + checkpoint.Point, + mutations, + ) + if err != nil { + return err + } + if checkpoint.HistoryRoot != expected.HistoryRoot || + checkpoint.CanonicalGeneration != expected.CanonicalGeneration || + checkpoint.CanonicalInventoryRoot != expected.CanonicalInventoryRoot || + checkpoint.QuarantineGeneration != expected.QuarantineGeneration || + checkpoint.QuarantineEventRoot != expected.QuarantineEventRoot || + checkpoint.QuarantineActiveRoot != expected.QuarantineActiveRoot || + checkpoint.QuarantineTombstoneRoot != + expected.QuarantineTombstoneRoot { + return fmt.Errorf( + "checkpoint roots or generations differ from the durable semantic prefix", + ) + } + return nil +} + +func (frgj *frostRetainedGroupJournal) persistCheckpointSuffix( + certificates []FrostRetainedGroupCheckpointCertificate, + hashes [][32]byte, +) error { + if len(certificates) == 0 || len(certificates) != len(hashes) { + return fmt.Errorf("FROST checkpoint certificate/hash count mismatch") + } + tail := len(certificates) - 1 + next := frostRetainedGroupCheckpointStateFromCertificate( + frgj.checkpointPolicy.ProtocolBindingHash, + certificates[tail], + hashes[tail], + ) + if next.Point == frgj.state.CurrentPoint && + next.Point == frgj.quarantineState.CurrentPoint { + if err := frgj.validateCheckpointAgainstDurableState(next); err != nil { + return err + } + } else if err := frgj.validateCheckpointAgainstDurablePrefix(next); err != nil { + return err + } + existingEntries, err := os.ReadDir(frgj.checkpointDirectory) + if err != nil { + return fmt.Errorf( + "cannot inspect immutable FROST checkpoint certificates: [%w]", + err, + ) + } + persistedCertificates := make( + []FrostRetainedGroupCheckpointCertificate, + len(certificates), + ) + for index, certificate := range certificates { + hash := hashes[index] + sequence := certificate.Body.Sequence + if _, exists := frgj.checkpointCertificates[sequence]; exists { + return fmt.Errorf( + "FROST checkpoint suffix contains an already persisted sequence [%d]", + sequence, + ) + } + persistedCertificate, err := + frgj.persistOrAdoptCheckpointCertificate( + certificate, + hash, + existingEntries, + ) + if err != nil { + return fmt.Errorf( + "cannot persist immutable FROST checkpoint certificate [%d]: [%w]", + sequence, + err, + ) + } + persistedCertificates[index] = persistedCertificate + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-checkpoint-certificate-before-next", + ); err != nil { + return err + } + } + } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-checkpoint-certificates-before-state", + ); err != nil { + return err + } + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointStateFile, + next, + true, + ); err != nil { + return fmt.Errorf( + "cannot advance durable FROST checkpoint head: [%w]", + err, + ) + } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-checkpoint-state-before-memory", + ); err != nil { + return err + } + } + for index, certificate := range persistedCertificates { + sequence := certificate.Body.Sequence + frgj.checkpointCertificates[sequence] = certificate + frgj.checkpointHashes[sequence] = hashes[index] + } + frgj.checkpointState = next + return nil +} + +func (frgj *frostRetainedGroupJournal) persistOrAdoptCheckpointCertificate( + certificate FrostRetainedGroupCheckpointCertificate, + certificateHash [32]byte, + existingEntries []os.DirEntry, +) (FrostRetainedGroupCheckpointCertificate, error) { + sequence := certificate.Body.Sequence + expectedName := frostRetainedGroupCheckpointFileName( + sequence, + certificateHash, + ) + sequencePrefix := fmt.Sprintf( + "%s%020d-", + frostRetainedGroupCheckpointFilePrefix, + sequence, + ) + existingName := "" + for _, entry := range existingEntries { + name := entry.Name() + if !strings.HasPrefix(name, sequencePrefix) || + !strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix) { + continue + } + if existingName != "" || name != expectedName { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "conflicting immutable checkpoint certificate exists for sequence [%d]", + sequence, + ) + } + existingName = name + } + expectedWire := + frostRetainedGroupCheckpointCertificateToWire(certificate) + if existingName == "" { + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + expectedName, + expectedWire, + false, + ); err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + return certificate, nil + } + storedWire := frostRetainedGroupWireCheckpointCertificate{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + existingName, + &storedWire, + ); err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot read orphan checkpoint certificate: [%w]", + err, + ) + } + storedCertificate, err := + frostRetainedGroupCheckpointCertificateFromWire(storedWire) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot decode orphan checkpoint certificate: [%w]", + err, + ) + } + storedHash, err := validateFrostRetainedGroupCheckpointCertificateShape( + frgj.checkpointPolicy, + storedCertificate, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot validate orphan checkpoint certificate: [%w]", + err, + ) + } + if storedHash != certificateHash || + storedCertificate.Schema != certificate.Schema || + storedCertificate.BodyHash != certificate.BodyHash || + storedCertificate.Body != certificate.Body { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "orphan checkpoint certificate differs from the requested checkpoint body", + ) + } + // The durable encoding wins when the incoming certificate carries a + // different, but equally valid, quorum subset for the same stable body. + return storedCertificate, nil +} + +func (frgj *frostRetainedGroupJournal) checkpointDescendsFrom( + floor FrostRetainedGroupCheckpointCursor, +) bool { + if floor.Sequence < frgj.checkpointPolicy.MinimumSequence || + floor.CertificateHash == [32]byte{} || + floor.Sequence > frgj.checkpointState.Sequence { + return false + } + hash, exists := frgj.checkpointHashes[floor.Sequence] + return exists && hash == floor.CertificateHash +} + +func (frgj *frostRetainedGroupJournal) checkpointAncestryFrom( + floor FrostRetainedGroupCheckpointCursor, +) ([]FrostRetainedGroupCheckpointCertificate, error) { + if !frgj.checkpointDescendsFrom(floor) { + return nil, fmt.Errorf( + "FROST checkpoint head does not descend from the external transparency floor", + ) + } + distance := frgj.checkpointState.Sequence - floor.Sequence + if distance > frostRetainedGroupMaximumHandshakeAncestry { + return nil, fmt.Errorf( + "external FROST checkpoint floor is too stale; obtain a fresh rollback-independent transparency floor", + ) + } + result := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + int(distance+1), + ) + proofBytes := 2 // JSON array brackets. + for sequence := floor.Sequence; sequence <= frgj.checkpointState.Sequence; sequence++ { + certificate, exists := frgj.checkpointCertificates[sequence] + if !exists { + return nil, fmt.Errorf( + "FROST checkpoint ancestry is missing certificate [%d]", + sequence, + ) + } + encoded, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(certificate), + ) + if err != nil { + return nil, fmt.Errorf( + "cannot encode FROST checkpoint ancestry certificate [%d]: [%w]", + sequence, + err, + ) + } + delimiter := 0 + if len(result) > 0 { + delimiter = 1 + } + if len(encoded) > + frostRetainedGroupMaximumHandshakeProofBytes- + proofBytes-delimiter { + return nil, fmt.Errorf( + "FROST checkpoint ancestry exceeds the canonical byte limit", + ) + } + proofBytes += delimiter + len(encoded) + result = append(result, certificate) + } + return result, nil +} diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity.go b/pkg/tbtc/frost_retained_group_endpoint_identity.go new file mode 100644 index 0000000000..6b98779056 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_endpoint_identity.go @@ -0,0 +1,2045 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "io" + "net" + "net/http" + "net/http/httptrace" + "net/netip" + "net/url" + "path" + "sort" + "strconv" + "strings" + "time" +) + +const ( + frostRetainedGroupEndpointIdentitySchema = "tbtc-frost-retained-group-endpoint-identity/v1" + frostRetainedGroupSourceIdentitySchema = "tbtc-frost-retained-group-source-identity/v1" + frostRetainedGroupEndpointIdentityDomain = "tbtc-frost-retained-group-endpoint-identity/v1\x00" + frostRetainedGroupSourceIdentityDomain = "tbtc-frost-retained-group-source-identity/v1\x00" + frostRetainedGroupResolvedAddressSetDomain = "tbtc-frost-retained-group-resolved-address-set/v1\x00" + frostRetainedGroupTransportAttestationSchema = "tbtc-frost-retained-group-transport-attestation/v1" + frostRetainedGroupTransportAttestationDomain = "tbtc-frost-retained-group-transport-attestation/v1\x00" + frostRetainedGroupBackendAttestationDomain = "tbtc-frost-retained-group-backend-attestation/v1\x00" + frostRetainedGroupOperatorAttestationDomain = "tbtc-frost-retained-group-operator-attestation/v1\x00" + frostRetainedGroupTLSExporterProtocolDomain = "tbtc-frost-retained-group-tls-exporter-protocol/v1\x00" + frostRetainedGroupTLSExporterContextDomain = "tbtc-frost-retained-group-tls-exporter-context/v1\x00" + frostRetainedGroupTLSExporterValueDomain = "tbtc-frost-retained-group-tls-exporter-value/v1\x00" + frostRetainedGroupTLSExporterLabel = "EXPORTER-tbtc-frost-retained-group-v1" + frostRetainedGroupTransportAttestationHeader = "Tbtc-Retained-Transport-Attestation" + frostRetainedGroupTransportChallengeHeader = "Tbtc-Retained-Transport-Challenge" + frostRetainedGroupMaximumTransportAttestationBytes = 16 * 1024 + frostRetainedGroupMaximumTransportBodyBytes = 16 * 1024 * 1024 + frostRetainedGroupMaximumResolvedAddresses = 16 + frostRetainedGroupTransportAttestationLifetime = 30 * time.Second + frostRetainedGroupTransportClockSkew = 5 * time.Second +) + +// FrostRetainedGroupEndpointIdentity is one manifest-authenticated endpoint +// role. EndpointFingerprint is derived from every other field by the frozen +// v1 transcript; it is never an operator-authored opaque label. +type FrostRetainedGroupEndpointIdentity struct { + Schema string `json:"schema"` + Role string `json:"role"` + TrustDomainID string `json:"trustDomainID"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedAddressSetHash [32]byte `json:"resolvedAddressSetHash"` + TLSLeafSPKIHash [32]byte `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint [32]byte `json:"backendServiceFingerprint"` + OperatorFingerprint [32]byte `json:"operatorFingerprint"` + AttestationKeyHash [32]byte `json:"attestationKeyHash"` + TLSExporterProtocolID [32]byte `json:"tlsExporterProtocolID"` + EndpointFingerprint [32]byte `json:"endpointFingerprint"` +} + +// FrostRetainedGroupHistoryIdentity is the complete export/verifier trust +// boundary committed by the signed activation manifest. +type FrostRetainedGroupHistoryIdentity struct { + Schema string `json:"schema"` + TrustDomainID string `json:"trustDomainID"` + EndpointFingerprint [32]byte `json:"endpointFingerprint"` + OperatorFingerprint [32]byte `json:"operatorFingerprint"` + HistorySignerKeyHash [32]byte `json:"historySignerKeyHash"` + Export FrostRetainedGroupEndpointIdentity `json:"export"` + Verifier FrostRetainedGroupEndpointIdentity `json:"verifier"` +} + +type frostRetainedGroupWireEndpointIdentity struct { + Schema string `json:"schema"` + Role string `json:"role"` + TrustDomainID string `json:"trustDomainID"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedAddressSetHash string `json:"resolvedAddressSetHash"` + TLSLeafSPKIHash string `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint string `json:"backendServiceFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + AttestationKeyHash string `json:"attestationKeyHash"` + TLSExporterProtocolID string `json:"tlsExporterProtocolID"` + EndpointFingerprint string `json:"endpointFingerprint"` +} + +type frostRetainedGroupWireIdentity struct { + Schema string `json:"schema"` + TrustDomainID string `json:"trustDomainID"` + EndpointFingerprint string `json:"endpointFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + HistorySignerKeyHash string `json:"historySignerKeyHash"` + Export frostRetainedGroupWireEndpointIdentity `json:"export"` + Verifier frostRetainedGroupWireEndpointIdentity `json:"verifier"` +} + +type frostRetainedGroupResolvedEndpoint struct { + endpoint *url.URL + canonical string + canonicalDNSName string + resolvedDNSName string + addresses []netip.Addr + addressSetHash [32]byte +} + +type frostRetainedGroupTransportAttestation struct { + Schema string `json:"schema"` + Role string `json:"role"` + EndpointFingerprint string `json:"endpointFingerprint"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedPeerIP string `json:"resolvedPeerIP"` + TLSLeafSPKIHash string `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint string `json:"backendServiceFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + AttestationKeyHash string `json:"attestationKeyHash"` + TLSExporterProtocolID string `json:"tlsExporterProtocolID"` + Challenge string `json:"challenge"` + RequestMethod string `json:"requestMethod"` + RequestTarget string `json:"requestTarget"` + RequestBodySHA256 string `json:"requestBodySha256"` + ResponseStatus uint64 `json:"responseStatus"` + ResponseBodySHA256 string `json:"responseBodySha256"` + IssuedAtUnixMs string `json:"issuedAtUnixMs"` + ExpiresAtUnixMs string `json:"expiresAtUnixMs"` + TLSExporterContextSHA256 string `json:"tlsExporterContextSha256"` + TLSExporterValueSHA256 string `json:"tlsExporterValueSha256"` + BackendSignerPublicKeySPKI string `json:"backendSignerPublicKeySpki"` + BackendSignatureAlgorithm string `json:"backendSignatureAlgorithm"` + BackendSignature string `json:"backendSignature"` + OperatorSignerPublicKeySPKI string `json:"operatorSignerPublicKeySpki"` + OperatorSignatureAlgorithm string `json:"operatorSignatureAlgorithm"` + OperatorSignature string `json:"operatorSignature"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + Signature string `json:"signature"` +} + +type frostRetainedGroupTransportProof struct { + role string + requestDigest [32]byte + responseDigest [32]byte + challenge [32]byte +} + +type frostRetainedGroupTransportProofKey struct{} + +type frostRetainedGroupAttestedRoundTripper struct { + base http.RoundTripper + endpoint frostRetainedGroupResolvedEndpoint + identity FrostRetainedGroupEndpointIdentity + separationPolicy *frostPrimaryRetainedSeparationPolicy + random io.Reader + now func() time.Time + maximumBodyBytes int64 + maximumClockSkew time.Duration + maximumLifetime time.Duration +} + +type frostRetainedGroupValidatedSourceConfig struct { + exportEndpoint frostRetainedGroupResolvedEndpoint + verifierEndpoint frostRetainedGroupResolvedEndpoint + primaryEndpoint frostRetainedGroupResolvedEndpoint + identity FrostRetainedGroupHistoryIdentity + requestTimeout time.Duration + rootCAs *x509.CertPool +} + +type frostRetainedGroupResolver interface { + LookupCNAME(context.Context, string) (string, error) + LookupNetIP(context.Context, string, string) ([]netip.Addr, error) +} + +type frostPrimaryEthereumIndependenceVerifier interface { + verifyIndependence( + context.Context, + frostRetainedGroupResolvedEndpoint, + frostRetainedGroupResolvedEndpoint, + ) error +} + +type frostRetainedGroupIndependenceMonitor struct { + exportEndpoint frostRetainedGroupResolvedEndpoint + verifierEndpoint frostRetainedGroupResolvedEndpoint + primaryTransport frostPrimaryEthereumIndependenceVerifier +} + +func frostRetainedGroupTLSExporterProtocolID() [32]byte { + return sha256.Sum256([]byte(frostRetainedGroupTLSExporterProtocolDomain)) +} + +// FrostRetainedGroupTLSExporterProtocolID is the compile-time protocol +// identity that every signed manifest endpoint descriptor must commit. +func FrostRetainedGroupTLSExporterProtocolID() [32]byte { + return frostRetainedGroupTLSExporterProtocolID() +} + +// ComputeFrostRetainedGroupEndpointIdentityFingerprint computes the frozen v1 +// endpoint-descriptor transcript. +func ComputeFrostRetainedGroupEndpointIdentityFingerprint( + identity FrostRetainedGroupEndpointIdentity, +) [32]byte { + return computeFrostRetainedGroupEndpointFingerprint(identity) +} + +// ComputeFrostRetainedGroupSourceEndpointFingerprint computes the frozen v1 +// aggregate export/verifier transcript. +func ComputeFrostRetainedGroupSourceEndpointFingerprint( + identity FrostRetainedGroupHistoryIdentity, +) [32]byte { + return computeFrostRetainedGroupSourceEndpointFingerprint(identity) +} + +// ValidateFrostRetainedGroupHistoryIdentity checks the complete endpoint-role +// separation and every derived transcript. +func ValidateFrostRetainedGroupHistoryIdentity( + identity FrostRetainedGroupHistoryIdentity, +) error { + return validateFrostRetainedGroupHistoryIdentity(identity) +} + +func frostRetainedGroupIdentityTranscript(domain string) *frostRetainedGroupTranscript { + transcript := &frostRetainedGroupTranscript{hasher: sha256.New()} + _, _ = transcript.hasher.Write([]byte(domain)) + return transcript +} + +type frostRetainedGroupTranscript struct { + hasher hash.Hash +} + +func (transcript *frostRetainedGroupTranscript) field( + name string, + value []byte, +) { + buffer := [8]byte{} + binary.BigEndian.PutUint64(buffer[:], uint64(len(name))) + _, _ = transcript.hasher.Write(buffer[:]) + _, _ = transcript.hasher.Write([]byte(name)) + binary.BigEndian.PutUint64(buffer[:], uint64(len(value))) + _, _ = transcript.hasher.Write(buffer[:]) + _, _ = transcript.hasher.Write(value) +} + +func (transcript *frostRetainedGroupTranscript) text(name string, value string) { + transcript.field(name, []byte(value)) +} + +func (transcript *frostRetainedGroupTranscript) bytes32( + name string, + value [32]byte, +) { + transcript.field(name, value[:]) +} + +func (transcript *frostRetainedGroupTranscript) uint64( + name string, + value uint64, +) { + buffer := [8]byte{} + binary.BigEndian.PutUint64(buffer[:], value) + transcript.field(name, buffer[:]) +} + +func (transcript *frostRetainedGroupTranscript) sum() [32]byte { + var result [32]byte + copy(result[:], transcript.hasher.Sum(nil)) + return result +} + +func computeFrostRetainedGroupEndpointFingerprint( + identity FrostRetainedGroupEndpointIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupEndpointIdentityDomain, + ) + transcript.text("schema", identity.Schema) + transcript.text("role", identity.Role) + transcript.text("trustDomainID", identity.TrustDomainID) + transcript.text("canonicalEndpoint", identity.CanonicalEndpoint) + transcript.text("canonicalDNSName", identity.CanonicalDNSName) + transcript.text("resolvedDNSName", identity.ResolvedDNSName) + transcript.bytes32("resolvedAddressSetHash", identity.ResolvedAddressSetHash) + transcript.bytes32("tlsLeafSpkiHash", identity.TLSLeafSPKIHash) + transcript.text("serviceIdentity", identity.ServiceIdentity) + transcript.bytes32( + "backendServiceFingerprint", + identity.BackendServiceFingerprint, + ) + transcript.bytes32("operatorFingerprint", identity.OperatorFingerprint) + transcript.bytes32("attestationKeyHash", identity.AttestationKeyHash) + transcript.bytes32( + "tlsExporterProtocolID", + identity.TLSExporterProtocolID, + ) + return transcript.sum() +} + +func computeFrostRetainedGroupSourceEndpointFingerprint( + identity FrostRetainedGroupHistoryIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupSourceIdentityDomain, + ) + transcript.text("schema", identity.Schema) + transcript.text("trustDomainID", identity.TrustDomainID) + transcript.bytes32( + "operatorFingerprint", + identity.OperatorFingerprint, + ) + transcript.bytes32( + "historySignerKeyHash", + identity.HistorySignerKeyHash, + ) + transcript.bytes32( + "exportEndpointFingerprint", + identity.Export.EndpointFingerprint, + ) + transcript.bytes32( + "verifierEndpointFingerprint", + identity.Verifier.EndpointFingerprint, + ) + return transcript.sum() +} + +func frostRetainedGroupEndpointIdentityToWire( + identity FrostRetainedGroupEndpointIdentity, +) frostRetainedGroupWireEndpointIdentity { + return frostRetainedGroupWireEndpointIdentity{ + Schema: identity.Schema, + Role: identity.Role, + TrustDomainID: identity.TrustDomainID, + CanonicalEndpoint: identity.CanonicalEndpoint, + CanonicalDNSName: identity.CanonicalDNSName, + ResolvedDNSName: identity.ResolvedDNSName, + ResolvedAddressSetHash: frostActivationHex32(identity.ResolvedAddressSetHash), + TLSLeafSPKIHash: frostActivationHex32(identity.TLSLeafSPKIHash), + ServiceIdentity: identity.ServiceIdentity, + BackendServiceFingerprint: frostActivationHex32(identity.BackendServiceFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + AttestationKeyHash: frostActivationHex32(identity.AttestationKeyHash), + TLSExporterProtocolID: frostActivationHex32(identity.TLSExporterProtocolID), + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + } +} + +func frostRetainedGroupIdentityToWire( + identity FrostRetainedGroupHistoryIdentity, +) frostRetainedGroupWireIdentity { + return frostRetainedGroupWireIdentity{ + Schema: identity.Schema, + TrustDomainID: identity.TrustDomainID, + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + HistorySignerKeyHash: frostActivationHex32(identity.HistorySignerKeyHash), + Export: frostRetainedGroupEndpointIdentityToWire( + identity.Export, + ), + Verifier: frostRetainedGroupEndpointIdentityToWire( + identity.Verifier, + ), + } +} + +func frostRetainedGroupEndpointIdentityFromWire( + wire frostRetainedGroupWireEndpointIdentity, +) (FrostRetainedGroupEndpointIdentity, error) { + parse := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid %s: [%w]", name, err) + } + return parsed, nil + } + addressSetHash, err := parse( + "resolved address-set hash", + wire.ResolvedAddressSetHash, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + leafHash, err := parse("TLS leaf SPKI hash", wire.TLSLeafSPKIHash) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + backend, err := parse( + "backend service fingerprint", + wire.BackendServiceFingerprint, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + operator, err := parse("operator fingerprint", wire.OperatorFingerprint) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + attestation, err := parse("attestation key hash", wire.AttestationKeyHash) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + exporter, err := parse( + "TLS exporter protocol ID", + wire.TLSExporterProtocolID, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + fingerprint, err := parse( + "endpoint fingerprint", + wire.EndpointFingerprint, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + result := FrostRetainedGroupEndpointIdentity{ + Schema: wire.Schema, + Role: wire.Role, + TrustDomainID: wire.TrustDomainID, + CanonicalEndpoint: wire.CanonicalEndpoint, + CanonicalDNSName: wire.CanonicalDNSName, + ResolvedDNSName: wire.ResolvedDNSName, + ResolvedAddressSetHash: addressSetHash, + TLSLeafSPKIHash: leafHash, + ServiceIdentity: wire.ServiceIdentity, + BackendServiceFingerprint: backend, + OperatorFingerprint: operator, + AttestationKeyHash: attestation, + TLSExporterProtocolID: exporter, + EndpointFingerprint: fingerprint, + } + if err := validateFrostRetainedGroupEndpointIdentity(result); err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + return result, nil +} + +func frostRetainedGroupIdentityFromWire( + wire frostRetainedGroupWireIdentity, +) (FrostRetainedGroupHistoryIdentity, error) { + endpointFingerprint, err := parseFrostActivationHex32( + wire.EndpointFingerprint, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + operatorFingerprint, err := parseFrostActivationHex32( + wire.OperatorFingerprint, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + historySignerKeyHash, err := parseFrostActivationHex32( + wire.HistorySignerKeyHash, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + exportIdentity, err := frostRetainedGroupEndpointIdentityFromWire(wire.Export) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + verifierIdentity, err := frostRetainedGroupEndpointIdentityFromWire( + wire.Verifier, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + result := FrostRetainedGroupHistoryIdentity{ + Schema: wire.Schema, + TrustDomainID: wire.TrustDomainID, + EndpointFingerprint: endpointFingerprint, + OperatorFingerprint: operatorFingerprint, + HistorySignerKeyHash: historySignerKeyHash, + Export: exportIdentity, + Verifier: verifierIdentity, + } + if err := validateFrostRetainedGroupHistoryIdentity(result); err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + return result, nil +} + +func validateFrostRetainedGroupEndpointIdentity( + identity FrostRetainedGroupEndpointIdentity, +) error { + if identity.Schema != frostRetainedGroupEndpointIdentitySchema || + (identity.Role != "retained-history-export" && + identity.Role != "retained-history-verifier") || + !validFrostRetainedGroupIdentityLabel(identity.TrustDomainID) || + identity.CanonicalEndpoint == "" || + identity.CanonicalDNSName == "" || + identity.ResolvedDNSName == "" || + identity.ResolvedAddressSetHash == [32]byte{} || + identity.TLSLeafSPKIHash == [32]byte{} || + identity.BackendServiceFingerprint == [32]byte{} || + identity.OperatorFingerprint == [32]byte{} || + identity.AttestationKeyHash == [32]byte{} || + identity.TLSExporterProtocolID != + frostRetainedGroupTLSExporterProtocolID() || + identity.EndpointFingerprint == [32]byte{} { + return fmt.Errorf("retained-group endpoint identity is incomplete") + } + endpoint, canonical, err := validateFrostRetainedGroupTLSEndpoint( + identity.CanonicalEndpoint, + ) + if err != nil || canonical != identity.CanonicalEndpoint || + endpoint.Hostname() != identity.CanonicalDNSName || + !validFrostRetainedGroupEndpointHostname(identity.ResolvedDNSName) { + return fmt.Errorf("retained-group endpoint identity is not canonical") + } + if err := validateFrostRetainedGroupServiceIdentity( + identity.ServiceIdentity, + ); err != nil { + return err + } + serviceIdentity, err := url.Parse(identity.ServiceIdentity) + if err != nil || serviceIdentity.Hostname() != identity.TrustDomainID { + return fmt.Errorf( + "retained-group endpoint trust domain differs from its SPIFFE authority", + ) + } + if computeFrostRetainedGroupEndpointFingerprint(identity) != + identity.EndpointFingerprint { + return fmt.Errorf("retained-group endpoint fingerprint mismatch") + } + roleHashes := map[[32]byte]string{} + for name, value := range map[string][32]byte{ + "TLS leaf": identity.TLSLeafSPKIHash, + "backend service": identity.BackendServiceFingerprint, + "operator": identity.OperatorFingerprint, + "attestation signer": identity.AttestationKeyHash, + } { + if previous, ok := roleHashes[value]; ok { + return fmt.Errorf( + "retained-group endpoint reuses %s identity as %s", + name, + previous, + ) + } + roleHashes[value] = name + } + return nil +} + +func validateFrostRetainedGroupHistoryIdentity( + identity FrostRetainedGroupHistoryIdentity, +) error { + if identity.Schema != frostRetainedGroupSourceIdentitySchema || + !validFrostRetainedGroupIdentityLabel(identity.TrustDomainID) || + identity.EndpointFingerprint == [32]byte{} || + identity.OperatorFingerprint == [32]byte{} || + identity.HistorySignerKeyHash == [32]byte{} || + identity.Export.Role != "retained-history-export" || + identity.Verifier.Role != "retained-history-verifier" || + identity.OperatorFingerprint != identity.Export.OperatorFingerprint { + return fmt.Errorf("retained-group source identity is incomplete") + } + if err := validateFrostRetainedGroupEndpointIdentity(identity.Export); err != nil { + return fmt.Errorf("invalid retained-group export identity: [%w]", err) + } + if err := validateFrostRetainedGroupEndpointIdentity(identity.Verifier); err != nil { + return fmt.Errorf("invalid retained-group verifier identity: [%w]", err) + } + if identity.Export.TrustDomainID == identity.Verifier.TrustDomainID || + identity.TrustDomainID == identity.Export.TrustDomainID || + identity.TrustDomainID == identity.Verifier.TrustDomainID || + identity.Export.CanonicalEndpoint == identity.Verifier.CanonicalEndpoint || + identity.Export.CanonicalDNSName == identity.Verifier.CanonicalDNSName || + identity.Export.ResolvedDNSName == identity.Verifier.ResolvedDNSName || + identity.Export.ResolvedAddressSetHash == + identity.Verifier.ResolvedAddressSetHash || + identity.Export.ServiceIdentity == identity.Verifier.ServiceIdentity { + return fmt.Errorf("retained-group export and verifier identities are aliased") + } + roleHashes := map[[32]byte]string{} + for name, value := range map[string][32]byte{ + "export endpoint": identity.Export.EndpointFingerprint, + "export TLS leaf": identity.Export.TLSLeafSPKIHash, + "export backend": identity.Export.BackendServiceFingerprint, + "export operator": identity.Export.OperatorFingerprint, + "export attestation": identity.Export.AttestationKeyHash, + "history signer": identity.HistorySignerKeyHash, + "verifier endpoint": identity.Verifier.EndpointFingerprint, + "verifier TLS leaf": identity.Verifier.TLSLeafSPKIHash, + "verifier backend": identity.Verifier.BackendServiceFingerprint, + "verifier operator": identity.Verifier.OperatorFingerprint, + "verifier attestation": identity.Verifier.AttestationKeyHash, + } { + if previous, ok := roleHashes[value]; ok { + return fmt.Errorf( + "retained-group source reuses %s identity as %s", + name, + previous, + ) + } + roleHashes[value] = name + } + if computeFrostRetainedGroupSourceEndpointFingerprint(identity) != + identity.EndpointFingerprint { + return fmt.Errorf("retained-group source endpoint fingerprint mismatch") + } + return nil +} + +func validFrostRetainedGroupIdentityLabel(value string) bool { + return value != "" && + value == strings.TrimSpace(value) && + len(value) <= 128 && + !strings.ContainsAny(value, "\x00\r\n\t") +} + +func validateFrostRetainedGroupServiceIdentity(value string) error { + if value == "" || value != strings.TrimSpace(value) || len(value) > 2048 { + return fmt.Errorf("retained-group service identity is invalid") + } + identity, err := url.Parse(value) + if err != nil || identity.Scheme != "spiffe" || + identity.Host == "" || identity.User != nil || + identity.Port() != "" || identity.Host != identity.Hostname() || + !validFrostRetainedGroupSPIFFETrustDomain(identity.Hostname()) || + identity.RawQuery != "" || identity.Fragment != "" || + identity.RawPath != "" || identity.Host != strings.ToLower(identity.Host) || + identity.Path == "" || identity.Path == "/" || + path.Clean(identity.Path) != identity.Path || + strings.HasSuffix(identity.Path, "/") || + strings.Contains(identity.Path, "//") || + identity.String() != value { + return fmt.Errorf("retained-group service identity is not a canonical SPIFFE URI") + } + for _, segment := range strings.Split(strings.TrimPrefix(identity.Path, "/"), "/") { + if segment == "" || segment == "." || segment == ".." { + return fmt.Errorf("retained-group service identity is not a canonical SPIFFE URI") + } + for _, character := range segment { + if (character < 'a' || character > 'z') && + (character < 'A' || character > 'Z') && + (character < '0' || character > '9') && + !strings.ContainsRune("-._", character) { + return fmt.Errorf("retained-group service identity is not a canonical SPIFFE URI") + } + } + } + return nil +} + +func validFrostRetainedGroupSPIFFETrustDomain(value string) bool { + if value == "" || len(value) > 255 || value != strings.ToLower(value) { + return false + } + for _, character := range value { + if (character < 'a' || character > 'z') && + (character < '0' || character > '9') && + !strings.ContainsRune(".-_", character) { + return false + } + } + return true +} + +func validateFrostRetainedGroupTLSEndpoint( + raw string, +) (*url.URL, string, error) { + if raw == "" || raw != strings.TrimSpace(raw) { + return nil, "", fmt.Errorf("URL is empty or has surrounding whitespace") + } + endpoint, err := url.Parse(raw) + if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || + endpoint.User != nil || endpoint.Fragment != "" || + endpoint.RawQuery != "" || endpoint.Opaque != "" || + endpoint.RawPath != "" || strings.Contains(raw, "\\") || + strings.Contains(endpoint.EscapedPath(), "%") { + return nil, "", fmt.Errorf("URL is not an unambiguous HTTPS endpoint") + } + hostname := endpoint.Hostname() + if hostname == "" || hostname != strings.ToLower(hostname) || + strings.HasSuffix(hostname, ".") || + !validFrostRetainedGroupEndpointHostname(hostname) { + return nil, "", fmt.Errorf("URL hostname is not canonical") + } + port := endpoint.Port() + if port == "" { + return nil, "", fmt.Errorf("HTTPS endpoint must use an explicit port") + } + parsedPort, err := strconv.ParseUint(port, 10, 16) + if err != nil || parsedPort == 0 || strconv.FormatUint(parsedPort, 10) != port { + return nil, "", fmt.Errorf("URL port is not canonical") + } + expectedHost := net.JoinHostPort(hostname, port) + if endpoint.Host != expectedHost { + return nil, "", fmt.Errorf("URL authority is not canonical") + } + if endpoint.Path == "" { + endpoint.Path = "/" + } + if !strings.HasPrefix(endpoint.Path, "/") || + path.Clean(endpoint.Path) != endpoint.Path || + (endpoint.Path != "/" && strings.HasSuffix(endpoint.Path, "/")) || + strings.Contains(endpoint.Path, "//") { + return nil, "", fmt.Errorf("URL path is not canonical") + } + canonical := endpoint.String() + if canonical != raw { + return nil, "", fmt.Errorf("URL is not in canonical form") + } + return endpoint, canonical, nil +} + +func validFrostRetainedGroupEndpointHostname(hostname string) bool { + if parsed, err := netip.ParseAddr(hostname); err == nil { + return parsed.Zone() == "" && parsed.String() == hostname + } + if len(hostname) > 253 { + return false + } + labels := strings.Split(hostname, ".") + if len(labels) < 2 { + return false + } + for _, label := range labels { + if len(label) == 0 || len(label) > 63 || + label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, character := range label { + if (character < 'a' || character > 'z') && + (character < '0' || character > '9') && + character != '-' { + return false + } + } + } + return true +} + +func resolveFrostRetainedGroupEndpoint( + ctx context.Context, + endpoint *url.URL, + resolver frostRetainedGroupResolver, +) (frostRetainedGroupResolvedEndpoint, error) { + if ctx == nil || endpoint == nil { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("retained-group endpoint resolution is incomplete") + } + if resolver == nil { + resolver = net.DefaultResolver + } + hostname := endpoint.Hostname() + addresses := make([]netip.Addr, 0) + canonicalDNSName := hostname + if parsed, err := netip.ParseAddr(hostname); err == nil { + addresses = append(addresses, parsed.Unmap()) + } else { + canonicalName, err := resolver.LookupCNAME(ctx, hostname) + if err != nil { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("cannot resolve retained-group endpoint CNAME: [%w]", err) + } + canonicalDNSName = strings.TrimSuffix( + strings.ToLower(canonicalName), + ".", + ) + if !validFrostRetainedGroupEndpointHostname(canonicalDNSName) { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("retained-group endpoint CNAME is not canonical") + } + resolved, err := resolver.LookupNetIP(ctx, "ip", hostname) + if err != nil { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("cannot resolve retained-group endpoint addresses: [%w]", err) + } + for _, address := range resolved { + if address.IsValid() && address.Zone() == "" { + addresses = append(addresses, address.Unmap()) + } + } + } + addresses = canonicalFrostRetainedGroupAddresses(addresses) + if len(addresses) == 0 || + len(addresses) > frostRetainedGroupMaximumResolvedAddresses { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("retained-group endpoint address set is invalid") + } + addressSetHash := frostRetainedGroupResolvedAddressSetHash(addresses) + return frostRetainedGroupResolvedEndpoint{ + endpoint: endpoint, + canonical: endpoint.String(), + canonicalDNSName: endpoint.Hostname(), + resolvedDNSName: canonicalDNSName, + addresses: addresses, + addressSetHash: addressSetHash, + }, nil +} + +func validateAndResolveFrostRetainedGroupSourceConfig( + ctx context.Context, + config FrostRetainedGroupHistorySourceConfig, + primaryTransport *FrostPrimaryEthereumTransport, +) (*frostRetainedGroupValidatedSourceConfig, error) { + if ctx == nil { + return nil, fmt.Errorf("retained-group source validation context is nil") + } + requestTimeout := config.RequestTimeout + if requestTimeout == 0 { + requestTimeout = frostRetainedGroupDefaultTimeout + } + if requestTimeout < time.Second || requestTimeout > time.Minute { + return nil, fmt.Errorf( + "retained-group request timeout is outside supported bounds", + ) + } + exportURL, canonicalExport, err := validateFrostRetainedGroupTLSEndpoint( + config.ExportURL, + ) + if err != nil { + return nil, fmt.Errorf("invalid retained-group export URL: [%w]", err) + } + verifierURL, canonicalVerifier, err := validateFrostRetainedGroupTLSEndpoint( + config.EthereumURL, + ) + if err != nil { + return nil, fmt.Errorf("invalid retained-group Ethereum URL: [%w]", err) + } + primaryEndpoint, resolver, err := primaryTransport.frozenEndpoint() + if err != nil { + return nil, fmt.Errorf( + "invalid guarded primary Ethereum transport: [%w]", + err, + ) + } + resolveContext, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + exportEndpoint, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + exportURL, + resolver, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve retained-group export URL: [%w]", err) + } + verifierEndpoint, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + verifierURL, + resolver, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve retained-group Ethereum URL: [%w]", err) + } + if frostRetainedGroupEndpointSetsOverlap(exportEndpoint, verifierEndpoint) { + return nil, fmt.Errorf( + "retained-group exporter and Ethereum verifier resolve to an alias or shared backend", + ) + } + if frostRetainedGroupEndpointSetsOverlap(exportEndpoint, primaryEndpoint) || + frostRetainedGroupEndpointSetsOverlap(verifierEndpoint, primaryEndpoint) { + return nil, fmt.Errorf( + "retained-group source is not independent of the primary endpoint", + ) + } + for name, value := range map[string]string{ + "source trust domain": config.TrustDomainID, + "export trust domain": config.ExportTrustDomainID, + "verifier trust domain": config.EthereumTrustDomainID, + } { + if !validFrostRetainedGroupIdentityLabel(value) { + return nil, fmt.Errorf("retained-group %s is invalid", name) + } + } + if config.TrustDomainID == config.ExportTrustDomainID || + config.TrustDomainID == config.EthereumTrustDomainID || + config.ExportTrustDomainID == config.EthereumTrustDomainID { + return nil, fmt.Errorf("retained-group trust-domain identities are aliased") + } + if err := validateFrostRetainedGroupServiceIdentity( + config.ExportServiceIdentity, + ); err != nil { + return nil, fmt.Errorf("invalid retained-group export service identity: [%w]", err) + } + if err := validateFrostRetainedGroupServiceIdentity( + config.EthereumServiceIdentity, + ); err != nil { + return nil, fmt.Errorf("invalid retained-group verifier service identity: [%w]", err) + } + if config.ExportServiceIdentity == config.EthereumServiceIdentity { + return nil, fmt.Errorf("retained-group service identities are aliased") + } + parseRequired := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(strings.TrimSpace(value)) + if err != nil || parsed == [32]byte{} { + return [32]byte{}, fmt.Errorf("retained-group %s is invalid", name) + } + return parsed, nil + } + exportBackend, err := parseRequired( + "export backend service fingerprint", + config.ExportBackendServiceFingerprint, + ) + if err != nil { + return nil, err + } + verifierBackend, err := parseRequired( + "verifier backend service fingerprint", + config.EthereumBackendServiceFingerprint, + ) + if err != nil { + return nil, err + } + exportOperator, err := parseRequired( + "export operator fingerprint", + config.ExportOperatorFingerprint, + ) + if err != nil { + return nil, err + } + verifierOperator, err := parseRequired( + "verifier operator fingerprint", + config.EthereumOperatorFingerprint, + ) + if err != nil { + return nil, err + } + exportHistorySigner, err := parseRequired( + "export history signer key hash", + config.TrustedSignerKeyHash, + ) + if err != nil { + return nil, err + } + exportAttestation, err := parseRequired( + "export attestation key hash", + config.ExportAttestationKeyHash, + ) + if err != nil { + return nil, err + } + verifierAttestation, err := parseRequired( + "verifier attestation key hash", + config.EthereumAttestationKeyHash, + ) + if err != nil { + return nil, err + } + exportLeaf, err := parseRequired( + "export TLS leaf SPKI hash", + config.ExportTLSLeafSPKIHash, + ) + if err != nil { + return nil, err + } + verifierLeaf, err := parseRequired( + "verifier TLS leaf SPKI hash", + config.EthereumTLSLeafSPKIHash, + ) + if err != nil { + return nil, err + } + allRoleHashes := map[[32]byte]string{} + for name, value := range map[string][32]byte{ + "export TLS leaf": exportLeaf, + "export backend": exportBackend, + "export operator": exportOperator, + "export attestation": exportAttestation, + "export history signer": exportHistorySigner, + "verifier TLS leaf": verifierLeaf, + "verifier backend": verifierBackend, + "verifier operator": verifierOperator, + "verifier attestation": verifierAttestation, + } { + if previous, exists := allRoleHashes[value]; exists { + return nil, fmt.Errorf( + "retained-group %s identity aliases %s", + name, + previous, + ) + } + allRoleHashes[value] = name + } + protocolID := frostRetainedGroupTLSExporterProtocolID() + exportIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-export", + TrustDomainID: config.ExportTrustDomainID, + CanonicalEndpoint: canonicalExport, + CanonicalDNSName: exportEndpoint.canonicalDNSName, + ResolvedDNSName: exportEndpoint.resolvedDNSName, + ResolvedAddressSetHash: exportEndpoint.addressSetHash, + TLSLeafSPKIHash: exportLeaf, + ServiceIdentity: config.ExportServiceIdentity, + BackendServiceFingerprint: exportBackend, + OperatorFingerprint: exportOperator, + AttestationKeyHash: exportAttestation, + TLSExporterProtocolID: protocolID, + } + exportIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(exportIdentity) + verifierIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-verifier", + TrustDomainID: config.EthereumTrustDomainID, + CanonicalEndpoint: canonicalVerifier, + CanonicalDNSName: verifierEndpoint.canonicalDNSName, + ResolvedDNSName: verifierEndpoint.resolvedDNSName, + ResolvedAddressSetHash: verifierEndpoint.addressSetHash, + TLSLeafSPKIHash: verifierLeaf, + ServiceIdentity: config.EthereumServiceIdentity, + BackendServiceFingerprint: verifierBackend, + OperatorFingerprint: verifierOperator, + AttestationKeyHash: verifierAttestation, + TLSExporterProtocolID: protocolID, + } + verifierIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(verifierIdentity) + identity := FrostRetainedGroupHistoryIdentity{ + Schema: frostRetainedGroupSourceIdentitySchema, + TrustDomainID: config.TrustDomainID, + OperatorFingerprint: exportOperator, + HistorySignerKeyHash: exportHistorySigner, + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(identity) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err != nil { + return nil, err + } + var roots *x509.CertPool + if config.TLSRootCAs != nil { + roots = config.TLSRootCAs.Clone() + } + return &frostRetainedGroupValidatedSourceConfig{ + exportEndpoint: exportEndpoint, + verifierEndpoint: verifierEndpoint, + primaryEndpoint: primaryEndpoint, + identity: identity, + requestTimeout: requestTimeout, + rootCAs: roots, + }, nil +} + +func (monitor *frostRetainedGroupIndependenceMonitor) verify( + ctx context.Context, +) error { + if monitor == nil || ctx == nil || + monitor.exportEndpoint.endpoint == nil || + monitor.verifierEndpoint.endpoint == nil || + monitor.primaryTransport == nil { + return fmt.Errorf("retained-group endpoint independence monitor is incomplete") + } + if err := monitor.primaryTransport.verifyIndependence( + ctx, + monitor.exportEndpoint, + monitor.verifierEndpoint, + ); err != nil { + return fmt.Errorf("primary Ethereum transport is not independent: [%w]", err) + } + return nil +} + +func canonicalFrostRetainedGroupAddresses( + addresses []netip.Addr, +) []netip.Addr { + unique := make(map[netip.Addr]bool) + for _, address := range addresses { + if address.IsValid() && address.Zone() == "" { + unique[address.Unmap()] = true + } + } + result := make([]netip.Addr, 0, len(unique)) + for address := range unique { + result = append(result, address) + } + sort.Slice(result, func(left int, right int) bool { + return result[left].Compare(result[right]) < 0 + }) + return result +} + +func frostRetainedGroupResolvedAddressSetHash( + addresses []netip.Addr, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupResolvedAddressSetDomain, + ) + canonical := canonicalFrostRetainedGroupAddresses(addresses) + transcript.uint64("addressCount", uint64(len(canonical))) + for index, address := range canonical { + transcript.text( + fmt.Sprintf("address[%d]", index), + address.String(), + ) + } + return transcript.sum() +} + +func frostRetainedGroupEndpointSetsOverlap( + left frostRetainedGroupResolvedEndpoint, + right frostRetainedGroupResolvedEndpoint, +) bool { + if left.canonicalDNSName == right.canonicalDNSName || + left.resolvedDNSName == right.resolvedDNSName || + left.canonicalDNSName == right.resolvedDNSName || + left.resolvedDNSName == right.canonicalDNSName { + return true + } + addresses := make(map[netip.Addr]bool, len(left.addresses)) + for _, address := range left.addresses { + addresses[address] = true + } + for _, address := range right.addresses { + if addresses[address] { + return true + } + } + return false +} + +func frostRetainedGroupPinnedDialContext( + endpoint frostRetainedGroupResolvedEndpoint, + timeout time.Duration, +) func(context.Context, string, string) (net.Conn, error) { + dialer := &net.Dialer{ + Timeout: timeout, + KeepAlive: -1, + } + return func( + ctx context.Context, + network string, + address string, + ) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || + host != endpoint.endpoint.Hostname() || + port != endpoint.endpoint.Port() { + return nil, fmt.Errorf( + "retained-group transport attempted an unpinned endpoint", + ) + } + var lastErr error + for _, pinned := range endpoint.addresses { + connection, err := dialer.DialContext( + ctx, + network, + net.JoinHostPort(pinned.String(), port), + ) + if err == nil { + return connection, nil + } + lastErr = err + } + return nil, fmt.Errorf( + "cannot connect any pinned retained-group endpoint address: [%w]", + lastErr, + ) + } +} + +func newFrostRetainedGroupPinnedTLSConfig( + identity FrostRetainedGroupEndpointIdentity, + rootCAs *x509.CertPool, +) (*tls.Config, error) { + if err := validateFrostRetainedGroupEndpointIdentity(identity); err != nil { + return nil, err + } + var roots *x509.CertPool + if rootCAs != nil { + roots = rootCAs.Clone() + } + return &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + ServerName: identity.CanonicalDNSName, + RootCAs: roots, + NextProtos: []string{"http/1.1"}, + VerifyConnection: func(state tls.ConnectionState) error { + return verifyFrostRetainedGroupTLSConnection(state, identity) + }, + }, nil +} + +func newFrostRetainedGroupAttestedHTTPClient( + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, + rootCAs *x509.CertPool, + timeout time.Duration, +) (*http.Client, *http.Transport, error) { + return newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + endpoint, + identity, + rootCAs, + timeout, + nil, + ) +} + +func newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, + rootCAs *x509.CertPool, + timeout time.Duration, + separationPolicy *frostPrimaryRetainedSeparationPolicy, +) (*http.Client, *http.Transport, error) { + if endpoint.endpoint == nil || endpoint.canonical != identity.CanonicalEndpoint || + endpoint.canonicalDNSName != identity.CanonicalDNSName || + endpoint.resolvedDNSName != identity.ResolvedDNSName || + endpoint.addressSetHash != identity.ResolvedAddressSetHash { + return nil, nil, fmt.Errorf( + "retained-group transport endpoint differs from its identity", + ) + } + tlsConfig, err := newFrostRetainedGroupPinnedTLSConfig(identity, rootCAs) + if err != nil { + return nil, nil, err + } + transport := &http.Transport{ + Proxy: nil, + DialContext: frostRetainedGroupPinnedDialContext(endpoint, timeout), + DisableKeepAlives: true, + DisableCompression: true, + ForceAttemptHTTP2: false, + MaxConnsPerHost: 1, + ResponseHeaderTimeout: timeout, + TLSHandshakeTimeout: timeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: 32 * 1024, + TLSClientConfig: tlsConfig, + } + attested := &frostRetainedGroupAttestedRoundTripper{ + base: transport, + endpoint: endpoint, + identity: identity, + separationPolicy: separationPolicy, + maximumBodyBytes: frostRetainedGroupMaximumTransportBodyBytes, + maximumClockSkew: frostRetainedGroupTransportClockSkew, + maximumLifetime: frostRetainedGroupTransportAttestationLifetime, + } + client := &http.Client{ + Transport: attested, + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf("retained-group redirects are forbidden") + }, + } + return client, transport, nil +} + +func verifyFrostRetainedGroupTLSConnection( + state tls.ConnectionState, + identity FrostRetainedGroupEndpointIdentity, +) error { + if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 { + return fmt.Errorf("retained-group TLS peer is not PKIX-verified") + } + if state.Version != tls.VersionTLS13 || + state.NegotiatedProtocol != "http/1.1" { + return fmt.Errorf("retained-group TLS protocol profile mismatch") + } + leaf := state.PeerCertificates[0] + if !leaf.BasicConstraintsValid || leaf.IsCA || + leaf.KeyUsage&x509.KeyUsageDigitalSignature == 0 || + leaf.KeyUsage&(x509.KeyUsageCertSign|x509.KeyUsageCRLSign) != 0 { + return fmt.Errorf("retained-group TLS leaf is not an X.509-SVID leaf") + } + if len(leaf.ExtKeyUsage) > 0 { + hasServerAuth := false + hasClientAuth := false + for _, usage := range leaf.ExtKeyUsage { + hasServerAuth = hasServerAuth || usage == x509.ExtKeyUsageServerAuth + hasClientAuth = hasClientAuth || usage == x509.ExtKeyUsageClientAuth + } + if !hasServerAuth || !hasClientAuth { + return fmt.Errorf("retained-group TLS leaf has incomplete X.509-SVID EKU") + } + } + if sha256.Sum256(leaf.RawSubjectPublicKeyInfo) != + identity.TLSLeafSPKIHash { + return fmt.Errorf("retained-group TLS leaf SPKI mismatch") + } + if len(leaf.URIs) != 1 || leaf.URIs[0].String() != identity.ServiceIdentity || + validateFrostRetainedGroupServiceIdentity( + leaf.URIs[0].String(), + ) != nil { + return fmt.Errorf("retained-group TLS service identity mismatch") + } + return nil +} + +func frostRetainedGroupAttestationTranscript( + attestation frostRetainedGroupTransportAttestation, +) ([32]byte, error) { + parse := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid %s: [%w]", name, err) + } + return parsed, nil + } + endpoint, err := parse("endpoint fingerprint", attestation.EndpointFingerprint) + if err != nil { + return [32]byte{}, err + } + addressSet := map[string]string{ + "tlsLeafSpkiHash": attestation.TLSLeafSPKIHash, + "backendServiceFingerprint": attestation.BackendServiceFingerprint, + "operatorFingerprint": attestation.OperatorFingerprint, + "attestationKeyHash": attestation.AttestationKeyHash, + "tlsExporterProtocolID": attestation.TLSExporterProtocolID, + "challenge": attestation.Challenge, + "requestBodySha256": attestation.RequestBodySHA256, + "responseBodySha256": attestation.ResponseBodySHA256, + "tlsExporterContextSha256": attestation.TLSExporterContextSHA256, + "tlsExporterValueSha256": attestation.TLSExporterValueSHA256, + } + parsed := make(map[string][32]byte, len(addressSet)) + for name, value := range addressSet { + parsed[name], err = parse(name, value) + if err != nil { + return [32]byte{}, err + } + } + issued, err := parseFrostRetainedGroupUint64(attestation.IssuedAtUnixMs) + if err != nil { + return [32]byte{}, err + } + expires, err := parseFrostRetainedGroupUint64(attestation.ExpiresAtUnixMs) + if err != nil { + return [32]byte{}, err + } + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupTransportAttestationDomain, + ) + transcript.text("schema", attestation.Schema) + transcript.text("role", attestation.Role) + transcript.bytes32("endpointFingerprint", endpoint) + transcript.text("canonicalEndpoint", attestation.CanonicalEndpoint) + transcript.text("canonicalDNSName", attestation.CanonicalDNSName) + transcript.text("resolvedDNSName", attestation.ResolvedDNSName) + transcript.text("resolvedPeerIP", attestation.ResolvedPeerIP) + transcript.bytes32("tlsLeafSpkiHash", parsed["tlsLeafSpkiHash"]) + transcript.text("serviceIdentity", attestation.ServiceIdentity) + transcript.bytes32( + "backendServiceFingerprint", + parsed["backendServiceFingerprint"], + ) + transcript.bytes32( + "operatorFingerprint", + parsed["operatorFingerprint"], + ) + transcript.bytes32("attestationKeyHash", parsed["attestationKeyHash"]) + transcript.bytes32( + "tlsExporterProtocolID", + parsed["tlsExporterProtocolID"], + ) + transcript.bytes32("challenge", parsed["challenge"]) + transcript.text("requestMethod", attestation.RequestMethod) + transcript.text("requestTarget", attestation.RequestTarget) + transcript.bytes32("requestBodySha256", parsed["requestBodySha256"]) + transcript.uint64("responseStatus", attestation.ResponseStatus) + transcript.bytes32("responseBodySha256", parsed["responseBodySha256"]) + transcript.uint64("issuedAtUnixMs", issued) + transcript.uint64("expiresAtUnixMs", expires) + transcript.bytes32( + "tlsExporterContextSha256", + parsed["tlsExporterContextSha256"], + ) + transcript.bytes32( + "tlsExporterValueSha256", + parsed["tlsExporterValueSha256"], + ) + return transcript.sum(), nil +} + +func frostRetainedGroupTLSExporterContext( + identity FrostRetainedGroupEndpointIdentity, + challenge [32]byte, + method string, + target string, + requestDigest [32]byte, + responseStatus uint64, + responseDigest [32]byte, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupTLSExporterContextDomain, + ) + transcript.bytes32("endpointFingerprint", identity.EndpointFingerprint) + transcript.bytes32("challenge", challenge) + transcript.text("requestMethod", method) + transcript.text("requestTarget", target) + transcript.bytes32("requestBodySha256", requestDigest) + transcript.uint64("responseStatus", responseStatus) + transcript.bytes32("responseBodySha256", responseDigest) + return transcript.sum() +} + +func frostRetainedGroupTLSExporterValueHash(material []byte) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupTLSExporterValueDomain, + ) + transcript.field("exporterValue", material) + return transcript.sum() +} + +func frostRetainedGroupBackendAttestationDigest( + transportAttestationDigest [32]byte, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupBackendAttestationDomain, + ) + transcript.bytes32( + "transportAttestationDigest", + transportAttestationDigest, + ) + return transcript.sum() +} + +func frostRetainedGroupOperatorAttestationDigest( + transportAttestationDigest [32]byte, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupOperatorAttestationDomain, + ) + transcript.bytes32( + "transportAttestationDigest", + transportAttestationDigest, + ) + return transcript.sum() +} + +func frostRetainedGroupExportKeyingMaterial( + state *tls.ConnectionState, + contextHash [32]byte, +) ([32]byte, error) { + if state == nil || !state.HandshakeComplete { + return [32]byte{}, fmt.Errorf("retained-group response has no completed TLS state") + } + material, err := state.ExportKeyingMaterial( + frostRetainedGroupTLSExporterLabel, + contextHash[:], + 32, + ) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot derive retained-group TLS exporter: [%w]", + err, + ) + } + return frostRetainedGroupTLSExporterValueHash(material), nil +} + +func parseFrostRetainedGroupUint64(value string) (uint64, error) { + if value == "" || (len(value) > 1 && value[0] == '0') { + return 0, fmt.Errorf("retained-group uint64 is not canonical") + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || strconv.FormatUint(parsed, 10) != value { + return 0, fmt.Errorf("retained-group uint64 is invalid") + } + return parsed, nil +} + +func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( + request *http.Request, +) (*http.Response, error) { + if roundTripper == nil || roundTripper.base == nil || request == nil || + request.URL == nil || request.Method != http.MethodPost { + return nil, fmt.Errorf("retained-group attested transport request is invalid") + } + base := roundTripper.endpoint.endpoint + basePath := strings.TrimSuffix(base.Path, "/") + if request.URL.Scheme != base.Scheme || + request.URL.Host != base.Host || + request.URL.User != nil || + request.URL.Fragment != "" || + request.URL.RawQuery != "" || + request.URL.Opaque != "" || + request.URL.RawPath != "" || + request.URL.Path == "" || + path.Clean(request.URL.Path) != request.URL.Path || + strings.Contains(request.URL.Path, "//") || + (request.URL.Path != base.Path && + !strings.HasPrefix(request.URL.Path, basePath+"/")) || + (request.Host != "" && request.Host != base.Host) { + return nil, fmt.Errorf("retained-group transport target escaped its pinned endpoint") + } + target := request.URL.String() + if request.Header.Get(frostRetainedGroupTransportChallengeHeader) != "" || + request.Header.Get("Accept-Encoding") != "" { + return nil, fmt.Errorf("retained-group transport headers are ambiguous") + } + var requestBodyReader io.Reader = http.NoBody + if request.Body != nil { + requestBodyReader = request.Body + } + body, err := io.ReadAll( + io.LimitReader( + requestBodyReader, + roundTripper.maximumBodyBytes+1, + ), + ) + if err != nil { + return nil, err + } + if int64(len(body)) > roundTripper.maximumBodyBytes { + return nil, fmt.Errorf("retained-group request body is too large") + } + if request.Body != nil { + _ = request.Body.Close() + } + outbound := request.Clone(request.Context()) + outbound.Header = request.Header.Clone() + outbound.Body = io.NopCloser(bytes.NewReader(body)) + outbound.ContentLength = int64(len(body)) + requestDigest := sha256.Sum256(body) + var challenge [32]byte + randomSource := roundTripper.random + if randomSource == nil { + randomSource = rand.Reader + } + if _, err := io.ReadFull(randomSource, challenge[:]); err != nil { + return nil, fmt.Errorf("cannot create retained-group transport challenge: [%w]", err) + } + outbound.Header.Set( + frostRetainedGroupTransportChallengeHeader, + hex.EncodeToString(challenge[:]), + ) + outbound.Header.Set("Accept-Encoding", "identity") + + var remote net.Addr + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + if info.Conn != nil { + remote = info.Conn.RemoteAddr() + } + }, + } + outbound = outbound.WithContext( + httptrace.WithClientTrace(outbound.Context(), trace), + ) + response, err := roundTripper.base.RoundTrip(outbound) + if err != nil { + return nil, err + } + bodyLimit := roundTripper.maximumBodyBytes + responseBody, readErr := io.ReadAll( + io.LimitReader(response.Body, bodyLimit+1), + ) + _ = response.Body.Close() + if readErr != nil { + return nil, readErr + } + if int64(len(responseBody)) > bodyLimit { + return nil, fmt.Errorf("retained-group response body is too large") + } + response.Body = io.NopCloser(bytes.NewReader(responseBody)) + response.ContentLength = int64(len(responseBody)) + if response.Uncompressed || + (response.Header.Get("Content-Encoding") != "" && + response.Header.Get("Content-Encoding") != "identity") { + return nil, fmt.Errorf("retained-group response transformation is forbidden") + } + responseDigest := sha256.Sum256(responseBody) + if err := verifyFrostRetainedGroupTransportAttestation( + response, + remote, + roundTripper.endpoint, + roundTripper.identity, + challenge, + outbound.Method, + target, + requestDigest, + responseDigest, + roundTripper.now, + roundTripper.maximumClockSkew, + roundTripper.maximumLifetime, + ); err != nil { + return nil, err + } + if roundTripper.separationPolicy != nil { + if response.TLS == nil { + return nil, fmt.Errorf( + "retained-group response has no exact TLS peer state", + ) + } + peer, err := frostTransportPeerIdentityFromTLS( + roundTripper.endpoint, + remote, + *response.TLS, + ) + if err != nil { + return nil, err + } + if err := roundTripper.separationPolicy.registerRetainedPeer( + roundTripper.identity.Role, + peer, + ); err != nil { + return nil, err + } + } + proof := frostRetainedGroupTransportProof{ + role: roundTripper.identity.Role, + requestDigest: requestDigest, + responseDigest: responseDigest, + challenge: challenge, + } + if response.Request == nil { + response.Request = outbound + } + response.Request = response.Request.Clone( + context.WithValue( + response.Request.Context(), + frostRetainedGroupTransportProofKey{}, + proof, + ), + ) + return response, nil +} + +func verifyFrostRetainedGroupTransportAttestation( + response *http.Response, + remote net.Addr, + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, + challenge [32]byte, + requestMethod string, + requestTarget string, + requestDigest [32]byte, + responseDigest [32]byte, + now func() time.Time, + maximumClockSkew time.Duration, + maximumLifetime time.Duration, +) error { + if response == nil || response.TLS == nil || !response.TLS.HandshakeComplete || + remote == nil || response.Request == nil { + return fmt.Errorf("retained-group response transport is unauthenticated") + } + if err := verifyFrostRetainedGroupTLSConnection(*response.TLS, identity); err != nil { + return err + } + remoteIP, err := frostRetainedGroupRemoteIP(remote) + if err != nil || !frostRetainedGroupAddressPinned(endpoint.addresses, remoteIP) { + return fmt.Errorf("retained-group response peer is outside the pinned address set") + } + values := response.Header.Values( + frostRetainedGroupTransportAttestationHeader, + ) + if len(values) != 1 || len(values[0]) == 0 || + len(values[0]) > frostRetainedGroupMaximumTransportAttestationBytes { + return fmt.Errorf("retained-group transport attestation header is missing or ambiguous") + } + raw, err := decodeCanonicalFrostRetainedGroupBase64(values[0]) + if err != nil || len(raw) == 0 || + len(raw) > frostRetainedGroupMaximumTransportAttestationBytes { + return fmt.Errorf("retained-group transport attestation encoding is invalid") + } + attestation := frostRetainedGroupTransportAttestation{} + if err := decodeStrictFrostActivationJSON(raw, &attestation); err != nil { + return fmt.Errorf("cannot decode retained-group transport attestation: [%w]", err) + } + issued, issuedErr := parseFrostRetainedGroupUint64( + attestation.IssuedAtUnixMs, + ) + expires, expiresErr := parseFrostRetainedGroupUint64( + attestation.ExpiresAtUnixMs, + ) + if now == nil { + now = time.Now + } + if maximumClockSkew <= 0 { + maximumClockSkew = frostRetainedGroupTransportClockSkew + } + if maximumLifetime <= 0 { + maximumLifetime = frostRetainedGroupTransportAttestationLifetime + } + nowMilliseconds := now().UnixMilli() + maximumInt64 := uint64(^uint64(0) >> 1) + maximumLifetimeMilliseconds := uint64(maximumLifetime / time.Millisecond) + maximumClockSkewMilliseconds := maximumClockSkew.Milliseconds() + if issuedErr != nil || expiresErr != nil || nowMilliseconds < 0 || + issued > maximumInt64 || expires > maximumInt64 || + expires <= issued || + expires-issued > maximumLifetimeMilliseconds { + return fmt.Errorf("retained-group transport attestation is stale") + } + issuedMilliseconds := int64(issued) + expiresMilliseconds := int64(expires) + if (issuedMilliseconds > nowMilliseconds && + issuedMilliseconds-nowMilliseconds > maximumClockSkewMilliseconds) || + (expiresMilliseconds <= nowMilliseconds && + nowMilliseconds-expiresMilliseconds >= maximumClockSkewMilliseconds) { + return fmt.Errorf("retained-group transport attestation is stale") + } + contextHash := frostRetainedGroupTLSExporterContext( + identity, + challenge, + requestMethod, + requestTarget, + requestDigest, + uint64(response.StatusCode), + responseDigest, + ) + exporterValueHash, err := frostRetainedGroupExportKeyingMaterial( + response.TLS, + contextHash, + ) + if err != nil { + return err + } + if attestation.Schema != frostRetainedGroupTransportAttestationSchema || + attestation.Role != identity.Role || + attestation.EndpointFingerprint != + frostActivationHex32(identity.EndpointFingerprint) || + attestation.CanonicalEndpoint != identity.CanonicalEndpoint || + attestation.CanonicalDNSName != identity.CanonicalDNSName || + attestation.ResolvedDNSName != identity.ResolvedDNSName || + attestation.ResolvedPeerIP != remoteIP.String() || + attestation.TLSLeafSPKIHash != + frostActivationHex32(identity.TLSLeafSPKIHash) || + attestation.ServiceIdentity != identity.ServiceIdentity || + attestation.BackendServiceFingerprint != + frostActivationHex32(identity.BackendServiceFingerprint) || + attestation.OperatorFingerprint != + frostActivationHex32(identity.OperatorFingerprint) || + attestation.AttestationKeyHash != + frostActivationHex32(identity.AttestationKeyHash) || + attestation.TLSExporterProtocolID != + frostActivationHex32(identity.TLSExporterProtocolID) || + attestation.Challenge != frostActivationHex32(challenge) || + attestation.RequestMethod != requestMethod || + attestation.RequestTarget != requestTarget || + attestation.RequestBodySHA256 != frostActivationHex32(requestDigest) || + attestation.ResponseStatus != uint64(response.StatusCode) || + attestation.ResponseBodySHA256 != frostActivationHex32(responseDigest) || + attestation.TLSExporterContextSHA256 != + frostActivationHex32(contextHash) || + attestation.TLSExporterValueSHA256 != + frostActivationHex32(exporterValueHash) { + return fmt.Errorf("retained-group transport attestation is differently bound") + } + digest, err := frostRetainedGroupAttestationTranscript(attestation) + if err != nil { + return fmt.Errorf("retained-group transport attestation transcript is invalid: [%w]", err) + } + backendDigest := frostRetainedGroupBackendAttestationDigest(digest) + if err := verifyFrostRetainedGroupEd25519RoleSignature( + "backend", + identity.BackendServiceFingerprint, + attestation.BackendSignerPublicKeySPKI, + attestation.BackendSignatureAlgorithm, + attestation.BackendSignature, + backendDigest, + ); err != nil { + return err + } + operatorDigest := frostRetainedGroupOperatorAttestationDigest(digest) + if err := verifyFrostRetainedGroupEd25519RoleSignature( + "operator", + identity.OperatorFingerprint, + attestation.OperatorSignerPublicKeySPKI, + attestation.OperatorSignatureAlgorithm, + attestation.OperatorSignature, + operatorDigest, + ); err != nil { + return err + } + if err := verifyFrostRetainedGroupEd25519RoleSignature( + "transport attestation", + identity.AttestationKeyHash, + attestation.SignerPublicKeySPKI, + attestation.SignatureAlgorithm, + attestation.Signature, + digest, + ); err != nil { + return err + } + return nil +} + +func verifyFrostRetainedGroupEd25519RoleSignature( + role string, + expectedKeyHash [32]byte, + publicKeySPKI string, + algorithm string, + signatureValue string, + digest [32]byte, +) error { + publicKeyDER, err := decodeCanonicalFrostRetainedGroupBase64( + publicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 || + sha256.Sum256(publicKeyDER) != expectedKeyHash { + return fmt.Errorf("retained-group %s signer is not trusted", role) + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return fmt.Errorf( + "cannot parse retained-group %s signer: [%w]", + role, + err, + ) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok || algorithm != "ed25519" { + return fmt.Errorf("retained-group %s signer is not Ed25519", role) + } + signature, err := decodeCanonicalFrostRetainedGroupBase64( + signatureValue, + ) + if err != nil || len(signature) != ed25519.SignatureSize || + !ed25519.Verify(publicKey, digest[:], signature) { + return fmt.Errorf("retained-group %s signature is invalid", role) + } + return nil +} + +func decodeCanonicalFrostRetainedGroupBase64(value string) ([]byte, error) { + decoded, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != value { + return nil, fmt.Errorf("retained-group base64 is not canonical") + } + return decoded, nil +} + +func frostRetainedGroupRemoteIP(address net.Addr) (netip.Addr, error) { + host, _, err := net.SplitHostPort(address.String()) + if err != nil { + return netip.Addr{}, err + } + parsed, err := netip.ParseAddr(host) + if err != nil || parsed.Zone() != "" { + return netip.Addr{}, fmt.Errorf("retained-group peer address is invalid") + } + return parsed.Unmap(), nil +} + +func frostRetainedGroupAddressPinned( + addresses []netip.Addr, + candidate netip.Addr, +) bool { + for _, address := range addresses { + if address == candidate { + return true + } + } + return false +} + +func requireFrostRetainedGroupTransportProof( + response *http.Response, + role string, +) error { + if response == nil || response.Request == nil { + return fmt.Errorf("retained-group response has no transport proof") + } + proof, ok := response.Request.Context().Value( + frostRetainedGroupTransportProofKey{}, + ).(frostRetainedGroupTransportProof) + if !ok || proof.role != role || + proof.requestDigest == [32]byte{} || + proof.responseDigest == [32]byte{} || + proof.challenge == [32]byte{} { + return fmt.Errorf("retained-group response has no valid transport proof") + } + return nil +} + +// marshalFrostRetainedGroupTransportAttestation constructs the exact header +// an independently implemented service must emit. It is intentionally kept +// package-private; production servers should implement the frozen transcript, +// not import signer-client internals. +func marshalFrostRetainedGroupTransportAttestation( + request *http.Request, + responseStatus int, + responseBody []byte, + identity FrostRetainedGroupEndpointIdentity, + attestationPrivateKey ed25519.PrivateKey, + attestationPublicKeyDER []byte, + backendPrivateKey ed25519.PrivateKey, + backendPublicKeyDER []byte, + operatorPrivateKey ed25519.PrivateKey, + operatorPublicKeyDER []byte, + now time.Time, + localIP netip.Addr, +) (string, error) { + if request == nil || request.TLS == nil || + len(attestationPrivateKey) != ed25519.PrivateKeySize || + sha256.Sum256(attestationPublicKeyDER) != identity.AttestationKeyHash || + len(backendPrivateKey) != ed25519.PrivateKeySize || + sha256.Sum256(backendPublicKeyDER) != + identity.BackendServiceFingerprint || + len(operatorPrivateKey) != ed25519.PrivateKeySize || + sha256.Sum256(operatorPublicKeyDER) != identity.OperatorFingerprint || + now.UnixMilli() < 0 || !localIP.IsValid() { + return "", fmt.Errorf("retained-group transport attestation inputs are invalid") + } + challengeBytes, err := hex.DecodeString( + request.Header.Get(frostRetainedGroupTransportChallengeHeader), + ) + if err != nil || len(challengeBytes) != 32 { + return "", fmt.Errorf("retained-group transport challenge is invalid") + } + var challenge [32]byte + copy(challenge[:], challengeBytes) + var requestBodyReader io.Reader = http.NoBody + if request.Body != nil { + requestBodyReader = request.Body + } + requestBody, err := io.ReadAll( + io.LimitReader( + requestBodyReader, + frostRetainedGroupMaximumTransportBodyBytes+1, + ), + ) + if err != nil || len(requestBody) > frostRetainedGroupMaximumTransportBodyBytes { + return "", fmt.Errorf("retained-group transport request body is invalid") + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + requestDigest := sha256.Sum256(requestBody) + responseDigest := sha256.Sum256(responseBody) + requestTarget, err := frostRetainedGroupServerRequestTarget(identity, request) + if err != nil { + return "", err + } + contextHash := frostRetainedGroupTLSExporterContext( + identity, + challenge, + request.Method, + requestTarget, + requestDigest, + uint64(responseStatus), + responseDigest, + ) + exporterValueHash, err := frostRetainedGroupExportKeyingMaterial( + request.TLS, + contextHash, + ) + if err != nil { + return "", err + } + issued := uint64(now.UnixMilli()) + expires := issued + uint64( + frostRetainedGroupTransportAttestationLifetime.Milliseconds(), + ) + attestation := frostRetainedGroupTransportAttestation{ + Schema: frostRetainedGroupTransportAttestationSchema, + Role: identity.Role, + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + CanonicalEndpoint: identity.CanonicalEndpoint, + CanonicalDNSName: identity.CanonicalDNSName, + ResolvedDNSName: identity.ResolvedDNSName, + ResolvedPeerIP: localIP.Unmap().String(), + TLSLeafSPKIHash: frostActivationHex32(identity.TLSLeafSPKIHash), + ServiceIdentity: identity.ServiceIdentity, + BackendServiceFingerprint: frostActivationHex32(identity.BackendServiceFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + AttestationKeyHash: frostActivationHex32(identity.AttestationKeyHash), + TLSExporterProtocolID: frostActivationHex32(identity.TLSExporterProtocolID), + Challenge: frostActivationHex32(challenge), + RequestMethod: request.Method, + RequestTarget: requestTarget, + RequestBodySHA256: frostActivationHex32(requestDigest), + ResponseStatus: uint64(responseStatus), + ResponseBodySHA256: frostActivationHex32(responseDigest), + IssuedAtUnixMs: strconv.FormatUint(issued, 10), + ExpiresAtUnixMs: strconv.FormatUint(expires, 10), + TLSExporterContextSHA256: frostActivationHex32(contextHash), + TLSExporterValueSHA256: frostActivationHex32(exporterValueHash), + BackendSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + backendPublicKeyDER, + ), + BackendSignatureAlgorithm: "ed25519", + OperatorSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + operatorPublicKeyDER, + ), + OperatorSignatureAlgorithm: "ed25519", + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + attestationPublicKeyDER, + ), + SignatureAlgorithm: "ed25519", + } + digest, err := frostRetainedGroupAttestationTranscript(attestation) + if err != nil { + return "", err + } + backendDigest := frostRetainedGroupBackendAttestationDigest(digest) + attestation.BackendSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(backendPrivateKey, backendDigest[:]), + ) + operatorDigest := frostRetainedGroupOperatorAttestationDigest(digest) + attestation.OperatorSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(operatorPrivateKey, operatorDigest[:]), + ) + attestation.Signature = base64.StdEncoding.EncodeToString( + ed25519.Sign(attestationPrivateKey, digest[:]), + ) + encoded, err := json.Marshal(attestation) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encoded), nil +} + +func frostRetainedGroupServerRequestTarget( + identity FrostRetainedGroupEndpointIdentity, + request *http.Request, +) (string, error) { + if request == nil || request.URL == nil { + return "", fmt.Errorf("retained-group server request target is missing") + } + base, _, err := validateFrostRetainedGroupTLSEndpoint( + identity.CanonicalEndpoint, + ) + if err != nil { + return "", err + } + basePath := strings.TrimSuffix(base.Path, "/") + if request.Host != base.Host || + request.URL.RawQuery != "" || request.URL.RawPath != "" || + request.URL.Fragment != "" || request.URL.Opaque != "" || + request.URL.Path == "" || + path.Clean(request.URL.Path) != request.URL.Path || + strings.Contains(request.URL.Path, "//") || + (request.URL.Path != base.Path && + !strings.HasPrefix(request.URL.Path, basePath+"/")) { + return "", fmt.Errorf("retained-group server request escaped its endpoint") + } + base.Path = request.URL.Path + base.RawPath = "" + base.RawQuery = "" + return base.String(), nil +} diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go new file mode 100644 index 0000000000..d948d5f601 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go @@ -0,0 +1,1237 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/rpc" +) + +func testFrostRetainedGroupCompleteIdentity() FrostRetainedGroupHistoryIdentity { + protocolID := frostRetainedGroupTLSExporterProtocolID() + exportIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-export", + TrustDomainID: "retained-export.example", + CanonicalEndpoint: "https://retained-export.example:443/history", + CanonicalDNSName: "retained-export.example", + ResolvedDNSName: "retained-export-origin.example", + ResolvedAddressSetHash: [32]byte{0xa1}, + TLSLeafSPKIHash: [32]byte{0xa2}, + ServiceIdentity: "spiffe://retained-export.example/export", + BackendServiceFingerprint: [32]byte{0xa3}, + OperatorFingerprint: [32]byte{0xa4}, + AttestationKeyHash: [32]byte{0xa5}, + TLSExporterProtocolID: protocolID, + } + exportIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(exportIdentity) + verifierIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-verifier", + TrustDomainID: "retained-verifier.example", + CanonicalEndpoint: "https://retained-verifier.example:443/rpc", + CanonicalDNSName: "retained-verifier.example", + ResolvedDNSName: "retained-verifier-origin.example", + ResolvedAddressSetHash: [32]byte{0xa6}, + TLSLeafSPKIHash: [32]byte{0xa7}, + ServiceIdentity: "spiffe://retained-verifier.example/verifier", + BackendServiceFingerprint: [32]byte{0xa8}, + OperatorFingerprint: [32]byte{0xa9}, + AttestationKeyHash: [32]byte{0xaa}, + TLSExporterProtocolID: protocolID, + } + verifierIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(verifierIdentity) + identity := FrostRetainedGroupHistoryIdentity{ + Schema: frostRetainedGroupSourceIdentitySchema, + TrustDomainID: "independent-journal-source", + OperatorFingerprint: exportIdentity.OperatorFingerprint, + HistorySignerKeyHash: [32]byte{0xab}, + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(identity) + return identity +} + +func TestFrostRetainedGroupIdentityFingerprintsFrozen(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + addressHash := frostRetainedGroupResolvedAddressSetHash([]netip.Addr{ + netip.MustParseAddr("2001:db8::1"), + netip.MustParseAddr("192.0.2.1"), + netip.MustParseAddr("::ffff:192.0.2.1"), + netip.MustParseAddr("2001:db8::1"), + }) + const expectedExport = "416dd89aa039243ca4b03e8b5c15e54e4dd9b5553ceaf230ad5e978bd3e073d3" + const expectedSource = "d4100a02930b9fd62a78fae2c40316ea8ddd85c2dcffc344a502d5ebdd27d971" + const expectedAddresses = "9bafc074d0b8ca6b0458ed2d25a48a140f5599854952386087530f1217a90d45" + if hex.EncodeToString(identity.Export.EndpointFingerprint[:]) != expectedExport || + hex.EncodeToString(identity.EndpointFingerprint[:]) != expectedSource || + hex.EncodeToString(addressHash[:]) != expectedAddresses { + t.Fatalf( + "frozen identity vectors changed: [%x] [%x] [%x]", + identity.Export.EndpointFingerprint, + identity.EndpointFingerprint, + addressHash, + ) + } +} + +func TestFrostRetainedGroupTransportAttestationFrozenVectors(t *testing.T) { + attestationPrivateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x01}, 32)) + backendPrivateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x02}, 32)) + operatorPrivateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x03}, 32)) + attestationPublicKeyDER, err := x509.MarshalPKIXPublicKey( + attestationPrivateKey.Public(), + ) + if err != nil { + t.Fatal(err) + } + backendPublicKeyDER, err := x509.MarshalPKIXPublicKey( + backendPrivateKey.Public(), + ) + if err != nil { + t.Fatal(err) + } + operatorPublicKeyDER, err := x509.MarshalPKIXPublicKey( + operatorPrivateKey.Public(), + ) + if err != nil { + t.Fatal(err) + } + identity := testFrostRetainedGroupCompleteIdentity().Export + identity.AttestationKeyHash = sha256.Sum256(attestationPublicKeyDER) + identity.BackendServiceFingerprint = sha256.Sum256(backendPublicKeyDER) + identity.OperatorFingerprint = sha256.Sum256(operatorPublicKeyDER) + identity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity) + challenge := [32]byte{0x11} + requestDigest := [32]byte{0x12} + responseDigest := [32]byte{0x13} + contextHash := frostRetainedGroupTLSExporterContext( + identity, + challenge, + http.MethodPost, + identity.CanonicalEndpoint, + requestDigest, + http.StatusOK, + responseDigest, + ) + exporterValueHash := frostRetainedGroupTLSExporterValueHash( + []byte("fixed-exported-keying-material"), + ) + attestation := frostRetainedGroupTransportAttestation{ + Schema: frostRetainedGroupTransportAttestationSchema, + Role: identity.Role, + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + CanonicalEndpoint: identity.CanonicalEndpoint, + CanonicalDNSName: identity.CanonicalDNSName, + ResolvedDNSName: identity.ResolvedDNSName, + ResolvedPeerIP: "192.0.2.1", + TLSLeafSPKIHash: frostActivationHex32(identity.TLSLeafSPKIHash), + ServiceIdentity: identity.ServiceIdentity, + BackendServiceFingerprint: frostActivationHex32(identity.BackendServiceFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + AttestationKeyHash: frostActivationHex32(identity.AttestationKeyHash), + TLSExporterProtocolID: frostActivationHex32(identity.TLSExporterProtocolID), + Challenge: frostActivationHex32(challenge), + RequestMethod: http.MethodPost, + RequestTarget: identity.CanonicalEndpoint, + RequestBodySHA256: frostActivationHex32(requestDigest), + ResponseStatus: http.StatusOK, + ResponseBodySHA256: frostActivationHex32(responseDigest), + IssuedAtUnixMs: "1700000000000", + ExpiresAtUnixMs: "1700000030000", + TLSExporterContextSHA256: frostActivationHex32(contextHash), + TLSExporterValueSHA256: frostActivationHex32(exporterValueHash), + BackendSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + backendPublicKeyDER, + ), + BackendSignatureAlgorithm: "ed25519", + OperatorSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + operatorPublicKeyDER, + ), + OperatorSignatureAlgorithm: "ed25519", + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + attestationPublicKeyDER, + ), + SignatureAlgorithm: "ed25519", + } + attestationDigest, err := frostRetainedGroupAttestationTranscript(attestation) + if err != nil { + t.Fatal(err) + } + backendDigest := frostRetainedGroupBackendAttestationDigest(attestationDigest) + operatorDigest := frostRetainedGroupOperatorAttestationDigest(attestationDigest) + attestation.BackendSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(backendPrivateKey, backendDigest[:]), + ) + attestation.OperatorSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(operatorPrivateKey, operatorDigest[:]), + ) + attestation.Signature = base64.StdEncoding.EncodeToString( + ed25519.Sign(attestationPrivateKey, attestationDigest[:]), + ) + wire, err := json.Marshal(attestation) + if err != nil { + t.Fatal(err) + } + wireHash := sha256.Sum256(wire) + const expectedContext = "50587c477f56e9d2597c4cf4d9cf69d69a8ded13b9a074d1c18042eb4aea2e30" + const expectedExporter = "62e2fd2ccb30b5e2ca49a99f04cbb01a947d8228060ee377dc6896c6c96a08b0" + const expectedAttestation = "53eb0f08ca2c1761592ec90387c5aba9913668ef68c1e4cf6f20dbbd531e267b" + const expectedBackend = "7fb92657871cf2dd864eeffaac5d699f1131f81b04507b2092f90987aa4a722c" + const expectedOperator = "02495d610fde3ad437a22de7e93dae5394d34a0ef4e590f4d613e3fa6197cbc4" + const expectedTransportSignature = "AV8bIwrHUE6ACkJ9vQWtQTVXGJDR8Nm42nQqCka8NISgXIIPbW2abLhOrGlX/bnYVUUUMbhRfcyYCf+asQ9KBg==" + const expectedBackendSignature = "0ACdPMwZaraKgWpVRMNnwc6qgLtB90rGDoj18kYCcbd2jJG36VCVf0j5OhHlqP5KoQbzKwJ9BRelXF1pYd/yDA==" + const expectedOperatorSignature = "lx22S9wJxUSOsgZZlWqP7S5bTdoULktZQ9Ao7KDwdpI8zJDkq3AD76rj737DSthJUt0+6IEdElMyiCnXwdgFBg==" + const expectedWire = "174da49defe9c6b6c669a8a33177ccf6257df24e1316c650734c8ff35099dcdb" + if hex.EncodeToString(contextHash[:]) != expectedContext || + hex.EncodeToString(exporterValueHash[:]) != expectedExporter || + hex.EncodeToString(attestationDigest[:]) != expectedAttestation || + hex.EncodeToString(backendDigest[:]) != expectedBackend || + hex.EncodeToString(operatorDigest[:]) != expectedOperator || + attestation.Signature != expectedTransportSignature || + attestation.BackendSignature != expectedBackendSignature || + attestation.OperatorSignature != expectedOperatorSignature || + hex.EncodeToString(wireHash[:]) != expectedWire { + t.Fatalf( + "frozen transport-attestation vectors changed: context=%x exporter=%x attestation=%x backend=%x operator=%x transport=%s backendSignature=%s operatorSignature=%s wire=%x", + contextHash, + exporterValueHash, + attestationDigest, + backendDigest, + operatorDigest, + attestation.Signature, + attestation.BackendSignature, + attestation.OperatorSignature, + wireHash, + ) + } +} + +func TestValidateFrostRetainedGroupHistoryIdentity_CommitsEveryField( + t *testing.T, +) { + endpointMutations := map[string]func(*FrostRetainedGroupEndpointIdentity){ + "schema": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.Schema += "-other" + }, + "role": func(identity *FrostRetainedGroupEndpointIdentity) { + if identity.Role == "retained-history-export" { + identity.Role = "retained-history-verifier" + } else { + identity.Role = "retained-history-export" + } + }, + "trust domain": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.TrustDomainID += "-other" + }, + "canonical endpoint": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.CanonicalEndpoint = "https://other.example:443/history" + }, + "canonical DNS name": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.CanonicalDNSName = "other.example" + }, + "resolved DNS name": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.ResolvedDNSName = "other-origin.example" + }, + "resolved addresses": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.ResolvedAddressSetHash[31] ^= 0x01 + }, + "TLS leaf": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.TLSLeafSPKIHash[31] ^= 0x01 + }, + "service identity": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.ServiceIdentity = "spiffe://retained.example/other" + }, + "backend identity": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.BackendServiceFingerprint[31] ^= 0x01 + }, + "operator identity": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.OperatorFingerprint[31] ^= 0x01 + }, + "attestation key": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.AttestationKeyHash[31] ^= 0x01 + }, + "TLS exporter protocol": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.TLSExporterProtocolID[31] ^= 0x01 + }, + "endpoint fingerprint": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.EndpointFingerprint[31] ^= 0x01 + }, + } + for name, mutate := range endpointMutations { + t.Run("export "+name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity.Export) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("mutated export identity was accepted") + } + }) + t.Run("verifier "+name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity.Verifier) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("mutated verifier identity was accepted") + } + }) + } + + sourceMutations := map[string]func(*FrostRetainedGroupHistoryIdentity){ + "schema": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Schema += "-other" + }, + "trust domain": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.TrustDomainID += "-other" + }, + "operator": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.OperatorFingerprint[31] ^= 0x01 + }, + "history signer": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.HistorySignerKeyHash[31] ^= 0x01 + }, + "fingerprint": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.EndpointFingerprint[31] ^= 0x01 + }, + } + for name, mutate := range sourceMutations { + t.Run("source "+name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("mutated source identity was accepted") + } + }) + } +} + +func TestValidateFrostRetainedGroupHistoryIdentity_RejectsRoleReuse( + t *testing.T, +) { + testCases := map[string]func(*FrostRetainedGroupHistoryIdentity){ + "within endpoint": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Export.BackendServiceFingerprint = + identity.Export.TLSLeafSPKIHash + identity.Export.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Export) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "cross endpoint hash": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.AttestationKeyHash = + identity.Export.OperatorFingerprint + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "history signer reuse": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.HistorySignerKeyHash = + identity.Export.AttestationKeyHash + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "cross endpoint service": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.ServiceIdentity = identity.Export.ServiceIdentity + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "same SPIFFE authority": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.ServiceIdentity = + "spiffe://retained-export.example/verifier" + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "cross endpoint DNS": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.ResolvedDNSName = identity.Export.ResolvedDNSName + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "aggregate trust domain": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.TrustDomainID = identity.Export.TrustDomainID + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + } + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("reused endpoint role identity was accepted") + } + }) + } +} + +func TestValidateFrostRetainedGroupServiceIdentity_StrictSPIFFECanonicalization( + t *testing.T, +) { + for _, valid := range []string{ + "spiffe://retained.example/export", + "spiffe://retained.example/services/verifier-v1", + "spiffe://single_label/export", + } { + if err := validateFrostRetainedGroupServiceIdentity(valid); err != nil { + t.Fatalf("canonical SPIFFE identity rejected [%s]: [%v]", valid, err) + } + } + for _, invalid := range []string{ + "spiffe://retained.example", + "spiffe://retained.example/", + "spiffe://retained.example:443/export", + "spiffe://Retained.example/export", + "spiffe://retained.example/export/", + "spiffe://retained.example/a//b", + "spiffe://retained.example/a/../b", + "spiffe://retained.example/%65xport", + "spiffe://retained.example/a:b", + "spiffe://retained.example/a~b", + "spiffe://user@retained.example/export", + "spiffe://retained.example/export?query=1", + "spiffe://retained.example/export#fragment", + } { + if err := validateFrostRetainedGroupServiceIdentity(invalid); err == nil { + t.Fatalf("ambiguous SPIFFE identity accepted [%s]", invalid) + } + } +} + +func TestValidateFrostRetainedGroupTLSEndpoint_StrictCanonicalization( + t *testing.T, +) { + for _, valid := range []string{ + "https://history.example:443/", + "https://history.example:8443/export", + "https://127.0.0.1:443/rpc", + "https://[2001:db8::1]:443/rpc", + } { + t.Run("valid "+valid, func(t *testing.T) { + _, canonical, err := validateFrostRetainedGroupTLSEndpoint(valid) + if err != nil || canonical != valid { + t.Fatalf("canonical endpoint rejected: [%s] [%v]", canonical, err) + } + }) + } + for _, invalid := range []string{ + "http://history.example:443/", + "wss://history.example:443/", + "https://history.example/", + "https://history.example:0443/", + "https://History.example:443/", + "https://history.example.:443/", + "https://user@history.example:443/", + "https://history.example:443/?query=1", + "https://history.example:443/#fragment", + "https://history.example:443/%68istory", + "https://history.example:443/a/../history", + "https://history.example:443//history", + "https://history.example:443/history/", + "https://hé.example:443/", + "https:\\\\history.example:443\\history", + "https://history.example:443", + } { + t.Run("invalid "+invalid, func(t *testing.T) { + if _, _, err := validateFrostRetainedGroupTLSEndpoint(invalid); err == nil { + t.Fatal("ambiguous endpoint was accepted") + } + }) + } +} + +func TestFrostRetainedGroupResolvedAddressSet_IsCanonicalAndDetectsAliases( + t *testing.T, +) { + leftAddresses := []netip.Addr{ + netip.MustParseAddr("2001:db8::1"), + netip.MustParseAddr("192.0.2.1"), + netip.MustParseAddr("::ffff:192.0.2.1"), + } + rightAddresses := []netip.Addr{ + netip.MustParseAddr("192.0.2.1"), + netip.MustParseAddr("2001:db8::1"), + } + if frostRetainedGroupResolvedAddressSetHash(leftAddresses) != + frostRetainedGroupResolvedAddressSetHash(rightAddresses) { + t.Fatal("address-set hash depends on order, duplicates, or mapped form") + } + left := frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "left.example", + resolvedDNSName: "left-origin.example", + addresses: []netip.Addr{netip.MustParseAddr("192.0.2.1")}, + } + right := frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "right.example", + resolvedDNSName: "right-origin.example", + addresses: []netip.Addr{netip.MustParseAddr("192.0.2.1")}, + } + if !frostRetainedGroupEndpointSetsOverlap(left, right) { + t.Fatal("shared backend IP was not detected") + } + right.addresses = []netip.Addr{netip.MustParseAddr("192.0.2.2")} + right.resolvedDNSName = left.canonicalDNSName + if !frostRetainedGroupEndpointSetsOverlap(left, right) { + t.Fatal("CNAME/canonical DNS alias was not detected") + } +} + +type frostRetainedGroupRebindingResolver struct { + cname string + addresses []netip.Addr +} + +type testFrostPrimaryEthereumIndependenceVerifier struct { + verify func( + context.Context, + frostRetainedGroupResolvedEndpoint, + frostRetainedGroupResolvedEndpoint, + ) error +} + +func (verifier *testFrostPrimaryEthereumIndependenceVerifier) verifyIndependence( + ctx context.Context, + exportEndpoint frostRetainedGroupResolvedEndpoint, + verifierEndpoint frostRetainedGroupResolvedEndpoint, +) error { + if verifier == nil { + return fmt.Errorf("test primary Ethereum verifier is nil") + } + if verifier.verify == nil { + return nil + } + return verifier.verify(ctx, exportEndpoint, verifierEndpoint) +} + +func (resolver *frostRetainedGroupRebindingResolver) LookupCNAME( + context.Context, + string, +) (string, error) { + return resolver.cname, nil +} + +func (resolver *frostRetainedGroupRebindingResolver) LookupNetIP( + context.Context, + string, + string, +) ([]netip.Addr, error) { + return append([]netip.Addr{}, resolver.addresses...), nil +} + +func TestFrostRetainedGroupIndependenceMonitor_RejectsPrimaryDNSRebind( + t *testing.T, +) { + primaryURL, err := url.Parse("https://primary.example:443/rpc") + if err != nil { + t.Fatal(err) + } + exportAddress := netip.MustParseAddr("192.0.2.1") + verifierAddress := netip.MustParseAddr("192.0.2.2") + resolver := &frostRetainedGroupRebindingResolver{ + cname: "primary-origin.example.", + addresses: []netip.Addr{netip.MustParseAddr("192.0.2.3")}, + } + primaryEndpoint := frostRetainedGroupResolvedEndpoint{ + canonical: primaryURL.String(), + canonicalDNSName: "primary.example", + resolvedDNSName: "primary-origin.example", + addresses: append([]netip.Addr{}, resolver.addresses...), + addressSetHash: frostRetainedGroupResolvedAddressSetHash( + resolver.addresses, + ), + endpoint: primaryURL, + } + monitor := &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "export.example", + resolvedDNSName: "export-origin.example", + addresses: []netip.Addr{exportAddress}, + endpoint: &url.URL{Scheme: "https", Host: "export.example:443", Path: "/"}, + }, + verifierEndpoint: frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "verifier.example", + resolvedDNSName: "verifier-origin.example", + addresses: []netip.Addr{verifierAddress}, + endpoint: &url.URL{Scheme: "https", Host: "verifier.example:443", Path: "/"}, + }, + primaryTransport: &testFrostPrimaryEthereumIndependenceVerifier{ + verify: func( + ctx context.Context, + exportEndpoint frostRetainedGroupResolvedEndpoint, + verifierEndpoint frostRetainedGroupResolvedEndpoint, + ) error { + currentPrimary, err := resolveFrostRetainedGroupEndpoint( + ctx, + primaryEndpoint.endpoint, + resolver, + ) + if err != nil { + return err + } + if frostRetainedGroupEndpointSetsOverlap( + currentPrimary, + exportEndpoint, + ) || frostRetainedGroupEndpointSetsOverlap( + currentPrimary, + verifierEndpoint, + ) { + return fmt.Errorf( + "primary Ethereum endpoint now aliases a retained endpoint", + ) + } + return nil + }, + }, + } + if err := monitor.verify(context.Background()); err != nil { + t.Fatalf("independent primary endpoint rejected: [%v]", err) + } + resolver.addresses = []netip.Addr{exportAddress} + if err := monitor.verify(context.Background()); err == nil || + !strings.Contains(err.Error(), "now aliases") { + t.Fatalf("primary DNS rebind was accepted: [%v]", err) + } +} + +func performFrostRetainedGroupAttestedTestRequest( + fixture *frostRetainedGroupHistorySourceFixture, +) error { + client, ok := fixture.source.httpClient.(*http.Client) + if !ok { + return io.ErrUnexpectedEOF + } + request, err := http.NewRequest( + http.MethodPost, + fixture.server.URL+"/operator-id", + strings.NewReader("{}"), + ) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if response != nil { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + } + return err +} + +func TestFrostRetainedGroupAttestedTransport_RejectsMissingAndMutatedEvidence( + t *testing.T, +) { + testCases := map[string]func(*frostRetainedGroupHistoryTestExport){ + "missing": func(export *frostRetainedGroupHistoryTestExport) { + export.omitTransportAttestation = true + }, + "duplicate": func(export *frostRetainedGroupHistoryTestExport) { + export.duplicateTransportAttestation = true + }, + "role": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.Role = "retained-history-verifier" + } + }, + "endpoint fingerprint": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.EndpointFingerprint = + frostActivationHex32([32]byte{0xb0}) + } + }, + "canonical endpoint": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.CanonicalEndpoint = + "https://other.example:443/" + } + }, + "canonical DNS": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.CanonicalDNSName = "other.example" + } + }, + "resolved DNS": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResolvedDNSName = "other-origin.example" + } + }, + "resolved peer": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResolvedPeerIP = "192.0.2.99" + } + }, + "TLS leaf": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSLeafSPKIHash = + frostActivationHex32([32]byte{0xb8}) + } + }, + "service identity": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ServiceIdentity = + "spiffe://retained.test/other" + } + }, + "backend": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.BackendServiceFingerprint = + frostActivationHex32([32]byte{0xb1}) + } + }, + "backend signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.BackendSignerPublicKeySPKI = + base64.StdEncoding.EncodeToString([]byte("other")) + } + }, + "backend signature algorithm": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.BackendSignatureAlgorithm = "other" + } + }, + "operator": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.OperatorFingerprint = + frostActivationHex32([32]byte{0xb2}) + } + }, + "operator signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.OperatorSignerPublicKeySPKI = + base64.StdEncoding.EncodeToString([]byte("other")) + } + }, + "operator signature algorithm": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.OperatorSignatureAlgorithm = "other" + } + }, + "attestation key": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.AttestationKeyHash = + frostActivationHex32([32]byte{0xb9}) + } + }, + "TLS exporter protocol": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSExporterProtocolID = + frostActivationHex32([32]byte{0xba}) + } + }, + "request digest": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.RequestBodySHA256 = + frostActivationHex32([32]byte{0xb3}) + } + }, + "response digest": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResponseBodySHA256 = + frostActivationHex32([32]byte{0xb4}) + } + }, + "status": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResponseStatus++ + } + }, + "challenge": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.Challenge = frostActivationHex32([32]byte{0xb5}) + } + }, + "request target": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.RequestTarget += "/other" + } + }, + "exporter context": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSExporterContextSHA256 = + frostActivationHex32([32]byte{0xb6}) + } + }, + "exporter value": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSExporterValueSHA256 = + frostActivationHex32([32]byte{0xb7}) + } + }, + "stale": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.IssuedAtUnixMs = "1" + attestation.ExpiresAtUnixMs = "2" + } + }, + "overflow timestamp": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.IssuedAtUnixMs = "18446744073709551614" + attestation.ExpiresAtUnixMs = "18446744073709551615" + } + }, + "signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.SignerPublicKeySPKI = + base64.StdEncoding.EncodeToString([]byte("other")) + } + }, + "noncanonical signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.SignerPublicKeySPKI += "\n" + } + }, + "signature algorithm": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.SignatureAlgorithm = "other" + } + }, + } + for name, configure := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + configure(fixture.export) + if err := performFrostRetainedGroupAttestedTestRequest(fixture); err == nil { + t.Fatal("unbound transport evidence was accepted") + } + }) + } +} + +func TestFrostRetainedGroupAttestedTransport_RejectsReplayAcrossTLSConnections( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.export.replayTransportAttestation = true + client := fixture.source.httpClient.(*http.Client) + attested := client.Transport.(*frostRetainedGroupAttestedRoundTripper) + challenge := bytes.Repeat([]byte{0x5a}, 32) + attested.random = bytes.NewReader(append(challenge, challenge...)) + if err := performFrostRetainedGroupAttestedTestRequest(fixture); err != nil { + t.Fatalf("initial attested request failed: [%v]", err) + } + if err := performFrostRetainedGroupAttestedTestRequest(fixture); err == nil || + !strings.Contains(err.Error(), "differently bound") { + t.Fatalf("cross-connection replay was accepted: [%v]", err) + } +} + +func TestFrostRetainedGroupAttestedTransport_RejectsHostAndPathAmbiguity( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + client := fixture.source.httpClient.(*http.Client) + for name, mutate := range map[string]func(*http.Request){ + "host override": func(request *http.Request) { + request.Host = "proxy.example:443" + }, + "encoded path": func(request *http.Request) { + request.URL.Path = "/operator-id" + request.URL.RawPath = "/%6fperator-id" + }, + "query": func(request *http.Request) { + request.URL.RawQuery = "proxy=1" + }, + } { + t.Run(name, func(t *testing.T) { + request, err := http.NewRequest( + http.MethodPost, + fixture.server.URL+"/operator-id", + strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + mutate(request) + response, err := client.Do(request) + if response != nil { + _ = response.Body.Close() + } + if err == nil { + t.Fatal("ambiguous transport target was accepted") + } + }) + } + + serverRequest := &http.Request{ + Host: "proxy.example:443", + URL: &url.URL{Path: "/history"}, + } + if _, err := frostRetainedGroupServerRequestTarget( + fixture.identity.Export, + serverRequest, + ); err == nil { + t.Fatal("server accepted a Host header outside the manifest endpoint") + } +} + +func TestFrostRetainedGroupAttestedTransport_DoesNotMutateCallerRequest( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + client := fixture.source.httpClient.(*http.Client) + request, err := http.NewRequest( + http.MethodPost, + fixture.server.URL+"/operator-id", + strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Caller-Header", "preserved") + originalHeader := request.Header.Clone() + + response, err := client.Do(request) + if err != nil { + t.Fatalf("attested request failed: [%v]", err) + } + _ = response.Body.Close() + if !reflect.DeepEqual(request.Header, originalHeader) { + t.Fatalf( + "attested transport mutated caller headers: before [%v], after [%v]", + originalHeader, + request.Header, + ) + } +} + +func TestVerifyFrostRetainedGroupTLSConnection_RequiresLeafAndServicePins( + t *testing.T, +) { + const serviceIdentity = "spiffe://retained-export.example/export" + server, leaf, _ := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.NotFoundHandler(), + serviceIdentity, + ) + _ = server + identity := testFrostRetainedGroupCompleteIdentity().Export + identity.TLSLeafSPKIHash = sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + identity.ServiceIdentity = serviceIdentity + identity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity) + state := tls.ConnectionState{ + Version: tls.VersionTLS13, + HandshakeComplete: true, + NegotiatedProtocol: "http/1.1", + PeerCertificates: []*x509.Certificate{leaf}, + VerifiedChains: [][]*x509.Certificate{{leaf}}, + } + if err := verifyFrostRetainedGroupTLSConnection(state, identity); err != nil { + t.Fatalf("correct TLS identity rejected: [%v]", err) + } + missingALPN := state + missingALPN.NegotiatedProtocol = "" + if err := verifyFrostRetainedGroupTLSConnection( + missingALPN, + identity, + ); err == nil { + t.Fatal("missing ALPN was accepted") + } + wrongALPN := state + wrongALPN.NegotiatedProtocol = "h2" + if err := verifyFrostRetainedGroupTLSConnection( + wrongALPN, + identity, + ); err == nil { + t.Fatal("wrong ALPN was accepted") + } + wrongTLSVersion := state + wrongTLSVersion.Version = tls.VersionTLS12 + if err := verifyFrostRetainedGroupTLSConnection( + wrongTLSVersion, + identity, + ); err == nil { + t.Fatal("wrong TLS version was accepted") + } + unverified := state + unverified.VerifiedChains = nil + if err := verifyFrostRetainedGroupTLSConnection( + unverified, + identity, + ); err == nil { + t.Fatal("unverified TLS chain was accepted") + } + wrongLeaf := identity + wrongLeaf.TLSLeafSPKIHash[31] ^= 0x01 + if err := verifyFrostRetainedGroupTLSConnection( + state, + wrongLeaf, + ); err == nil { + t.Fatal("wrong TLS leaf was accepted") + } + wrongService := identity + wrongService.ServiceIdentity = "spiffe://retained.test/other" + if err := verifyFrostRetainedGroupTLSConnection( + state, + wrongService, + ); err == nil { + t.Fatal("wrong SPIFFE service identity was accepted") + } + secondURI, err := url.Parse("spiffe://retained.test/second") + if err != nil { + t.Fatal(err) + } + ambiguousLeaf := *leaf + ambiguousLeaf.URIs = append( + append([]*url.URL{}, leaf.URIs...), + secondURI, + ) + ambiguous := state + ambiguous.PeerCertificates = []*x509.Certificate{&ambiguousLeaf} + ambiguous.VerifiedChains = [][]*x509.Certificate{{&ambiguousLeaf}} + if err := verifyFrostRetainedGroupTLSConnection( + ambiguous, + identity, + ); err == nil { + t.Fatal("ambiguous SPIFFE URI SAN set was accepted") + } +} + +type frostRetainedGroupVerifierAttestationHandler struct { + t *testing.T + identity FrostRetainedGroupEndpointIdentity + privateKey ed25519.PrivateKey + publicKeyDER []byte + backendPrivateKey ed25519.PrivateKey + backendPublicKeyDER []byte + operatorPrivateKey ed25519.PrivateKey + operatorPublicKeyDER []byte + omit atomic.Bool +} + +func (handler *frostRetainedGroupVerifierAttestationHandler) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + requestBody, err := io.ReadAll(request.Body) + if err != nil { + handler.t.Fatal(err) + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + rpcRequest := struct { + ID json.RawMessage `json:"id"` + }{} + if err := json.Unmarshal(requestBody, &rpcRequest); err != nil { + handler.t.Fatal(err) + } + responseBody, err := json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result string `json:"result"` + }{ + JSONRPC: "2.0", + ID: rpcRequest.ID, + Result: "0x1", + }) + if err != nil { + handler.t.Fatal(err) + } + if !handler.omit.Load() { + localAddress, ok := request.Context().Value( + http.LocalAddrContextKey, + ).(net.Addr) + if !ok { + handler.t.Fatal("missing verifier local address") + } + localIP, err := frostRetainedGroupRemoteIP(localAddress) + if err != nil { + handler.t.Fatal(err) + } + attestation, err := marshalFrostRetainedGroupTransportAttestation( + request, + http.StatusOK, + responseBody, + handler.identity, + handler.privateKey, + handler.publicKeyDER, + handler.backendPrivateKey, + handler.backendPublicKeyDER, + handler.operatorPrivateKey, + handler.operatorPublicKeyDER, + time.Now(), + localIP, + ) + if err != nil { + handler.t.Fatal(err) + } + responseWriter.Header().Set( + frostRetainedGroupTransportAttestationHeader, + attestation, + ) + } + responseWriter.Header().Set("Content-Type", "application/json") + responseWriter.WriteHeader(http.StatusOK) + if _, err := responseWriter.Write(responseBody); err != nil { + handler.t.Fatal(err) + } +} + +func TestFrostRetainedGroupVerifierRPC_RequiresPerResponseConformanceAttestation( + t *testing.T, +) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + backendPublicKey, backendPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + backendPublicKeyDER, err := x509.MarshalPKIXPublicKey(backendPublicKey) + if err != nil { + t.Fatal(err) + } + operatorPublicKey, operatorPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + operatorPublicKeyDER, err := x509.MarshalPKIXPublicKey(operatorPublicKey) + if err != nil { + t.Fatal(err) + } + handler := &frostRetainedGroupVerifierAttestationHandler{ + t: t, + privateKey: privateKey, + publicKeyDER: publicKeyDER, + backendPrivateKey: backendPrivateKey, + backendPublicKeyDER: backendPublicKeyDER, + operatorPrivateKey: operatorPrivateKey, + operatorPublicKeyDER: operatorPublicKeyDER, + } + const serviceIdentity = "spiffe://retained-verifier.example/verifier" + server, leaf, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + handler, + serviceIdentity, + ) + endpointURL, canonicalEndpoint, err := + validateFrostRetainedGroupTLSEndpoint(server.URL + "/") + if err != nil { + t.Fatal(err) + } + resolvedEndpoint, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + endpointURL, + nil, + ) + if err != nil { + t.Fatal(err) + } + identity := testFrostRetainedGroupCompleteIdentity().Verifier + identity.CanonicalEndpoint = canonicalEndpoint + identity.CanonicalDNSName = resolvedEndpoint.canonicalDNSName + identity.ResolvedDNSName = resolvedEndpoint.resolvedDNSName + identity.ResolvedAddressSetHash = resolvedEndpoint.addressSetHash + identity.TLSLeafSPKIHash = sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + identity.ServiceIdentity = serviceIdentity + identity.BackendServiceFingerprint = sha256.Sum256(backendPublicKeyDER) + identity.OperatorFingerprint = sha256.Sum256(operatorPublicKeyDER) + identity.AttestationKeyHash = sha256.Sum256(publicKeyDER) + identity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity) + handler.identity = identity + client, transport, err := newFrostRetainedGroupAttestedHTTPClient( + resolvedEndpoint, + identity, + roots, + time.Second, + ) + if err != nil { + t.Fatal(err) + } + defer transport.CloseIdleConnections() + rpcClient, err := rpc.DialOptions( + context.Background(), + canonicalEndpoint, + rpc.WithHTTPClient(client), + ) + if err != nil { + t.Fatal(err) + } + defer rpcClient.Close() + var result string + if err := rpcClient.CallContext( + context.Background(), + &result, + "eth_chainId", + ); err != nil || result != "0x1" { + t.Fatalf("attested verifier RPC failed: [%s] [%v]", result, err) + } + handler.omit.Store(true) + if err := rpcClient.CallContext( + context.Background(), + &result, + "eth_chainId", + ); err == nil || !strings.Contains(err.Error(), "attestation") { + t.Fatalf("generic unattested RPC response was accepted: [%v]", err) + } +} + +func TestFrostRetainedGroupTLSExporterProtocolIDFrozen(t *testing.T) { + const expected = "42e4447e7981f7691f5d7a0f93fa5500bbc3dda7e58438a9db475ae6c4e39b5c" + protocolID := frostRetainedGroupTLSExporterProtocolID() + actual := hex.EncodeToString(protocolID[:]) + if actual != expected { + t.Fatalf("frozen TLS exporter protocol ID changed: [%s]", actual) + } +} diff --git a/pkg/tbtc/frost_retained_group_history_evidence.go b/pkg/tbtc/frost_retained_group_history_evidence.go new file mode 100644 index 0000000000..b47f8fc847 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -0,0 +1,1413 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "math/big" + "sort" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + ethabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + bridgeabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + frostregistry "github.com/keep-network/keep-core/pkg/frost/registry" +) + +// FrostRetainedGroupActivationEvidenceBinder binds a retained-group history +// source to the exact deployment and descriptor set authenticated by the +// signed activation manifest. Production activation requires this binding +// before the source can authenticate semantic history or operator IDs. +type FrostRetainedGroupActivationEvidenceBinder interface { + BindFrostRetainedGroupActivationEvidence( + FrostPreSignActivationProfile, + FrostPreSignActivationRuntimeManifest, + ) error +} + +type FrostRetainedGroupProtocolBindingSource interface { + FrostRetainedGroupProtocolBindingHash() ([32]byte, error) +} + +type frostRetainedGroupEvidenceProfile struct { + manifestHash [32]byte + profileHash [32]byte + implementationSetHash [32]byte + descriptorSetHash [32]byte + linkedLibraryDescriptorSetHash [32]byte + inventoryProtocolID [32]byte + quarantineProtocolID [32]byte + domainChainID [32]byte + genesisBlockHash [32]byte + bindingHash [32]byte + liftPolicy frostRetainedGroupQuarantineLiftPolicy + checkpointPolicy frostRetainedGroupCheckpointPolicy + deployments map[string]FrostPreSignDeploymentEvidence + bridgeABI ethabi.ABI + registryABI ethabi.ABI +} + +type frostRetainedGroupReceiptCache map[common.Hash]*types.Receipt + +type frostRetainedGroupCodeCacheKey struct { + address common.Address + blockHash common.Hash + codeHash common.Hash + descriptorHash common.Hash + verified bool +} + +type frostRetainedGroupCodeCache map[frostRetainedGroupCodeCacheKey][]byte + +var _ FrostRetainedGroupActivationEvidenceBinder = (*signedFrostRetainedGroupHistorySource)(nil) +var _ FrostRetainedGroupProtocolBindingSource = (*signedFrostRetainedGroupHistorySource)(nil) + +// BindFrostRetainedGroupActivationEvidence is deliberately one-shot. The +// profile and descriptor are supplied only after the activation envelope has +// been signature-checked and converted to its immutable runtime manifest. +func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( + profile FrostPreSignActivationProfile, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) error { + if source == nil { + return fmt.Errorf("retained-group history source is nil") + } + if err := profile.ValidateForProduction(); err != nil { + return fmt.Errorf("retained-group activation profile is invalid: [%w]", err) + } + if runtimeManifest.ManifestHash == [32]byte{} || + runtimeManifest.ProfileHash == [32]byte{} || + runtimeManifest.GenesisBlockHash == [32]byte{} || + runtimeManifest.ImplementationSetHash == [32]byte{} || + runtimeManifest.LinkedLibraryDescriptorSetHash == [32]byte{} || + runtimeManifest.EndpointIdentitySetHash == [32]byte{} || + runtimeManifest.CanonicalJournal.DescriptorSetHash == [32]byte{} || + runtimeManifest.RetainedGroupInventoryProtocolID == [32]byte{} || + runtimeManifest.QuarantineJournal.ProtocolID == [32]byte{} || + profile.ActivationManifestHash != runtimeManifest.ManifestHash || + profile.ProfileHash != runtimeManifest.ProfileHash || + profile.ImplementationSetHash != runtimeManifest.ImplementationSetHash || + profile.DomainChainID != runtimeManifest.DomainChainID || + profile.ReservationProtocolID != runtimeManifest.ReservationProtocolID || + profile.SigningPolicyHash != runtimeManifest.SigningPolicyHash || + runtimeManifest.SignerProtocolID == [32]byte{} || + runtimeManifest.BitcoinOutboxProtocolID == [32]byte{} || + runtimeManifest.CanonicalJournal.Checkpoint.BlockNumber == 0 || + runtimeManifest.CanonicalJournal.Checkpoint.BlockHash == [32]byte{} || + strings.TrimSpace(runtimeManifest.CanonicalJournal.StoreID) == "" || + runtimeManifest.CanonicalJournal.StoreFingerprint == [32]byte{} || + runtimeManifest.CanonicalJournal.ClusterFingerprint == [32]byte{} || + strings.TrimSpace(runtimeManifest.QuarantineJournal.StoreID) == "" || + runtimeManifest.QuarantineJournal.StoreFingerprint == [32]byte{} || + runtimeManifest.QuarantineJournal.ClusterFingerprint == [32]byte{} || + runtimeManifest.CanonicalJournal.SourceTrustDomainID != + source.identity.TrustDomainID || + runtimeManifest.CanonicalJournal.SourceEndpointFingerprint != + source.identity.EndpointFingerprint || + runtimeManifest.CanonicalJournal.SourceOperatorFingerprint != + source.identity.OperatorFingerprint || + runtimeManifest.CanonicalJournal.SourceIdentity != source.identity || + new(big.Int).SetBytes(runtimeManifest.DomainChainID[:]).BitLen() > 64 || + new(big.Int).SetBytes(runtimeManifest.DomainChainID[:]).Uint64() != + source.chainID || + ComputeFrostPreSignDeploymentEvidenceHash(runtimeManifest.Deployments) != + profile.ImplementationSetHash { + return fmt.Errorf("retained-group evidence does not match the signed activation manifest") + } + deployments, err := validateFrostRetainedGroupDeploymentEvidence( + runtimeManifest.Deployments, + ) + if err != nil { + return err + } + for role, expected := range map[string]struct { + address [20]byte + codeHash [32]byte + }{ + "bridge": { + address: profile.BridgeAddress, + codeHash: profile.BridgeCodeHash, + }, + "completeRouter": { + address: profile.CompleteRouter, + codeHash: profile.CompleteRouterCodeHash, + }, + "authorizationRegistry": { + address: profile.RegistryAddress, + codeHash: profile.RegistryCodeHash, + }, + "frostWalletRegistry": { + address: profile.FrostRegistry, + codeHash: profile.FrostRegistryCodeHash, + }, + "frostProposalValidator": { + address: profile.ProposalValidator, + codeHash: profile.ProposalValidatorCodeHash, + }, + "frostSortitionPool": { + address: profile.SortitionPool, + codeHash: profile.SortitionPoolCodeHash, + }, + } { + deployment := deployments[role] + if deployment.Current.Address != expected.address || + deployment.Current.RuntimeCodeHash != expected.codeHash { + return fmt.Errorf( + "retained-group deployment [%s] differs from the activation profile", + role, + ) + } + } + linkedLibraryDescriptorSetHash, err := + frostRetainedGroupLinkedLibraryDescriptorSetHash( + runtimeManifest.Deployments, + ) + if err != nil || + linkedLibraryDescriptorSetHash != + runtimeManifest.LinkedLibraryDescriptorSetHash { + return fmt.Errorf( + "retained-group linked-library descriptor set differs from the signed activation manifest", + ) + } + bindingHash, err := source.computeProtocolBinding( + profile, + runtimeManifest, + ) + if err != nil { + return err + } + liftPolicy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return fmt.Errorf( + "retained-group quarantine lift policy is invalid: [%w]", + err, + ) + } + checkpointPolicy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return fmt.Errorf( + "retained-group checkpoint policy is invalid: [%w]", + err, + ) + } + parsedBridgeABI, err := ethabi.JSON(strings.NewReader(bridgeabi.BridgeMetaData.ABI)) + if err != nil { + return fmt.Errorf("cannot parse pinned Bridge ABI: [%w]", err) + } + parsedRegistryABI, err := ethabi.JSON(strings.NewReader(frostabi.FrostWalletRegistryMetaData.ABI)) + if err != nil { + return fmt.Errorf("cannot parse pinned FROST registry ABI: [%w]", err) + } + evidence := &frostRetainedGroupEvidenceProfile{ + manifestHash: runtimeManifest.ManifestHash, + profileHash: runtimeManifest.ProfileHash, + implementationSetHash: runtimeManifest.ImplementationSetHash, + descriptorSetHash: runtimeManifest.CanonicalJournal.DescriptorSetHash, + linkedLibraryDescriptorSetHash: runtimeManifest.LinkedLibraryDescriptorSetHash, + inventoryProtocolID: runtimeManifest.RetainedGroupInventoryProtocolID, + quarantineProtocolID: runtimeManifest.QuarantineJournal.ProtocolID, + domainChainID: runtimeManifest.DomainChainID, + genesisBlockHash: runtimeManifest.GenesisBlockHash, + bindingHash: bindingHash, + liftPolicy: liftPolicy, + checkpointPolicy: checkpointPolicy, + deployments: deployments, + bridgeABI: parsedBridgeABI, + registryABI: parsedRegistryABI, + } + for name, event := range map[string]struct { + contract *ethabi.ABI + topic common.Hash + }{ + "DkgResultSubmitted": {&evidence.registryABI, common.HexToHash("0xbfc6cd6291b6741d3ac1631ba81a0288d08265bea4d59d452e8c953e11ec11c6")}, + "DkgResultApproved": {&evidence.registryABI, common.HexToHash("0xe6e9d5eba171e82025efb3f3d44fd35905e7283d104284cb9f3bbc5bf1e4276f")}, + "WalletCreated": {&evidence.registryABI, common.HexToHash("0xbe8f27cef1f3d94120c9c547c3614f5b992fdb0c0a497cc920fde06546291ab4")}, + "WalletClosed": {&evidence.registryABI, common.HexToHash("0xa6ae4af610b8ada39d3675190ead27a5552631a8e33f53e4e37dbb082f11a73e")}, + "NewWalletRegisteredV2": {&evidence.bridgeABI, common.HexToHash("0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b")}, + "WalletMovingFunds": {&evidence.bridgeABI, common.HexToHash("0xbdc9ce990a067e5fd3a5d8dfc68e27e9f221aaa3fe55265e0b7e93c460b3efe2")}, + "WalletClosing": {&evidence.bridgeABI, common.HexToHash("0x68cb496f5e64383745876664ef119840f154a729c03ba866b8aecb5c9f53d516")}, + "BridgeWalletClosed": {&evidence.bridgeABI, common.HexToHash("0x47b159947c3066cb253f60e8f046cfd747411788a545cb189679e3fa1467b28d")}, + "WalletTerminated": {&evidence.bridgeABI, common.HexToHash("0x9272a280b0f32f70b00ad0b546499c68e3ecc6f7bb7ef43491ec5d7b99bf69ef")}, + } { + eventName := name + if name == "BridgeWalletClosed" { + eventName = "WalletClosed" + } + parsedEvent, ok := event.contract.Events[eventName] + if !ok || parsedEvent.ID != event.topic { + return fmt.Errorf("pinned retained-group event descriptor [%s] is unavailable or changed", name) + } + } + + source.evidenceMutex.Lock() + defer source.evidenceMutex.Unlock() + if source.evidence != nil { + return fmt.Errorf("retained-group activation evidence is already bound") + } + source.evidence = evidence + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) computeProtocolBinding( + profile FrostPreSignActivationProfile, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) ([32]byte, error) { + liftAuthoritySetHash, err := frostRetainedGroupLiftAuthoritySetHash( + runtimeManifest.QuarantineJournal.LiftAuthorityThreshold, + runtimeManifest.QuarantineJournal.LiftAuthorities, + ) + if err != nil { + return [32]byte{}, err + } + checkpointAuthoritySetHash, err := frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + runtimeManifest.QuarantineJournal.CheckpointAuthorityThreshold, + runtimeManifest.QuarantineJournal.CheckpointAuthorities, + ) + if err != nil { + return [32]byte{}, err + } + binding := frostRetainedGroupProtocolBinding{ + Schema: "tbtc-frost-retained-group-protocol-binding/v4", + ChainID: source.chainID, + DomainChainID: frostActivationHex32(runtimeManifest.DomainChainID), + GenesisBlockHash: frostActivationHex32(runtimeManifest.GenesisBlockHash), + Checkpoint: frostRetainedGroupWireBlockPoint{ + BlockNumber: runtimeManifest.CanonicalJournal.Checkpoint.BlockNumber, + BlockHash: frostActivationHex32( + runtimeManifest.CanonicalJournal.Checkpoint.BlockHash, + ), + }, + ManifestHash: frostActivationHex32(runtimeManifest.ManifestHash), + ProfileHash: frostActivationHex32(runtimeManifest.ProfileHash), + ImplementationSetHash: frostActivationHex32(runtimeManifest.ImplementationSetHash), + DescriptorSetHash: frostActivationHex32(runtimeManifest.CanonicalJournal.DescriptorSetHash), + LinkedLibraryDescriptorSetHash: frostActivationHex32(runtimeManifest.LinkedLibraryDescriptorSetHash), + EndpointIdentitySetHash: frostActivationHex32(runtimeManifest.EndpointIdentitySetHash), + SignerProtocolID: frostActivationHex32(runtimeManifest.SignerProtocolID), + ReservationProtocolID: frostActivationHex32(runtimeManifest.ReservationProtocolID), + EvidenceProtocolID: frostActivationHex32(profile.EvidenceProtocolID), + BitcoinOutboxProtocolID: frostActivationHex32(runtimeManifest.BitcoinOutboxProtocolID), + InventoryProtocolID: frostActivationHex32(runtimeManifest.RetainedGroupInventoryProtocolID), + QuarantineProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.ProtocolID), + LiftProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.LiftProtocolID), + TombstoneProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.TombstoneProtocolID), + LiftAuthoritySetHash: frostActivationHex32(liftAuthoritySetHash), + CheckpointAuthoritySetHash: frostActivationHex32(checkpointAuthoritySetHash), + CheckpointMinimumSequence: runtimeManifest.QuarantineJournal.CheckpointMinimumSequence, + CheckpointPredecessorHash: frostActivationHex32(runtimeManifest.QuarantineJournal.CheckpointPredecessorHash), + SigningPolicyHash: frostActivationHex32(runtimeManifest.SigningPolicyHash), + CanonicalStoreID: runtimeManifest.CanonicalJournal.StoreID, + CanonicalStoreFingerprint: frostActivationHex32( + runtimeManifest.CanonicalJournal.StoreFingerprint, + ), + CanonicalClusterFingerprint: frostActivationHex32( + runtimeManifest.CanonicalJournal.ClusterFingerprint, + ), + QuarantineStoreID: runtimeManifest.QuarantineJournal.StoreID, + QuarantineStoreFingerprint: frostActivationHex32( + runtimeManifest.QuarantineJournal.StoreFingerprint, + ), + QuarantineClusterFingerprint: frostActivationHex32( + runtimeManifest.QuarantineJournal.ClusterFingerprint, + ), + SourceIdentity: frostRetainedGroupIdentityToWire(source.identity), + } + return frostRetainedGroupDomainHash( + frostRetainedGroupProtocolBindingDomain, + binding, + ) +} + +func validateFrostRetainedGroupDeploymentEvidence( + deployments []FrostPreSignDeploymentEvidence, +) (map[string]FrostPreSignDeploymentEvidence, error) { + requiredRoles := map[string]bool{ + "bridge": false, + "completeRouter": false, + "authorizationRegistry": false, + "frostWalletRegistry": false, + "frostProposalValidator": false, + "frostSortitionPool": false, + "ecdsaFraudRouter": false, + "ecdsaCutoverCoordinator": false, + } + if len(deployments) != len(requiredRoles) { + return nil, fmt.Errorf("retained-group deployment evidence is incomplete") + } + result := make(map[string]FrostPreSignDeploymentEvidence, len(deployments)) + for _, deployment := range deployments { + if _, required := requiredRoles[deployment.Role]; !required || + requiredRoles[deployment.Role] || + strings.TrimSpace(deployment.Name) == "" || + deployment.DeploymentBlock == 0 || + deployment.RelevantEventStartBlock < deployment.DeploymentBlock || + len(deployment.HistoricalEpochs) == 0 || + len(deployment.HistoricalEpochs) > 64 { + return nil, fmt.Errorf("retained-group deployment [%s] is invalid", deployment.Role) + } + requiredRoles[deployment.Role] = true + if err := validateFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ); err != nil { + return nil, fmt.Errorf("invalid current %s deployment descriptor: [%w]", deployment.Role, err) + } + for index, epoch := range deployment.HistoricalEpochs { + if epoch.Start.BlockNumber == 0 || epoch.Start.BlockHash == [32]byte{} || + (index == 0 && + epoch.Start.BlockNumber != deployment.DeploymentBlock) || + (index+1 < len(deployment.HistoricalEpochs) && epoch.End == nil) || + (index+1 == len(deployment.HistoricalEpochs) && epoch.End != nil) { + return nil, fmt.Errorf("retained-group %s epoch [%d] range is invalid", deployment.Role, index) + } + if epoch.End != nil && + (epoch.End.BlockNumber < epoch.Start.BlockNumber || + epoch.End.BlockHash == [32]byte{}) { + return nil, fmt.Errorf("retained-group %s epoch [%d] end is invalid", deployment.Role, index) + } + if index > 0 { + previous := deployment.HistoricalEpochs[index-1] + if previous.End == nil || + previous.End.BlockNumber == ^uint64(0) || + previous.End.BlockNumber+1 != epoch.Start.BlockNumber { + return nil, fmt.Errorf("retained-group %s epochs have a gap or overlap", deployment.Role) + } + } + if err := validateFrostRetainedGroupDeploymentDescriptor( + epoch.Descriptor, + ); err != nil { + return nil, fmt.Errorf("invalid %s epoch [%d] descriptor: [%w]", deployment.Role, index, err) + } + } + if deployment.RelevantEventStartBlock < + deployment.HistoricalEpochs[0].Start.BlockNumber || + deployment.Current.DescriptorHash != + deployment.HistoricalEpochs[len(deployment.HistoricalEpochs)-1].Descriptor.DescriptorHash { + return nil, fmt.Errorf("retained-group %s epochs do not cover the event range", deployment.Role) + } + result[deployment.Role] = cloneFrostRetainedGroupDeploymentEvidence( + deployment, + ) + } + return result, nil +} + +func validateFrostRetainedGroupDeploymentDescriptor( + descriptor FrostPreSignDeploymentDescriptorEvidence, +) error { + if descriptor.Address == [20]byte{} || + descriptor.RuntimeCodeHash == [32]byte{} || + descriptor.LinkedLibraryDescriptorHash == [32]byte{} || + descriptor.DescriptorHash == [32]byte{} || + descriptor.ComputeHash() != descriptor.DescriptorHash { + return fmt.Errorf("deployment descriptor identity or commitment is invalid") + } + switch descriptor.Upgradeability { + case "immutable": + if descriptor.ImplementationAddress != [20]byte{} || + descriptor.ImplementationCodeHash != [32]byte{} || + descriptor.AdminAddress != [20]byte{} || + descriptor.AdminCodeHash != [32]byte{} || + descriptor.ImplementationSlotValue != [32]byte{} || + descriptor.AdminSlotValue != [32]byte{} { + return fmt.Errorf("immutable deployment descriptor contains proxy fields") + } + case "eip1967": + if descriptor.ImplementationAddress == [20]byte{} || + descriptor.ImplementationCodeHash == [32]byte{} || + descriptor.AdminAddress == [20]byte{} || + descriptor.AdminCodeHash == [32]byte{} || + descriptor.ImplementationAddress == descriptor.AdminAddress || + descriptor.ImplementationAddress == descriptor.Address || + descriptor.AdminAddress == descriptor.Address || + !frostRetainedGroupSlotValueBindsAddress( + descriptor.ImplementationSlotValue, + descriptor.ImplementationAddress, + ) || + !frostRetainedGroupSlotValueBindsAddress( + descriptor.AdminSlotValue, + descriptor.AdminAddress, + ) { + return fmt.Errorf("EIP-1967 deployment descriptor is incomplete") + } + default: + return fmt.Errorf("unsupported upgradeability [%s]", descriptor.Upgradeability) + } + count := 0 + if err := validateFrostRetainedGroupLinkedLibraries( + descriptor.LinkedLibraries, + 0, + &count, + ); err != nil { + return err + } + computedDescriptorHash, err := + frostRetainedGroupLinkedLibraryInventoryHash( + descriptor.LinkedLibraries, + ) + if err != nil || + computedDescriptorHash != descriptor.LinkedLibraryDescriptorHash { + return fmt.Errorf("linked-library descriptor hash mismatch") + } + return nil +} + +func validateFrostRetainedGroupLinkedLibraries( + libraries []FrostPreSignLinkedLibraryEvidence, + depth int, + count *int, +) error { + if count == nil || depth > 16 { + return fmt.Errorf("linked-library evidence is too deep") + } + roles := make(map[string]bool) + addresses := make(map[[20]byte]bool) + var previousRole string + for index, library := range libraries { + (*count)++ + if *count > 256 || + !frostRetainedGroupValidProtocolRole(library.ProtocolRole) || + (index > 0 && library.ProtocolRole <= previousRole) || + roles[library.ProtocolRole] || addresses[library.Address] || + library.Address == [20]byte{} || + library.RuntimeCodeHash == [32]byte{} || + library.LinkedLibraryDescriptorHash == [32]byte{} || + len(library.References) == 0 { + return fmt.Errorf("linked-library evidence is noncanonical") + } + roles[library.ProtocolRole] = true + addresses[library.Address] = true + previousRole = library.ProtocolRole + for referenceIndex, reference := range library.References { + if reference.Length != 20 || + reference.Start > ^uint64(0)-reference.Length || + (referenceIndex > 0 && + library.References[referenceIndex-1].Start+ + library.References[referenceIndex-1].Length > + reference.Start) { + return fmt.Errorf("linked-library references are noncanonical") + } + } + if err := validateFrostRetainedGroupLinkedLibraries( + library.LinkedLibraries, + depth+1, + count, + ); err != nil { + return err + } + computedDescriptorHash, err := + frostRetainedGroupLinkedLibraryInventoryHash( + library.LinkedLibraries, + ) + if err != nil || + computedDescriptorHash != library.LinkedLibraryDescriptorHash { + return fmt.Errorf( + "linked-library [%s] descriptor hash mismatch", + library.ProtocolRole, + ) + } + } + return nil +} + +func frostRetainedGroupValidProtocolRole(value string) bool { + if len(value) == 0 || len(value) > 255 { + return false + } + for _, character := range []byte(value) { + if (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + strings.ContainsRune("._:/-", rune(character)) { + continue + } + return false + } + return true +} + +type frostRetainedGroupLinkedLibraryReferenceCommitment struct { + Start uint64 `json:"start"` + Length uint64 `json:"length"` +} + +type frostRetainedGroupLinkedLibraryDescriptorCommitment struct { + ProtocolRole string `json:"protocolRole"` + References []frostRetainedGroupLinkedLibraryReferenceCommitment `json:"references"` + LinkedLibraries []frostRetainedGroupLinkedLibraryDescriptorCommitment `json:"linkedLibraries"` +} + +func frostRetainedGroupLinkedLibraryDescriptors( + libraries []FrostPreSignLinkedLibraryEvidence, +) []frostRetainedGroupLinkedLibraryDescriptorCommitment { + result := make( + []frostRetainedGroupLinkedLibraryDescriptorCommitment, + 0, + len(libraries), + ) + for _, library := range libraries { + references := make( + []frostRetainedGroupLinkedLibraryReferenceCommitment, + 0, + len(library.References), + ) + for _, reference := range library.References { + references = append( + references, + frostRetainedGroupLinkedLibraryReferenceCommitment{ + Start: reference.Start, + Length: reference.Length, + }, + ) + } + result = append( + result, + frostRetainedGroupLinkedLibraryDescriptorCommitment{ + ProtocolRole: library.ProtocolRole, + References: references, + LinkedLibraries: frostRetainedGroupLinkedLibraryDescriptors( + library.LinkedLibraries, + ), + }, + ) + } + return result +} + +func frostRetainedGroupLinkedLibraryInventoryHash( + libraries []FrostPreSignLinkedLibraryEvidence, +) ([32]byte, error) { + canonical, err := canonicalFrostActivationValue(map[string]interface{}{ + "schema": "tbtc-p2tr-linked-library-inventory/v1", + "linkedLibraries": frostRetainedGroupLinkedLibraryDescriptors( + libraries, + ), + }) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func frostRetainedGroupLinkedLibraryDescriptorSetHash( + deployments []FrostPreSignDeploymentEvidence, +) ([32]byte, error) { + type epochDescriptor struct { + StartBlock uint64 `json:"startBlock"` + EndBlock *uint64 `json:"endBlock"` + CodeKind string `json:"codeKind"` + LinkedLibraries []frostRetainedGroupLinkedLibraryDescriptorCommitment `json:"linkedLibraries"` + } + type contractDescriptor struct { + ContractRole string `json:"contractRole"` + CodeKind string `json:"codeKind"` + LinkedLibraries []frostRetainedGroupLinkedLibraryDescriptorCommitment `json:"linkedLibraries"` + HistoricalEpochs []epochDescriptor `json:"historicalEpochs"` + } + contracts := make([]contractDescriptor, 0, len(deployments)) + for _, deployment := range deployments { + codeKind := "runtime" + if deployment.Current.Upgradeability == "eip1967" { + codeKind = "implementation-runtime" + } + historicalEpochs := make( + []epochDescriptor, + 0, + len(deployment.HistoricalEpochs), + ) + for _, epoch := range deployment.HistoricalEpochs { + epochCodeKind := "runtime" + if epoch.Descriptor.Upgradeability == "eip1967" { + epochCodeKind = "implementation-runtime" + } + var endBlock *uint64 + if epoch.End != nil { + value := epoch.End.BlockNumber + endBlock = &value + } + historicalEpochs = append( + historicalEpochs, + epochDescriptor{ + StartBlock: epoch.Start.BlockNumber, + EndBlock: endBlock, + CodeKind: epochCodeKind, + LinkedLibraries: frostRetainedGroupLinkedLibraryDescriptors( + epoch.Descriptor.LinkedLibraries, + ), + }, + ) + } + contracts = append( + contracts, + contractDescriptor{ + ContractRole: deployment.Role, + CodeKind: codeKind, + LinkedLibraries: frostRetainedGroupLinkedLibraryDescriptors( + deployment.Current.LinkedLibraries, + ), + HistoricalEpochs: historicalEpochs, + }, + ) + } + sort.Slice(contracts, func(i, j int) bool { + return contracts[i].ContractRole < contracts[j].ContractRole + }) + canonical, err := canonicalFrostActivationValue(map[string]interface{}{ + "schema": "tbtc-p2tr-linked-library-descriptor-set/v2", + "contracts": contracts, + }) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func frostRetainedGroupSlotValueBindsAddress( + value [32]byte, + address [20]byte, +) bool { + return bytes.Equal(value[:12], make([]byte, 12)) && + bytes.Equal(value[12:], address[:]) +} + +func cloneFrostRetainedGroupDeploymentEvidence( + deployment FrostPreSignDeploymentEvidence, +) FrostPreSignDeploymentEvidence { + result := deployment + result.Current = cloneFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ) + result.HistoricalEpochs = make( + []FrostPreSignDeploymentEpochEvidence, + len(deployment.HistoricalEpochs), + ) + for index, epoch := range deployment.HistoricalEpochs { + result.HistoricalEpochs[index] = epoch + if epoch.End != nil { + end := *epoch.End + result.HistoricalEpochs[index].End = &end + } + result.HistoricalEpochs[index].Descriptor = + cloneFrostRetainedGroupDeploymentDescriptor(epoch.Descriptor) + } + return result +} + +func cloneFrostRetainedGroupDeploymentDescriptor( + descriptor FrostPreSignDeploymentDescriptorEvidence, +) FrostPreSignDeploymentDescriptorEvidence { + result := descriptor + result.LinkedLibraries = cloneFrostRetainedGroupLinkedLibraries( + descriptor.LinkedLibraries, + ) + return result +} + +func cloneFrostRetainedGroupLinkedLibraries( + libraries []FrostPreSignLinkedLibraryEvidence, +) []FrostPreSignLinkedLibraryEvidence { + result := make([]FrostPreSignLinkedLibraryEvidence, len(libraries)) + for index, library := range libraries { + result[index] = library + result[index].References = append( + []FrostPreSignLinkedLibraryReference{}, + library.References..., + ) + result[index].LinkedLibraries = cloneFrostRetainedGroupLinkedLibraries( + library.LinkedLibraries, + ) + } + return result +} + +func (source *signedFrostRetainedGroupHistorySource) activationEvidence() ( + *frostRetainedGroupEvidenceProfile, + error, +) { + if source == nil { + return nil, fmt.Errorf("retained-group history source is nil") + } + source.evidenceMutex.RLock() + defer source.evidenceMutex.RUnlock() + if source.evidence == nil { + return nil, fmt.Errorf("retained-group history source is not bound to the signed activation manifest") + } + return source.evidence, nil +} + +func (source *signedFrostRetainedGroupHistorySource) FrostRetainedGroupProtocolBindingHash() ( + [32]byte, + error, +) { + evidence, err := source.activationEvidence() + if err != nil { + return [32]byte{}, err + } + return evidence.bindingHash, nil +} + +func (source *signedFrostRetainedGroupHistorySource) verifyHistoryEvidence( + ctx context.Context, + mutations []FrostRetainedGroupMutation, + evidence *frostRetainedGroupEvidenceProfile, +) error { + if evidence == nil { + return fmt.Errorf("retained-group activation evidence is nil") + } + receipts := make(frostRetainedGroupReceiptCache) + code := make(frostRetainedGroupCodeCache) + for index, mutation := range mutations { + if err := source.verifyMutationEvidence(ctx, mutation, evidence, receipts, code); err != nil { + return fmt.Errorf("mutation [%d] [%s]: [%w]", index, mutation.Kind, err) + } + } + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) verifyMutationEvidence( + ctx context.Context, + mutation FrostRetainedGroupMutation, + evidence *frostRetainedGroupEvidenceProfile, + receipts frostRetainedGroupReceiptCache, + code frostRetainedGroupCodeCache, +) error { + // Quarantine, recovery-required, and lift records are signed operational + // records, not Ethereum events. Their Point is only a canonical finalized + // ordering anchor and is deliberately never presented as receipt evidence. + if isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + if _, err := validateFrostRetainedGroupLiftCertificateShape( + evidence.liftPolicy, + mutation.LiftCertificate, + ); err != nil { + return err + } + if err := source.VerifyPoint( + ctx, + mutation.LiftCertificate.Body.ResolutionFinality, + ); err != nil { + return fmt.Errorf( + "quarantine lift resolution finality is not canonical: [%w]", + err, + ) + } + } + return nil + } + + switch mutation.Kind { + case FrostRetainedGroupAdmissionMutation: + return source.verifyAdmissionEvidence(ctx, mutation, evidence, receipts, code) + case FrostRetainedGroupMovingFundsMutation, + FrostRetainedGroupClosingMutation, + FrostRetainedGroupClosedMutation, + FrostRetainedGroupTerminatedMutation: + eventName := map[FrostRetainedGroupMutationKind]string{ + FrostRetainedGroupMovingFundsMutation: "WalletMovingFunds", + FrostRetainedGroupClosingMutation: "WalletClosing", + FrostRetainedGroupClosedMutation: "WalletClosed", + FrostRetainedGroupTerminatedMutation: "WalletTerminated", + }[mutation.Kind] + log, err := source.authenticatedEventLog( + ctx, + mutation.Point, + evidence.deployments["bridge"], + evidence.bridgeABI.Events[eventName].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(log.Topics) != 3 || len(log.Data) != 0 || log.Topics[1] != (common.Hash{}) || + log.Topics[2] != frostRetainedGroupBytes20Topic(mutation.WalletPublicKeyHash) { + return fmt.Errorf("Bridge lifecycle log does not encode the exported FROST wallet") + } + return nil + case FrostRetainedGroupRegistryClosureMutation: + log, err := source.authenticatedEventLog( + ctx, + mutation.Point, + evidence.deployments["frostWalletRegistry"], + evidence.registryABI.Events["WalletClosed"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(log.Topics) != 2 || len(log.Data) != 0 || log.Topics[1] != common.Hash(mutation.WalletID) { + return fmt.Errorf("FROST registry closure log does not encode the exported wallet") + } + return nil + default: + return fmt.Errorf("unsupported retained-group mutation kind [%s]", mutation.Kind) + } +} + +func (source *signedFrostRetainedGroupHistorySource) verifyAdmissionEvidence( + ctx context.Context, + mutation FrostRetainedGroupMutation, + evidence *frostRetainedGroupEvidenceProfile, + receipts frostRetainedGroupReceiptCache, + code frostRetainedGroupCodeCache, +) error { + if mutation.Point != mutation.BridgeRegistrationPoint || + compareFrostRetainedGroupEventPoints(mutation.DkgSubmissionPoint, mutation.DkgApprovalPoint) >= 0 || + !sameFrostRetainedGroupTransaction(mutation.DkgApprovalPoint, mutation.CreationPoint) || + !sameFrostRetainedGroupTransaction(mutation.CreationPoint, mutation.BridgeRegistrationPoint) || + mutation.DkgApprovalPoint.LogIndex >= mutation.CreationPoint.LogIndex || + mutation.CreationPoint.LogIndex >= mutation.BridgeRegistrationPoint.LogIndex { + return fmt.Errorf("admission evidence points are not the required DKG/registration sequence") + } + + submissionLog, err := source.authenticatedEventLog( + ctx, + mutation.DkgSubmissionPoint, + evidence.deployments["frostWalletRegistry"], + evidence.registryABI.Events["DkgResultSubmitted"].ID, + receipts, + code, + ) + if err != nil { + return err + } + result, resultHash, err := frostRetainedGroupDecodeDkgSubmission( + submissionLog, + evidence, + ) + if err != nil { + return err + } + fullMembers := frostregistry.FullMembers(result.Members) + misbehaved := frostregistry.MisbehavedMemberIndices( + result.MisbehavedMembersIndices, + ) + activeMembers, err := frostregistry.ActiveMembersFromMisbehaved( + fullMembers, + misbehaved, + ) + if err != nil { + return fmt.Errorf("DKG submission has invalid misbehaved member indices: [%w]", err) + } + if len(fullMembers) > 100 { + return fmt.Errorf("DKG submission exceeds the supported group size") + } + for _, operatorID := range fullMembers { + if operatorID == 0 { + return fmt.Errorf("DKG submission contains a zero operator ID") + } + } + activeMembersHash, err := frostregistry.ActiveMembersHash(activeMembers) + if err != nil { + return fmt.Errorf("cannot hash active DKG members: [%w]", err) + } + if resultHash != mutation.DkgResultHash || + result.XOnlyOutputKey != mutation.WalletID || + result.MembersHash != mutation.RetainedGroupHash || + result.MembersHash != activeMembersHash || + !frostRetainedGroupEqualOperatorIDs( + []uint32(activeMembers), + mutation.OperatorIDs, + ) { + return fmt.Errorf("DKG submission does not encode the exported admission") + } + + approvalLog, err := source.authenticatedEventLog( + ctx, + mutation.DkgApprovalPoint, + evidence.deployments["frostWalletRegistry"], + evidence.registryABI.Events["DkgResultApproved"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(approvalLog.Topics) != 3 || len(approvalLog.Data) != 0 || + approvalLog.Topics[1] != common.Hash(mutation.DkgResultHash) || + approvalLog.Topics[2] == (common.Hash{}) || + !frostRetainedGroupCanonicalAddressTopic(approvalLog.Topics[2]) { + return fmt.Errorf("DKG approval log does not approve the exported result") + } + + creationLog, err := source.authenticatedEventLog( + ctx, + mutation.CreationPoint, + evidence.deployments["frostWalletRegistry"], + evidence.registryABI.Events["WalletCreated"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(creationLog.Topics) != 3 || len(creationLog.Data) != 0 || + creationLog.Topics[1] != common.Hash(mutation.WalletID) || + creationLog.Topics[2] != common.Hash(mutation.DkgResultHash) { + return fmt.Errorf("FROST wallet-creation log does not encode the exported admission") + } + + registrationLog, err := source.authenticatedEventLog( + ctx, + mutation.BridgeRegistrationPoint, + evidence.deployments["bridge"], + evidence.bridgeABI.Events["NewWalletRegisteredV2"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(registrationLog.Topics) != 4 || len(registrationLog.Data) != 0 || + registrationLog.Topics[1] != common.Hash(mutation.WalletID) || + registrationLog.Topics[2] != (common.Hash{}) || + registrationLog.Topics[3] != frostRetainedGroupBytes20Topic(mutation.WalletPublicKeyHash) { + return fmt.Errorf("Bridge registration log does not encode the exported FROST wallet") + } + return nil +} + +func frostRetainedGroupDecodeDkgSubmission( + log *types.Log, + evidence *frostRetainedGroupEvidenceProfile, +) (result frostabi.FrostDkgResult, resultHash [32]byte, err error) { + defer func() { + if recovered := recover(); recovered != nil { + result = frostabi.FrostDkgResult{} + resultHash = [32]byte{} + err = fmt.Errorf("cannot decode DKG submission result") + } + }() + if log == nil || evidence == nil || len(log.Topics) != 3 || len(log.Data) == 0 { + return result, resultHash, fmt.Errorf("DKG submission log is malformed") + } + resultHash = [32]byte(log.Topics[1]) + if resultHash == [32]byte{} || crypto.Keccak256Hash(log.Data) != common.Hash(resultHash) { + return result, resultHash, fmt.Errorf("DKG result hash does not commit to the submitted result") + } + values, unpackErr := evidence.registryABI.Events["DkgResultSubmitted"].Inputs.NonIndexed().Unpack(log.Data) + if unpackErr != nil || len(values) != 1 { + return result, resultHash, fmt.Errorf("cannot decode DKG submitted result: [%w]", unpackErr) + } + converted := ethabi.ConvertType(values[0], new(frostabi.FrostDkgResult)) + decoded, ok := converted.(*frostabi.FrostDkgResult) + if !ok || decoded == nil { + return result, resultHash, fmt.Errorf("cannot convert DKG submitted result") + } + return *decoded, resultHash, nil +} + +func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( + ctx context.Context, + point FrostRetainedGroupEventPoint, + deployment FrostPreSignDeploymentEvidence, + topic common.Hash, + receipts frostRetainedGroupReceiptCache, + code frostRetainedGroupCodeCache, +) (*types.Log, error) { + if !point.valid() || topic == (common.Hash{}) { + return nil, fmt.Errorf("event evidence descriptor is incomplete") + } + descriptor, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + point.BlockNumber, + point.BlockHash, + true, + ) + if err != nil { + return nil, err + } + if err := source.authenticateContractDeployment( + ctx, + descriptor, + point.BlockNumber, + point.BlockHash, + code, + ); err != nil { + return nil, err + } + transactionHash := common.Hash(point.TransactionHash) + receipt, ok := receipts[transactionHash] + if !ok { + if len(receipts) >= frostRetainedGroupMaximumEvidenceReceipts { + return nil, fmt.Errorf("retained-group evidence exceeds the receipt limit") + } + var err error + requestContext, cancel := source.requestContext(ctx) + receipt, err = source.verifier.TransactionReceipt(requestContext, transactionHash) + cancel() + if err != nil { + return nil, fmt.Errorf("cannot read transaction receipt [%s]: [%w]", transactionHash.Hex(), err) + } + if receipt == nil { + return nil, fmt.Errorf("transaction receipt [%s] is missing", transactionHash.Hex()) + } + receipts[transactionHash] = receipt + } + if len(receipt.Logs) > frostRetainedGroupMaximumReceiptLogs { + return nil, fmt.Errorf("transaction receipt exceeds the retained-group log limit") + } + if receipt.Status != types.ReceiptStatusSuccessful || receipt.BlockNumber == nil || + !receipt.BlockNumber.IsUint64() || receipt.BlockNumber.Uint64() != point.BlockNumber || + receipt.BlockHash != common.Hash(point.BlockHash) || receipt.TxHash != transactionHash || + receipt.TransactionIndex != uint(point.TransactionIndex) { + return nil, fmt.Errorf("transaction receipt does not match the exported event point") + } + var matched *types.Log + for _, candidate := range receipt.Logs { + if candidate == nil || candidate.Index != uint(point.LogIndex) { + continue + } + if matched != nil { + return nil, fmt.Errorf("transaction receipt contains duplicate global log index") + } + matched = candidate + } + if matched == nil || matched.Removed || + matched.Address != common.Address(descriptor.Address) || + matched.BlockNumber != point.BlockNumber || matched.BlockHash != common.Hash(point.BlockHash) || + matched.TxHash != transactionHash || matched.TxIndex != uint(point.TransactionIndex) || + len(matched.Topics) == 0 || matched.Topics[0] != topic { + return nil, fmt.Errorf("receipt log does not match the exact contract/event point") + } + return matched, nil +} + +func frostRetainedGroupDeploymentDescriptorAt( + deployment FrostPreSignDeploymentEvidence, + blockNumber uint64, + blockHash [32]byte, + rejectTransitionBlock bool, +) (FrostPreSignDeploymentDescriptorEvidence, error) { + if blockNumber < deployment.RelevantEventStartBlock || blockHash == [32]byte{} { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point is outside the authenticated deployment range") + } + matchIndex := -1 + for index, epoch := range deployment.HistoricalEpochs { + if blockNumber < epoch.Start.BlockNumber || + (epoch.End != nil && blockNumber > epoch.End.BlockNumber) { + continue + } + if matchIndex >= 0 { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point matches multiple deployment epochs") + } + matchIndex = index + } + if matchIndex < 0 { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point has no authenticated deployment epoch") + } + epoch := deployment.HistoricalEpochs[matchIndex] + if (blockNumber == epoch.Start.BlockNumber && + blockHash != epoch.Start.BlockHash) || + (epoch.End != nil && blockNumber == epoch.End.BlockNumber && + blockHash != epoch.End.BlockHash) { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point conflicts with a signed deployment boundary") + } + if rejectTransitionBlock && matchIndex > 0 && + blockNumber == epoch.Start.BlockNumber { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group event occurs in an implementation-transition block") + } + return epoch.Descriptor, nil +} + +func (source *signedFrostRetainedGroupHistorySource) authenticateContractDeployment( + ctx context.Context, + descriptor FrostPreSignDeploymentDescriptorEvidence, + blockNumber uint64, + blockHash [32]byte, + cache frostRetainedGroupCodeCache, +) error { + if blockNumber == 0 || blockHash == [32]byte{} { + return fmt.Errorf("retained-group contract deployment point is invalid") + } + verifiedKey := frostRetainedGroupCodeCacheKey{ + address: common.Address(descriptor.Address), + blockHash: common.Hash(blockHash), + codeHash: common.Hash(descriptor.RuntimeCodeHash), + descriptorHash: common.Hash(descriptor.DescriptorHash), + verified: true, + } + if _, ok := cache[verifiedKey]; ok { + return nil + } + proxyCode, err := source.readAuthenticatedCode( + ctx, + common.Address(descriptor.Address), + common.Hash(descriptor.RuntimeCodeHash), + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ) + if err != nil { + return err + } + implementationSlot := frostRetainedGroupEIP1967Slot( + "eip1967.proxy.implementation", + ) + adminSlot := frostRetainedGroupEIP1967Slot("eip1967.proxy.admin") + requestContext, cancel := source.requestContext(ctx) + implementationValue, err := source.verifier.StorageAtHash( + requestContext, + common.Address(descriptor.Address), + implementationSlot, + common.Hash(blockHash), + ) + cancel() + if err != nil || len(implementationValue) != 32 { + return fmt.Errorf("cannot read retained-group EIP-1967 implementation slot: [%w]", err) + } + requestContext, cancel = source.requestContext(ctx) + adminValue, err := source.verifier.StorageAtHash( + requestContext, + common.Address(descriptor.Address), + adminSlot, + common.Hash(blockHash), + ) + cancel() + if err != nil || len(adminValue) != 32 { + return fmt.Errorf("cannot read retained-group EIP-1967 admin slot: [%w]", err) + } + ownerCode := proxyCode + switch descriptor.Upgradeability { + case "immutable": + if !bytes.Equal(implementationValue, make([]byte, 32)) || + !bytes.Equal(adminValue, make([]byte, 32)) { + return fmt.Errorf("immutable retained-group deployment has populated EIP-1967 slots") + } + case "eip1967": + if !bytes.Equal(implementationValue, descriptor.ImplementationSlotValue[:]) || + !bytes.Equal(adminValue, descriptor.AdminSlotValue[:]) { + return fmt.Errorf("retained-group EIP-1967 slot value mismatch") + } + ownerCode, err = source.readAuthenticatedCode( + ctx, + common.Address(descriptor.ImplementationAddress), + common.Hash(descriptor.ImplementationCodeHash), + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ) + if err != nil { + return fmt.Errorf("retained-group implementation authentication failed: [%w]", err) + } + if _, err := source.readAuthenticatedCode( + ctx, + common.Address(descriptor.AdminAddress), + common.Hash(descriptor.AdminCodeHash), + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ); err != nil { + return fmt.Errorf("retained-group admin authentication failed: [%w]", err) + } + default: + return fmt.Errorf("retained-group deployment upgradeability is unsupported") + } + if err := source.authenticateLinkedLibraries( + ctx, + ownerCode, + descriptor.LinkedLibraries, + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ); err != nil { + return err + } + if len(cache) >= frostRetainedGroupMaximumEvidenceCodePoints { + return fmt.Errorf("retained-group evidence exceeds the contract-code point limit") + } + cache[verifiedKey] = []byte{1} + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) readAuthenticatedCode( + ctx context.Context, + address common.Address, + expectedHash common.Hash, + descriptorHash common.Hash, + blockNumber uint64, + blockHash common.Hash, + cache frostRetainedGroupCodeCache, +) ([]byte, error) { + key := frostRetainedGroupCodeCacheKey{ + address: address, + blockHash: blockHash, + codeHash: expectedHash, + descriptorHash: descriptorHash, + } + if cached, ok := cache[key]; ok { + return cached, nil + } + if len(cache) >= frostRetainedGroupMaximumEvidenceCodePoints { + return nil, fmt.Errorf("retained-group evidence exceeds the contract-code point limit") + } + requestContext, cancel := source.requestContext(ctx) + code, err := source.verifier.CodeAtHash( + requestContext, + address, + blockHash, + ) + cancel() + if err != nil { + return nil, fmt.Errorf("cannot read pinned contract code at block [%d]: [%w]", blockNumber, err) + } + if len(code) == 0 || len(code) > frostRetainedGroupMaximumContractCodeBytes || + crypto.Keccak256Hash(code) != expectedHash { + return nil, fmt.Errorf("contract code at block [%d] differs from the signed activation manifest", blockNumber) + } + copied := append([]byte{}, code...) + cache[key] = copied + return copied, nil +} + +func (source *signedFrostRetainedGroupHistorySource) authenticateLinkedLibraries( + ctx context.Context, + ownerCode []byte, + libraries []FrostPreSignLinkedLibraryEvidence, + descriptorHash common.Hash, + blockNumber uint64, + blockHash common.Hash, + cache frostRetainedGroupCodeCache, +) error { + for _, library := range libraries { + for _, reference := range library.References { + if reference.Start > uint64(len(ownerCode)) || + reference.Start+reference.Length < reference.Start || + reference.Start+reference.Length > uint64(len(ownerCode)) || + !bytes.Equal( + ownerCode[int(reference.Start):int(reference.Start+reference.Length)], + library.Address[:], + ) { + return fmt.Errorf("retained-group linked-library reference [%s:%d] mismatch", library.ProtocolRole, reference.Start) + } + } + libraryCode, err := source.readAuthenticatedCode( + ctx, + common.Address(library.Address), + common.Hash(library.RuntimeCodeHash), + descriptorHash, + blockNumber, + blockHash, + cache, + ) + if err != nil { + return fmt.Errorf("retained-group linked library [%s] authentication failed: [%w]", library.ProtocolRole, err) + } + if err := source.authenticateLinkedLibraries( + ctx, + libraryCode, + library.LinkedLibraries, + descriptorHash, + blockNumber, + blockHash, + cache, + ); err != nil { + return err + } + } + return nil +} + +func frostRetainedGroupEIP1967Slot(label string) common.Hash { + value := crypto.Keccak256Hash([]byte(label)).Big() + value.Sub(value, big.NewInt(1)) + return common.BigToHash(value) +} + +func (source *signedFrostRetainedGroupHistorySource) resolveOperatorIDAt( + ctx context.Context, + operator common.Address, + at FrostPreSignFinality, + evidence *frostRetainedGroupEvidenceProfile, +) (uint32, error) { + if evidence == nil || operator == (common.Address{}) { + return 0, fmt.Errorf("operator-resolution evidence is incomplete") + } + deployment := evidence.deployments["frostSortitionPool"] + descriptor, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + at.BlockNumber, + at.BlockHash, + false, + ) + if err != nil { + return 0, err + } + if err := source.authenticateContractDeployment( + ctx, + descriptor, + at.BlockNumber, + at.BlockHash, + make(frostRetainedGroupCodeCache), + ); err != nil { + return 0, err + } + // getOperatorID(address), pinned explicitly rather than learned from an + // exporter or a mutable ABI service. + callData := make([]byte, 4+32) + copy(callData[:4], []byte{0x5a, 0x48, 0xb4, 0x6b}) + copy(callData[4+12:], operator[:]) + to := common.Address(descriptor.Address) + requestContext, cancel := source.requestContext(ctx) + output, err := source.verifier.CallContractAtHash( + requestContext, + ethereum.CallMsg{To: &to, Data: callData}, + common.Hash(at.BlockHash), + ) + cancel() + if err != nil { + return 0, err + } + if len(output) != 32 || !bytes.Equal(output[:28], make([]byte, 28)) { + return 0, fmt.Errorf("sortition-pool getOperatorID returned noncanonical data") + } + operatorID := binary.BigEndian.Uint32(output[28:]) + if operatorID == 0 { + return 0, fmt.Errorf("operator is not registered in the pinned sortition pool at the requested block") + } + return operatorID, nil +} + +func frostRetainedGroupEqualOperatorIDs(left []uint32, right []uint32) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func frostRetainedGroupBytes20Topic(value [20]byte) common.Hash { + var result common.Hash + copy(result[:20], value[:]) + return result +} + +func frostRetainedGroupCanonicalAddressTopic(topic common.Hash) bool { + return bytes.Equal(topic[:12], make([]byte, 12)) +} diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go new file mode 100644 index 0000000000..b19e9490d3 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -0,0 +1,1896 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "mime" + "net" + "net/http" + "net/url" + "path" + "strings" + "sync" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/keep-network/keep-core/pkg/chain" +) + +const ( + frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v5" + frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v4" + frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v4" + frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v4" + frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v5\x00" + frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v4\x00" + frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v4\x00" + frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v4\x00" + frostRetainedGroupProtocolBindingDomain = "tbtc-frost-retained-group-protocol-binding-v4\x00" + + frostRetainedGroupMaximumResponseBytes = 1024 * 1024 + frostRetainedGroupMaximumAggregateResponseBytes = 16 * 1024 * 1024 + frostRetainedGroupMaximumPages = 256 + frostRetainedGroupMaximumMutations = 4096 + frostRetainedGroupMaximumWallets = 2048 + frostRetainedGroupMaximumUniqueBlocks = 8192 + frostRetainedGroupMaximumEvidenceReceipts = 8192 + frostRetainedGroupMaximumEvidenceCodePoints = 16384 + frostRetainedGroupMaximumReceiptLogs = 4096 + frostRetainedGroupMaximumContractCodeBytes = 64 * 1024 + frostRetainedGroupMaximumCursorBytes = 256 + frostRetainedGroupMaximumReasonBytes = 1024 + frostRetainedGroupDefaultTimeout = 20 * time.Second + frostRetainedGroupMaximumReconciliationDuration = 5 * time.Minute +) + +// FrostRetainedGroupHistorySourceConfig configures the independent, +// receipt-complete retained-group history service. ExportURL and EthereumURL +// must be distinct from each other and from the primary Ethereum endpoint. The +// history-envelope, backend, operator, and transport-attestation Ed25519 SPKI +// hashes are separate manifest roles and are pinned rather than learned from +// either service. +type FrostRetainedGroupHistorySourceConfig struct { + ExportURL string + EthereumURL string + TrustDomainID string + ExportTrustDomainID string + EthereumTrustDomainID string + ExportServiceIdentity string + EthereumServiceIdentity string + ExportBackendServiceFingerprint string + EthereumBackendServiceFingerprint string + ExportOperatorFingerprint string + EthereumOperatorFingerprint string + TrustedSignerKeyHash string + ExportAttestationKeyHash string + EthereumAttestationKeyHash string + ExportTLSLeafSPKIHash string + EthereumTLSLeafSPKIHash string + RequestTimeout time.Duration + TLSRootCAs *x509.CertPool `mapstructure:"-"` + PrimaryTLSRootCAs *x509.CertPool `mapstructure:"-"` + Resolver *net.Resolver `mapstructure:"-"` +} + +// FrostPreSignEthereumEvidenceVerifier is the read-only Ethereum view used to +// independently authenticate security-sensitive FROST authorization evidence. +// Exact-hash methods must use EIP-1898 with requireCanonical=true. +type FrostPreSignEthereumEvidenceVerifier interface { + ChainID(context.Context) (*big.Int, error) + HeaderByNumber(context.Context, *big.Int) (*types.Header, error) + HeaderByHash(context.Context, common.Hash) (*types.Header, error) + TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) + FilterLogs(context.Context, ethereum.FilterQuery) ([]types.Log, error) + CodeAtHash(context.Context, common.Address, common.Hash) ([]byte, error) + StorageAtHash(context.Context, common.Address, common.Hash, common.Hash) ([]byte, error) + CallContractAtHash(context.Context, ethereum.CallMsg, common.Hash) ([]byte, error) +} + +// FrostPreSignEthereumEvidenceVerifierSource exposes an independently +// identity-bound Ethereum verifier to the production authorization adapter. +type FrostPreSignEthereumEvidenceVerifierSource interface { + FrostPreSignEthereumEvidenceVerifier( + context.Context, + ) (FrostPreSignEthereumEvidenceVerifier, error) +} + +type frostRetainedGroupEthereumVerifier interface { + FrostPreSignEthereumEvidenceVerifier + Close() +} + +type frostRetainedGroupHTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +type canonicalFrostRetainedGroupEthereumVerifier struct { + *ethclient.Client + rpcClient *rpc.Client +} + +func (verifier *canonicalFrostRetainedGroupEthereumVerifier) CodeAtHash( + ctx context.Context, + account common.Address, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := verifier.rpcClient.CallContext( + ctx, + &result, + "eth_getCode", + account, + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func (verifier *canonicalFrostRetainedGroupEthereumVerifier) StorageAtHash( + ctx context.Context, + account common.Address, + key common.Hash, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := verifier.rpcClient.CallContext( + ctx, + &result, + "eth_getStorageAt", + account, + key, + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func (verifier *canonicalFrostRetainedGroupEthereumVerifier) CallContractAtHash( + ctx context.Context, + message ethereum.CallMsg, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := verifier.rpcClient.CallContext( + ctx, + &result, + "eth_call", + frostRetainedGroupCallArgument(message), + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func frostRetainedGroupCallArgument(message ethereum.CallMsg) map[string]interface{} { + result := map[string]interface{}{ + "from": message.From, + "to": message.To, + } + if len(message.Data) > 0 { + result["input"] = hexutil.Bytes(message.Data) + } + if message.Value != nil { + result["value"] = (*hexutil.Big)(message.Value) + } + if message.Gas != 0 { + result["gas"] = hexutil.Uint64(message.Gas) + } + if message.GasPrice != nil { + result["gasPrice"] = (*hexutil.Big)(message.GasPrice) + } + if message.GasFeeCap != nil { + result["maxFeePerGas"] = (*hexutil.Big)(message.GasFeeCap) + } + if message.GasTipCap != nil { + result["maxPriorityFeePerGas"] = (*hexutil.Big)(message.GasTipCap) + } + if message.AccessList != nil { + result["accessList"] = message.AccessList + } + if message.BlobGasFeeCap != nil { + result["maxFeePerBlobGas"] = (*hexutil.Big)(message.BlobGasFeeCap) + } + if message.BlobHashes != nil { + result["blobVersionedHashes"] = message.BlobHashes + } + return result +} + +// signedFrostRetainedGroupHistorySource consumes independently signed, +// paginated history receipts and checks their block commitments against a +// separately configured finalized Ethereum endpoint. It intentionally does +// not use eth_getLogs: generic RPC providers cannot prove a capped response is +// complete. +type signedFrostRetainedGroupHistorySource struct { + exportEndpoint *url.URL + verifier frostRetainedGroupEthereumVerifier + httpClient frostRetainedGroupHTTPClient + httpTransports []*http.Transport + independenceMonitor *frostRetainedGroupIndependenceMonitor + chainID uint64 + identity FrostRetainedGroupHistoryIdentity + trustedSignerKeyHash [32]byte + evidenceMutex sync.RWMutex + evidence *frostRetainedGroupEvidenceProfile + maximumPages uint64 + maximumMutations uint64 + maximumResponseBytes uint64 + maximumUniqueBlocks uint64 + maximumReadDuration time.Duration + requestTimeout time.Duration +} + +var _ FrostRetainedGroupHistorySource = (*signedFrostRetainedGroupHistorySource)(nil) +var _ FrostPreSignEthereumEvidenceVerifierSource = (*signedFrostRetainedGroupHistorySource)(nil) + +type frostRetainedGroupSignedEnvelope struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + Payload json.RawMessage `json:"payload"` + PayloadSHA256 string `json:"payloadSha256"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + Signature string `json:"signature"` +} + +type frostRetainedGroupWireFinality struct { + RelayTransactionHash string `json:"relayTransactionHash"` + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` + TransactionIndex uint32 `json:"transactionIndex"` + LogIndex uint32 `json:"logIndex"` + AuthorizationSequence string `json:"authorizationSequence"` +} + +type frostRetainedGroupWireBlockPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` +} + +type frostRetainedGroupProtocolBinding struct { + Schema string `json:"schema"` + ChainID uint64 `json:"chainID"` + DomainChainID string `json:"domainChainID"` + GenesisBlockHash string `json:"genesisBlockHash"` + Checkpoint frostRetainedGroupWireBlockPoint `json:"checkpoint"` + ManifestHash string `json:"manifestHash"` + ProfileHash string `json:"profileHash"` + ImplementationSetHash string `json:"implementationSetHash"` + DescriptorSetHash string `json:"descriptorSetHash"` + LinkedLibraryDescriptorSetHash string `json:"linkedLibraryDescriptorSetHash"` + EndpointIdentitySetHash string `json:"endpointIdentitySetHash"` + SignerProtocolID string `json:"signerProtocolID"` + ReservationProtocolID string `json:"reservationProtocolID"` + EvidenceProtocolID string `json:"evidenceProtocolID"` + BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` + InventoryProtocolID string `json:"inventoryProtocolID"` + QuarantineProtocolID string `json:"quarantineProtocolID"` + LiftProtocolID string `json:"liftProtocolID"` + TombstoneProtocolID string `json:"tombstoneProtocolID"` + LiftAuthoritySetHash string `json:"liftAuthoritySetHash"` + CheckpointAuthoritySetHash string `json:"checkpointAuthoritySetHash"` + CheckpointMinimumSequence uint64 `json:"checkpointMinimumSequence"` + CheckpointPredecessorHash string `json:"checkpointPredecessorHash"` + SigningPolicyHash string `json:"signingPolicyHash"` + CanonicalStoreID string `json:"canonicalStoreID"` + CanonicalStoreFingerprint string `json:"canonicalStoreFingerprint"` + CanonicalClusterFingerprint string `json:"canonicalClusterFingerprint"` + QuarantineStoreID string `json:"quarantineStoreID"` + QuarantineStoreFingerprint string `json:"quarantineStoreFingerprint"` + QuarantineClusterFingerprint string `json:"quarantineClusterFingerprint"` + SourceIdentity frostRetainedGroupWireIdentity `json:"sourceIdentity"` +} + +type frostRetainedGroupWireEventPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` + TransactionHash string `json:"transactionHash"` + TransactionIndex uint32 `json:"transactionIndex"` + LogIndex uint32 `json:"logIndex"` +} + +type frostRetainedGroupWireQuarantineRaisedRecord struct { + QuarantineID string `json:"quarantineID"` + WalletID string `json:"walletID"` + EvidenceHash string `json:"evidenceHash"` + Reason string `json:"reason"` + RecoveryRequired bool `json:"recoveryRequired"` + RaisedAt frostRetainedGroupWireEventPoint `json:"raisedAt"` +} + +type frostRetainedGroupWireQuarantineLiftBody struct { + Schema string `json:"schema"` + ProtocolBindingHash string `json:"protocolBindingHash"` + ManifestHash string `json:"manifestHash"` + ProfileHash string `json:"profileHash"` + ImplementationSetHash string `json:"implementationSetHash"` + ChainID uint64 `json:"chainID"` + DomainChainID string `json:"domainChainID"` + GenesisBlockHash string `json:"genesisBlockHash"` + QuarantineProtocolID string `json:"quarantineProtocolID"` + LiftProtocolID string `json:"liftProtocolID"` + TombstoneProtocolID string `json:"tombstoneProtocolID"` + AuthoritySetHash string `json:"authoritySetHash"` + QuarantineID string `json:"quarantineID"` + WalletID string `json:"walletID"` + OriginalRaisedRecord frostRetainedGroupWireQuarantineRaisedRecord `json:"originalRaisedRecord"` + PriorGeneration uint64 `json:"priorGeneration"` + PriorEventRoot string `json:"priorEventRoot"` + PriorActiveRoot string `json:"priorActiveRoot"` + PriorTombstoneRoot string `json:"priorTombstoneRoot"` + LiftPoint frostRetainedGroupWireEventPoint `json:"liftPoint"` + ResolutionEvidenceHash string `json:"resolutionEvidenceHash"` + ResolutionFinality frostRetainedGroupWireFinality `json:"resolutionFinality"` + NotBeforeBlock uint64 `json:"notBeforeBlock"` + ExpiresAtBlock uint64 `json:"expiresAtBlock"` +} + +type frostRetainedGroupWireQuarantineLiftSignature struct { + AuthorityID string `json:"authorityID"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostRetainedGroupWireQuarantineLiftCertificate struct { + Schema string `json:"schema"` + Body frostRetainedGroupWireQuarantineLiftBody `json:"body"` + BodyHash string `json:"bodyHash"` + Signatures []frostRetainedGroupWireQuarantineLiftSignature `json:"signatures"` +} + +type frostRetainedGroupWireMutation struct { + Point frostRetainedGroupWireEventPoint `json:"point"` + Kind string `json:"kind"` + WalletID string `json:"walletID"` + WalletPublicKeyHash string `json:"walletPublicKeyHash"` + OperatorIDs []uint32 `json:"operatorIDs"` + RetainedGroupHash string `json:"retainedGroupHash"` + DkgResultHash string `json:"dkgResultHash"` + DkgSubmissionPoint frostRetainedGroupWireEventPoint `json:"dkgSubmissionPoint"` + DkgApprovalPoint frostRetainedGroupWireEventPoint `json:"dkgApprovalPoint"` + CreationPoint frostRetainedGroupWireEventPoint `json:"creationPoint"` + BridgeRegistrationPoint frostRetainedGroupWireEventPoint `json:"bridgeRegistrationPoint"` + QuarantineID string `json:"quarantineID"` + EvidenceHash string `json:"evidenceHash"` + LiftCertificateHash string `json:"liftCertificateHash"` + LiftCertificate *frostRetainedGroupWireQuarantineLiftCertificate `json:"liftCertificate"` + Reason string `json:"reason"` +} + +type frostRetainedGroupHistoryQuery struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` +} + +type frostRetainedGroupHistoryPageRequest struct { + BindingHash string `json:"bindingHash"` + Query frostRetainedGroupHistoryQuery `json:"query"` + CheckpointAfter frostRetainedGroupWireCheckpointCursor `json:"checkpointAfter"` + Cursor string `json:"cursor"` +} + +type frostRetainedGroupWireCheckpointCursor struct { + Sequence uint64 `json:"sequence"` + CertificateHash string `json:"certificateHash"` +} + +type frostRetainedGroupHistoryReceipt struct { + PageCount uint64 `json:"pageCount"` + MutationCount uint64 `json:"mutationCount"` + BindingHash string `json:"bindingHash"` + HistoryRoot string `json:"historyRoot"` + CheckpointAfter frostRetainedGroupWireCheckpointCursor `json:"checkpointAfter"` + CheckpointCertificates []frostRetainedGroupWireCheckpointCertificate `json:"checkpointCertificates"` + CheckpointChainRoot string `json:"checkpointChainRoot"` + CheckpointTipHash string `json:"checkpointTipHash"` + CheckpointComplete bool `json:"checkpointComplete"` +} + +type frostRetainedGroupHistoryPagePayload struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + Identity frostRetainedGroupWireIdentity `json:"identity"` + ChainID uint64 `json:"chainID"` + QueryHash string `json:"queryHash"` + SnapshotID string `json:"snapshotID"` + PageIndex uint64 `json:"pageIndex"` + Cursor string `json:"cursor"` + PreviousPageHash string `json:"previousPageHash"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` + EmptyAtFrom bool `json:"emptyAtFrom"` + DescriptorSetHash string `json:"descriptorSetHash"` + CheckpointAfter frostRetainedGroupWireCheckpointCursor `json:"checkpointAfter"` + Mutations []frostRetainedGroupWireMutation `json:"mutations"` + NextCursor string `json:"nextCursor"` + Complete bool `json:"complete"` + Receipt *frostRetainedGroupHistoryReceipt `json:"receipt"` +} + +type frostRetainedGroupOperatorQuery struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + OperatorAddress string `json:"operatorAddress"` + At frostRetainedGroupWireFinality `json:"at"` +} + +type frostRetainedGroupOperatorReceiptPayload struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + Identity frostRetainedGroupWireIdentity `json:"identity"` + ChainID uint64 `json:"chainID"` + QueryHash string `json:"queryHash"` + OperatorAddress string `json:"operatorAddress"` + At frostRetainedGroupWireFinality `json:"at"` + OperatorID uint32 `json:"operatorID"` + Found bool `json:"found"` +} + +// FrostRetainedGroupHistoryEndpointFingerprint returns the complete identity +// committed by the activation manifest. The caller must supply the explicit +// endpoint descriptors; URL strings alone are not an authenticated endpoint +// identity. +func FrostRetainedGroupHistoryEndpointFingerprint( + identity FrostRetainedGroupHistoryIdentity, +) ([32]byte, error) { + if err := validateFrostRetainedGroupHistoryIdentity(identity); err != nil { + return [32]byte{}, err + } + return computeFrostRetainedGroupSourceEndpointFingerprint(identity), nil +} + +// NewFrostRetainedGroupHistorySource creates the production source. It fails +// closed if the verifier is not a distinct endpoint, does not expose finalized +// blocks, or serves a different chain. +func NewFrostRetainedGroupHistorySource( + ctx context.Context, + config FrostRetainedGroupHistorySourceConfig, + primaryTransport *FrostPrimaryEthereumTransport, + expectedChainID uint64, +) (*signedFrostRetainedGroupHistorySource, error) { + if ctx == nil { + return nil, fmt.Errorf("retained-group history context is nil") + } + validated, err := validateAndResolveFrostRetainedGroupSourceConfig( + ctx, + config, + primaryTransport, + ) + if err != nil { + return nil, err + } + if expectedChainID == 0 { + return nil, fmt.Errorf("retained-group history expected chain ID is zero") + } + separationPolicy, err := primaryTransport.bindRetainedEndpoints( + validated.exportEndpoint, + validated.identity.Export, + validated.verifierEndpoint, + validated.identity.Verifier, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot bind primary Ethereum transport to retained endpoints: [%w]", + err, + ) + } + rpcHTTPClient, rpcTransport, err := + newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + validated.verifierEndpoint, + validated.identity.Verifier, + validated.rootCAs, + validated.requestTimeout, + separationPolicy, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot configure independent retained-group Ethereum verifier transport: [%w]", + err, + ) + } + rpcClient, err := rpc.DialOptions( + ctx, + validated.verifierEndpoint.canonical, + rpc.WithHTTPClient(rpcHTTPClient), + ) + if err != nil { + rpcTransport.CloseIdleConnections() + return nil, fmt.Errorf("cannot connect independent retained-group Ethereum verifier: [%w]", err) + } + verifier := &canonicalFrostRetainedGroupEthereumVerifier{ + Client: ethclient.NewClient(rpcClient), + rpcClient: rpcClient, + } + exportHTTPClient, exportTransport, err := + newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + validated.exportEndpoint, + validated.identity.Export, + validated.rootCAs, + validated.requestTimeout, + separationPolicy, + ) + if err != nil { + verifier.Close() + rpcTransport.CloseIdleConnections() + return nil, fmt.Errorf( + "cannot configure retained-group export transport: [%w]", + err, + ) + } + source, err := newSignedFrostRetainedGroupHistorySource( + ctx, + validated.exportEndpoint.endpoint, + verifier, + exportHTTPClient, + expectedChainID, + validated.identity, + validated.identity.HistorySignerKeyHash, + validated.requestTimeout, + &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: validated.exportEndpoint, + verifierEndpoint: validated.verifierEndpoint, + primaryTransport: primaryTransport, + }, + ) + if err != nil { + verifier.Close() + rpcTransport.CloseIdleConnections() + exportTransport.CloseIdleConnections() + return nil, err + } + source.httpTransports = []*http.Transport{exportTransport, rpcTransport} + return source, nil +} + +func newSignedFrostRetainedGroupHistorySource( + ctx context.Context, + exportEndpoint *url.URL, + verifier frostRetainedGroupEthereumVerifier, + httpClient frostRetainedGroupHTTPClient, + chainID uint64, + identity FrostRetainedGroupHistoryIdentity, + signerHash [32]byte, + requestTimeout time.Duration, + independenceMonitor *frostRetainedGroupIndependenceMonitor, +) (*signedFrostRetainedGroupHistorySource, error) { + if exportEndpoint == nil || verifier == nil || httpClient == nil || chainID == 0 || + validateFrostRetainedGroupHistoryIdentity(identity) != nil || + signerHash == [32]byte{} || + identity.HistorySignerKeyHash != signerHash || + independenceMonitor == nil || + requestTimeout < time.Second || requestTimeout > time.Minute { + return nil, fmt.Errorf("retained-group history source configuration is incomplete") + } + if err := independenceMonitor.verify(ctx); err != nil { + return nil, err + } + requestContext, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + actualChainID, err := verifier.ChainID(requestContext) + if err != nil { + return nil, fmt.Errorf("cannot identify independent retained-group Ethereum verifier: [%w]", err) + } + if actualChainID == nil || !actualChainID.IsUint64() || actualChainID.Uint64() != chainID { + return nil, fmt.Errorf("independent retained-group Ethereum verifier chain ID mismatch") + } + source := &signedFrostRetainedGroupHistorySource{ + exportEndpoint: exportEndpoint, + verifier: verifier, + httpClient: httpClient, + independenceMonitor: independenceMonitor, + chainID: chainID, + identity: identity, + trustedSignerKeyHash: signerHash, + maximumPages: frostRetainedGroupMaximumPages, + maximumMutations: frostRetainedGroupMaximumMutations, + maximumResponseBytes: frostRetainedGroupMaximumAggregateResponseBytes, + maximumUniqueBlocks: frostRetainedGroupMaximumUniqueBlocks, + maximumReadDuration: frostRetainedGroupMaximumReconciliationDuration, + requestTimeout: requestTimeout, + } + if _, err := source.FinalizedHead(ctx); err != nil { + return nil, fmt.Errorf("independent retained-group Ethereum verifier has no usable finalized head: [%w]", err) + } + return source, nil +} + +func (source *signedFrostRetainedGroupHistorySource) Close() { + if source == nil { + return + } + if source.verifier != nil { + source.verifier.Close() + } + for _, transport := range source.httpTransports { + if transport != nil { + transport.CloseIdleConnections() + } + } +} + +func (source *signedFrostRetainedGroupHistorySource) FrostPreSignEthereumEvidenceVerifier( + ctx context.Context, +) (FrostPreSignEthereumEvidenceVerifier, error) { + if source == nil || ctx == nil || source.verifier == nil || + source.independenceMonitor == nil { + return nil, fmt.Errorf( + "independent FROST Ethereum verifier is unavailable", + ) + } + if err := source.independenceMonitor.verify(ctx); err != nil { + return nil, fmt.Errorf( + "independent FROST Ethereum verifier lost endpoint separation: [%w]", + err, + ) + } + return source.verifier, nil +} + +func (source *signedFrostRetainedGroupHistorySource) requestContext( + ctx context.Context, +) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, source.requestTimeout) +} + +func (source *signedFrostRetainedGroupHistorySource) Identity( + ctx context.Context, +) (FrostRetainedGroupHistoryIdentity, error) { + if ctx == nil { + return FrostRetainedGroupHistoryIdentity{}, fmt.Errorf("retained-group identity context is nil") + } + select { + case <-ctx.Done(): + return FrostRetainedGroupHistoryIdentity{}, ctx.Err() + default: + return source.identity, nil + } +} + +func (source *signedFrostRetainedGroupHistorySource) FinalizedHead( + ctx context.Context, +) (FrostPreSignFinality, error) { + if ctx == nil { + return FrostPreSignFinality{}, fmt.Errorf("retained-group finalized-head context is nil") + } + if err := source.independenceMonitor.verify(ctx); err != nil { + return FrostPreSignFinality{}, err + } + requestContext, cancel := source.requestContext(ctx) + defer cancel() + header, err := source.verifier.HeaderByNumber( + requestContext, + big.NewInt(int64(rpc.FinalizedBlockNumber)), + ) + if err != nil { + return FrostPreSignFinality{}, err + } + if header == nil || header.Number == nil || !header.Number.IsUint64() || + header.Number.Sign() <= 0 || header.Hash() == (common.Hash{}) { + return FrostPreSignFinality{}, fmt.Errorf("retained-group finalized header is invalid") + } + return FrostPreSignFinality{ + BlockNumber: header.Number.Uint64(), + BlockHash: header.Hash(), + }, nil +} + +func (source *signedFrostRetainedGroupHistorySource) VerifyPoint( + ctx context.Context, + point FrostPreSignFinality, +) error { + head, err := source.FinalizedHead(ctx) + if err != nil { + return err + } + return source.verifyPointAtFinalizedHead(ctx, point, head) +} + +func (source *signedFrostRetainedGroupHistorySource) verifyPointAtFinalizedHead( + ctx context.Context, + point FrostPreSignFinality, + head FrostPreSignFinality, +) error { + if point.BlockNumber == 0 || point.BlockHash == [32]byte{} || + point.BlockNumber > head.BlockNumber { + return fmt.Errorf("retained-group point is invalid or not finalized") + } + requestContext, cancel := source.requestContext(ctx) + defer cancel() + header, err := source.verifier.HeaderByHash( + requestContext, + common.Hash(point.BlockHash), + ) + if err != nil { + return err + } + if header == nil || header.Number == nil || !header.Number.IsUint64() || + header.Number.Uint64() != point.BlockNumber || header.Hash() != common.Hash(point.BlockHash) { + return fmt.Errorf("retained-group point does not match the independent canonical chain") + } + canonicalContext, canonicalCancel := source.requestContext(ctx) + defer canonicalCancel() + canonicalHeader, err := source.verifier.HeaderByNumber( + canonicalContext, + new(big.Int).SetUint64(point.BlockNumber), + ) + if err != nil { + return err + } + if canonicalHeader == nil || canonicalHeader.Number == nil || + !canonicalHeader.Number.IsUint64() || + canonicalHeader.Number.Uint64() != point.BlockNumber || + canonicalHeader.Hash() != common.Hash(point.BlockHash) { + return fmt.Errorf("retained-group point is not canonical by height") + } + if point.BlockNumber == head.BlockNumber && point.BlockHash != head.BlockHash { + return fmt.Errorf("retained-group point conflicts with the finalized head") + } + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( + ctx context.Context, + from FrostPreSignFinality, + to FrostPreSignFinality, + checkpointAfter FrostRetainedGroupCheckpointCursor, +) (*FrostRetainedGroupHistory, error) { + if ctx == nil { + return nil, fmt.Errorf("retained-group history context is nil") + } + if from.BlockNumber == 0 || from.BlockHash == [32]byte{} || + to.BlockNumber < from.BlockNumber || to.BlockHash == [32]byte{} { + return nil, fmt.Errorf("retained-group history bounds are invalid") + } + if source.maximumPages == 0 || source.maximumMutations == 0 || + source.maximumResponseBytes == 0 || source.maximumUniqueBlocks == 0 || + source.maximumReadDuration <= 0 { + return nil, fmt.Errorf("retained-group history resource limits are invalid") + } + readContext, cancel := context.WithTimeout(ctx, source.maximumReadDuration) + defer cancel() + ctx = readContext + evidence, err := source.activationEvidence() + if err != nil { + return nil, err + } + if checkpointAfter.Sequence == evidence.checkpointPolicy.MinimumSequence-1 { + if checkpointAfter.CertificateHash != + evidence.checkpointPolicy.PredecessorHash { + return nil, fmt.Errorf( + "retained-group checkpoint cursor differs from the manifest floor", + ) + } + } else if checkpointAfter.Sequence < + evidence.checkpointPolicy.MinimumSequence || + checkpointAfter.CertificateHash == [32]byte{} { + return nil, fmt.Errorf( + "retained-group checkpoint cursor is below the manifest floor", + ) + } + headBefore, err := source.FinalizedHead(ctx) + if err != nil { + return nil, err + } + if err := source.verifyPointAtFinalizedHead(ctx, from, headBefore); err != nil { + return nil, fmt.Errorf("retained-group checkpoint is not canonical: [%w]", err) + } + if err := source.verifyPointAtFinalizedHead(ctx, to, headBefore); err != nil { + return nil, fmt.Errorf("retained-group target is not canonical: [%w]", err) + } + + query := frostRetainedGroupHistoryQuery{ + Schema: frostRetainedGroupHistoryRequestSchema, + BindingHash: frostActivationHex32(evidence.bindingHash), + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(to), + } + queryHash, err := frostRetainedGroupDomainHash( + frostRetainedGroupHistoryQueryDomain, + query, + ) + if err != nil { + return nil, err + } + + mutations := make([]FrostRetainedGroupMutation, 0) + mutationHashes := make([][32]byte, 0) + seenCursors := make(map[string]bool) + blockHashes := map[uint64][32]byte{ + from.BlockNumber: from.BlockHash, + to.BlockNumber: to.BlockHash, + } + cursor := "" + var snapshotID [32]byte + var descriptorSetHash [32]byte + var previousPageHash [32]byte + var aggregateResponseBytes uint64 + for pageIndex := uint64(0); pageIndex < source.maximumPages; pageIndex++ { + if seenCursors[cursor] { + return nil, fmt.Errorf("retained-group history cursor repeated") + } + seenCursors[cursor] = true + request := frostRetainedGroupHistoryPageRequest{ + BindingHash: frostActivationHex32(evidence.bindingHash), + Query: query, + CheckpointAfter: frostRetainedGroupWireCheckpointCursor{ + Sequence: checkpointAfter.Sequence, + CertificateHash: frostActivationHex32( + checkpointAfter.CertificateHash, + ), + }, + Cursor: cursor, + } + payload := &frostRetainedGroupHistoryPagePayload{} + responseBytes, err := source.postSigned(ctx, "history", request, payload) + if err != nil { + return nil, fmt.Errorf("cannot read retained-group history page [%d]: [%w]", pageIndex, err) + } + if responseBytes > source.maximumResponseBytes-aggregateResponseBytes { + return nil, fmt.Errorf("retained-group history exceeds the aggregate response-byte limit") + } + aggregateResponseBytes += responseBytes + pageHash, err := source.validateHistoryPage( + payload, + queryHash, + from, + to, + cursor, + pageIndex, + previousPageHash, + snapshotID, + descriptorSetHash, + checkpointAfter, + ) + if err != nil { + return nil, err + } + parsedSnapshotID, _ := parseFrostActivationHex32(payload.SnapshotID) + parsedDescriptorSetHash, _ := parseFrostActivationHex32(payload.DescriptorSetHash) + if pageIndex == 0 { + snapshotID = parsedSnapshotID + descriptorSetHash = parsedDescriptorSetHash + } + for _, wireMutation := range payload.Mutations { + canonicalMutation, err := canonicalFrostActivationValue(wireMutation) + if err != nil { + return nil, fmt.Errorf("cannot hash retained-group history mutation: [%w]", err) + } + mutation, err := frostRetainedGroupMutationFromWire(wireMutation) + if err != nil { + return nil, fmt.Errorf("retained-group history contains malformed mutation: [%w]", err) + } + if err := addFrostRetainedGroupMutationBlockHashes(blockHashes, mutation); err != nil { + return nil, err + } + if uint64(len(blockHashes)) > source.maximumUniqueBlocks { + return nil, fmt.Errorf("retained-group history exceeds the unique-block limit") + } + mutations = append(mutations, mutation) + mutationHashes = append( + mutationHashes, + sha256.Sum256(canonicalMutation), + ) + if uint64(len(mutations)) > source.maximumMutations { + return nil, fmt.Errorf("retained-group history exceeds the mutation limit") + } + } + if payload.Complete { + if payload.Receipt == nil || payload.NextCursor != "" || + payload.Receipt.PageCount != pageIndex+1 || + payload.Receipt.MutationCount != uint64(len(mutations)) || + payload.Receipt.BindingHash != + frostActivationHex32(evidence.bindingHash) || + payload.Receipt.CheckpointAfter != + request.CheckpointAfter { + return nil, fmt.Errorf("retained-group final history receipt is inconsistent") + } + receiptRoot, err := parseFrostActivationHex32(payload.Receipt.HistoryRoot) + if err != nil { + return nil, fmt.Errorf("retained-group history receipt root is invalid") + } + computedRoot := frostRetainedGroupHistoryRootFromHashes( + evidence.bindingHash, + queryHash, + mutationHashes, + ) + if receiptRoot != computedRoot { + return nil, fmt.Errorf("retained-group history receipt does not cover the exact mutation sequence") + } + if len(payload.Receipt.CheckpointCertificates) > + frostRetainedGroupMaximumCheckpointsPerPage { + return nil, fmt.Errorf( + "retained-group checkpoint page exceeds its bound", + ) + } + checkpoints := make( + []FrostRetainedGroupCheckpointCertificate, + len(payload.Receipt.CheckpointCertificates), + ) + for index, wireCertificate := range payload.Receipt.CheckpointCertificates { + checkpoint, err := + frostRetainedGroupCheckpointCertificateFromWire( + wireCertificate, + ) + if err != nil { + return nil, fmt.Errorf( + "retained-group checkpoint certificate [%d] is malformed: [%w]", + index, + err, + ) + } + checkpoints[index] = checkpoint + } + checkpointHashes, err := + validateFrostRetainedGroupCheckpointSuffix( + evidence.checkpointPolicy, + checkpointAfter, + checkpoints, + ) + if err != nil { + return nil, err + } + checkpointChainRoot, err := parseFrostActivationHex32( + payload.Receipt.CheckpointChainRoot, + ) + if err != nil || + checkpointChainRoot != + frostRetainedGroupCheckpointChainRoot( + evidence.bindingHash, + checkpointAfter, + checkpointHashes, + ) { + return nil, fmt.Errorf( + "retained-group final receipt checkpoint-chain root mismatch", + ) + } + checkpointTipHash, err := parseFrostActivationHex32( + payload.Receipt.CheckpointTipHash, + ) + if err != nil || + (len(checkpointHashes) == 0 && + checkpointTipHash != checkpointAfter.CertificateHash) || + (len(checkpointHashes) > 0 && + checkpointTipHash != + checkpointHashes[len(checkpointHashes)-1]) { + return nil, fmt.Errorf( + "retained-group final receipt checkpoint-tip mismatch", + ) + } + history := &FrostRetainedGroupHistory{ + From: from, + To: to, + Mutations: mutations, + HistoryRoot: receiptRoot, + CheckpointAfter: checkpointAfter, + Checkpoints: checkpoints, + CheckpointChainRoot: checkpointChainRoot, + CheckpointTipHash: checkpointTipHash, + CheckpointComplete: payload.Receipt.CheckpointComplete, + Complete: true, + EmptyAtFrom: true, + DescriptorSetHash: descriptorSetHash, + } + if err := validateCompleteFrostRetainedGroupHistory( + history, + evidence.liftPolicy, + ); err != nil { + return nil, err + } + if !history.CheckpointComplete && len(checkpoints) == 0 { + return nil, fmt.Errorf( + "retained-group nonfinal checkpoint page made no progress", + ) + } + if len(checkpoints) > 0 { + if err := validateFrostRetainedGroupCheckpointSemantics( + evidence.checkpointPolicy, + history, + checkpointHashes, + ); err != nil { + return nil, err + } + } + for _, checkpoint := range checkpoints { + blockNumber := checkpoint.Body.Point.BlockNumber + blockHash := checkpoint.Body.Point.BlockHash + if existing, ok := blockHashes[blockNumber]; ok && + existing != blockHash { + return nil, fmt.Errorf( + "retained-group checkpoint chain contains conflicting block hashes", + ) + } + blockHashes[blockNumber] = blockHash + if uint64(len(blockHashes)) > source.maximumUniqueBlocks { + return nil, fmt.Errorf( + "retained-group history exceeds the unique-block limit", + ) + } + } + for blockNumber, blockHash := range blockHashes { + if err := source.verifyPointAtFinalizedHead(ctx, FrostPreSignFinality{ + BlockNumber: blockNumber, + BlockHash: blockHash, + }, headBefore); err != nil { + return nil, fmt.Errorf("retained-group history references a noncanonical block: [%w]", err) + } + } + if err := source.verifyHistoryEvidence(ctx, mutations, evidence); err != nil { + return nil, fmt.Errorf("retained-group history has unauthenticated semantic evidence: [%w]", err) + } + headAfter, err := source.FinalizedHead(ctx) + if err != nil { + return nil, err + } + if headAfter.BlockNumber < headBefore.BlockNumber || + (headAfter.BlockNumber == headBefore.BlockNumber && headAfter.BlockHash != headBefore.BlockHash) { + return nil, fmt.Errorf("retained-group finalized head changed inconsistently during export") + } + if err := source.verifyPointAtFinalizedHead(ctx, to, headAfter); err != nil { + return nil, fmt.Errorf("retained-group target changed during export: [%w]", err) + } + return history, nil + } + if payload.Receipt != nil || len(payload.Mutations) == 0 || + !validFrostRetainedGroupCursor(payload.NextCursor) || payload.NextCursor == cursor { + return nil, fmt.Errorf("retained-group nonfinal history page is malformed") + } + cursor = payload.NextCursor + previousPageHash = pageHash + } + return nil, fmt.Errorf("retained-group history exceeded the page limit without a final receipt") +} + +func (source *signedFrostRetainedGroupHistorySource) validateHistoryPage( + payload *frostRetainedGroupHistoryPagePayload, + queryHash [32]byte, + from FrostPreSignFinality, + to FrostPreSignFinality, + cursor string, + pageIndex uint64, + previousPageHash [32]byte, + snapshotID [32]byte, + descriptorSetHash [32]byte, + checkpointAfter FrostRetainedGroupCheckpointCursor, +) ([32]byte, error) { + evidence, evidenceErr := source.activationEvidence() + if evidenceErr != nil { + return [32]byte{}, evidenceErr + } + if payload == nil || payload.Schema != frostRetainedGroupHistoryPageSchema || + payload.BindingHash != frostActivationHex32(evidence.bindingHash) || + payload.ChainID != source.chainID || payload.PageIndex != pageIndex || + payload.Cursor != cursor || !payload.EmptyAtFrom || + !source.validWireIdentity(payload.Identity) { + return [32]byte{}, fmt.Errorf("retained-group history page has the wrong identity or position") + } + if payload.CheckpointAfter.Sequence != checkpointAfter.Sequence || + payload.CheckpointAfter.CertificateHash != + frostActivationHex32(checkpointAfter.CertificateHash) { + return [32]byte{}, fmt.Errorf( + "retained-group history page checkpoint cursor mismatch", + ) + } + declaredQueryHash, err := parseFrostActivationHex32(payload.QueryHash) + if err != nil || declaredQueryHash != queryHash { + return [32]byte{}, fmt.Errorf("retained-group history page is bound to a different query") + } + pageSnapshotID, err := parseFrostActivationHex32(payload.SnapshotID) + if err != nil || pageSnapshotID == [32]byte{} || (pageIndex > 0 && pageSnapshotID != snapshotID) { + return [32]byte{}, fmt.Errorf("retained-group history snapshot changed between pages") + } + pageDescriptorSetHash, err := parseFrostActivationHex32(payload.DescriptorSetHash) + if err != nil || pageDescriptorSetHash != evidence.descriptorSetHash || + (pageIndex > 0 && pageDescriptorSetHash != descriptorSetHash) { + return [32]byte{}, fmt.Errorf("retained-group descriptor set differs from the signed activation manifest") + } + pageFrom, err := frostRetainedGroupFinalityFromWire(payload.From) + if err != nil || pageFrom != from { + return [32]byte{}, fmt.Errorf("retained-group history page checkpoint mismatch") + } + pageTo, err := frostRetainedGroupFinalityFromWire(payload.To) + if err != nil || pageTo != to { + return [32]byte{}, fmt.Errorf("retained-group history page target mismatch") + } + declaredPreviousPageHash, err := parseFrostActivationHex32(payload.PreviousPageHash) + if err != nil || declaredPreviousPageHash != previousPageHash { + return [32]byte{}, fmt.Errorf("retained-group history page hash chain is broken") + } + if uint64(len(payload.Mutations)) > source.maximumMutations { + return [32]byte{}, fmt.Errorf("retained-group history page exceeds the mutation limit") + } + canonical, err := canonicalFrostActivationValue(payload) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func (source *signedFrostRetainedGroupHistorySource) ResolveOperatorID( + ctx context.Context, + operator chain.Address, + at FrostPreSignFinality, +) (chain.OperatorID, error) { + if ctx == nil { + return 0, fmt.Errorf("retained-group operator-resolution context is nil") + } + resolveContext, cancel := context.WithTimeout(ctx, source.maximumReadDuration) + defer cancel() + ctx = resolveContext + evidence, err := source.activationEvidence() + if err != nil { + return 0, err + } + canonicalAddress, err := canonicalFrostRetainedGroupOperatorAddress(operator) + if err != nil { + return 0, err + } + headBefore, err := source.FinalizedHead(ctx) + if err != nil { + return 0, err + } + if err := source.verifyPointAtFinalizedHead(ctx, at, headBefore); err != nil { + return 0, err + } + query := frostRetainedGroupOperatorQuery{ + Schema: frostRetainedGroupOperatorRequestSchema, + BindingHash: frostActivationHex32(evidence.bindingHash), + OperatorAddress: canonicalAddress, + At: frostRetainedGroupFinalityToWire(at), + } + queryHash, err := frostRetainedGroupDomainHash(frostRetainedGroupOperatorQueryDomain, query) + if err != nil { + return 0, err + } + payload := &frostRetainedGroupOperatorReceiptPayload{} + if _, err := source.postSigned(ctx, "operator-id", query, payload); err != nil { + return 0, fmt.Errorf("cannot resolve retained-group operator ID: [%w]", err) + } + declaredQueryHash, err := parseFrostActivationHex32(payload.QueryHash) + receiptAt, pointErr := frostRetainedGroupFinalityFromWire(payload.At) + if payload.Schema != frostRetainedGroupOperatorReceiptSchema || + payload.BindingHash != frostActivationHex32(evidence.bindingHash) || + payload.ChainID != source.chainID || !source.validWireIdentity(payload.Identity) || + err != nil || declaredQueryHash != queryHash || pointErr != nil || receiptAt != at || + payload.OperatorAddress != canonicalAddress || !payload.Found || payload.OperatorID == 0 { + return 0, fmt.Errorf("retained-group operator receipt is incomplete or differently bound") + } + onChainOperatorID, err := source.resolveOperatorIDAt( + ctx, + common.HexToAddress(canonicalAddress), + at, + evidence, + ) + if err != nil { + return 0, fmt.Errorf("cannot independently authenticate retained-group operator ID: [%w]", err) + } + if onChainOperatorID != payload.OperatorID { + return 0, fmt.Errorf("retained-group operator receipt disagrees with exact finalized sortition-pool state") + } + headAfter, err := source.FinalizedHead(ctx) + if err != nil { + return 0, err + } + if headAfter.BlockNumber < headBefore.BlockNumber || + (headAfter.BlockNumber == headBefore.BlockNumber && headAfter.BlockHash != headBefore.BlockHash) { + return 0, fmt.Errorf("retained-group finalized head changed during operator resolution") + } + if err := source.verifyPointAtFinalizedHead(ctx, at, headAfter); err != nil { + return 0, fmt.Errorf("retained-group operator-resolution point changed: [%w]", err) + } + return chain.OperatorID(payload.OperatorID), nil +} + +func (source *signedFrostRetainedGroupHistorySource) postSigned( + ctx context.Context, + operation string, + requestPayload interface{}, + responsePayload interface{}, +) (uint64, error) { + if err := source.independenceMonitor.verify(ctx); err != nil { + return 0, err + } + requestContext, cancel := source.requestContext(ctx) + defer cancel() + requestBody, err := json.Marshal(requestPayload) + if err != nil { + return 0, err + } + endpoint := *source.exportEndpoint + endpoint.Path = path.Join(endpoint.Path, operation) + request, err := http.NewRequestWithContext( + requestContext, + http.MethodPost, + endpoint.String(), + bytes.NewReader(requestBody), + ) + if err != nil { + return 0, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + response, err := source.httpClient.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if err := requireFrostRetainedGroupTransportProof( + response, + source.identity.Export.Role, + ); err != nil { + return 0, err + } + if response.StatusCode != http.StatusOK { + return 0, fmt.Errorf("retained-group export returned HTTP status [%d]", response.StatusCode) + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + return 0, fmt.Errorf("retained-group export response is not application/json") + } + data, err := io.ReadAll(io.LimitReader(response.Body, frostRetainedGroupMaximumResponseBytes+1)) + if err != nil { + return 0, err + } + if len(data) == 0 || len(data) > frostRetainedGroupMaximumResponseBytes { + return 0, fmt.Errorf("retained-group export response size is invalid") + } + envelope := &frostRetainedGroupSignedEnvelope{} + if err := decodeStrictFrostActivationJSON(data, envelope); err != nil { + return 0, fmt.Errorf("cannot decode retained-group signed envelope: [%w]", err) + } + if err := source.verifySignedEnvelope(envelope, responsePayload); err != nil { + return 0, err + } + return uint64(len(data)), nil +} + +func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( + envelope *frostRetainedGroupSignedEnvelope, + target interface{}, +) error { + evidence, evidenceErr := source.activationEvidence() + if envelope == nil || evidenceErr != nil || + envelope.Schema != "tbtc-frost-retained-group-signed-envelope/v3" || + envelope.BindingHash != frostActivationHex32(evidence.bindingHash) || + envelope.SignatureAlgorithm != "ed25519" || len(envelope.Payload) == 0 { + return fmt.Errorf("retained-group signed envelope is malformed") + } + canonical, err := canonicalFrostActivationValue(envelope.Payload) + if err != nil { + return err + } + payloadHash := sha256.Sum256(canonical) + declaredHash, err := parseFrostActivationHex32(envelope.PayloadSHA256) + if err != nil || declaredHash != payloadHash { + return fmt.Errorf("retained-group signed envelope payload hash mismatch") + } + publicKeyDER, err := decodeCanonicalFrostRetainedGroupBase64( + envelope.SignerPublicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 || + sha256.Sum256(publicKeyDER) != source.trustedSignerKeyHash { + return fmt.Errorf("retained-group export signer is not trusted") + } + parsedKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return fmt.Errorf("cannot parse retained-group export signer: [%w]", err) + } + publicKey, ok := parsedKey.(ed25519.PublicKey) + if !ok { + return fmt.Errorf("retained-group export signer is not Ed25519") + } + signature, err := decodeCanonicalFrostRetainedGroupBase64( + envelope.Signature, + ) + signed := append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...) + if err != nil || len(signature) != ed25519.SignatureSize || + !ed25519.Verify(publicKey, signed, signature) { + return fmt.Errorf("retained-group export signature is invalid") + } + if err := decodeStrictFrostActivationJSON(canonical, target); err != nil { + return fmt.Errorf("cannot decode retained-group signed payload: [%w]", err) + } + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) validWireIdentity( + identity frostRetainedGroupWireIdentity, +) bool { + parsed, err := frostRetainedGroupIdentityFromWire(identity) + return err == nil && parsed == source.identity +} + +func frostRetainedGroupDomainHash(domain string, value interface{}) ([32]byte, error) { + canonical, err := canonicalFrostActivationValue(value) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(domain)) + hasher.Write(canonical) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func frostRetainedGroupHistoryRoot( + bindingHash [32]byte, + queryHash [32]byte, + mutations []frostRetainedGroupWireMutation, +) ([32]byte, error) { + hashes := make([][32]byte, 0, len(mutations)) + for _, mutation := range mutations { + canonical, err := canonicalFrostActivationValue(mutation) + if err != nil { + return [32]byte{}, err + } + hashes = append(hashes, sha256.Sum256(canonical)) + } + return frostRetainedGroupHistoryRootFromHashes( + bindingHash, + queryHash, + hashes, + ), nil +} + +func frostRetainedGroupHistoryRootFromHashes( + bindingHash [32]byte, + queryHash [32]byte, + mutationHashes [][32]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupHistoryRootDomain)) + hasher.Write(bindingHash[:]) + hasher.Write(queryHash[:]) + count := make([]byte, 8) + for i := uint(0); i < 8; i++ { + count[7-i] = byte(uint64(len(mutationHashes)) >> (i * 8)) + } + hasher.Write(count) + for _, mutationHash := range mutationHashes { + hasher.Write(mutationHash[:]) + } + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostRetainedGroupFinalityToWire( + point FrostPreSignFinality, +) frostRetainedGroupWireFinality { + return frostRetainedGroupWireFinality{ + RelayTransactionHash: frostActivationHex32(point.RelayTransactionHash), + BlockNumber: point.BlockNumber, + BlockHash: frostActivationHex32(point.BlockHash), + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + AuthorizationSequence: frostActivationHex32(point.AuthorizationSequence), + } +} + +func frostRetainedGroupFinalityFromWire( + point frostRetainedGroupWireFinality, +) (FrostPreSignFinality, error) { + relayHash, err := parseFrostActivationHex32(point.RelayTransactionHash) + if err != nil { + return FrostPreSignFinality{}, err + } + blockHash, err := parseFrostActivationHex32(point.BlockHash) + if err != nil || point.BlockNumber == 0 || blockHash == [32]byte{} { + return FrostPreSignFinality{}, fmt.Errorf("retained-group finality block is invalid") + } + sequence, err := parseFrostActivationHex32(point.AuthorizationSequence) + if err != nil { + return FrostPreSignFinality{}, err + } + return FrostPreSignFinality{ + RelayTransactionHash: relayHash, + BlockNumber: point.BlockNumber, + BlockHash: blockHash, + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + AuthorizationSequence: sequence, + }, nil +} + +func frostRetainedGroupEventPointToWire( + point FrostRetainedGroupEventPoint, +) frostRetainedGroupWireEventPoint { + return frostRetainedGroupWireEventPoint{ + BlockNumber: point.BlockNumber, + BlockHash: frostActivationHex32(point.BlockHash), + TransactionHash: frostActivationHex32(point.TransactionHash), + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + } +} + +func frostRetainedGroupEventPointFromWire( + point frostRetainedGroupWireEventPoint, +) (FrostRetainedGroupEventPoint, error) { + blockHash, err := parseFrostActivationHex32(point.BlockHash) + if err != nil { + return FrostRetainedGroupEventPoint{}, err + } + transactionHash, err := parseFrostActivationHex32(point.TransactionHash) + if err != nil { + return FrostRetainedGroupEventPoint{}, err + } + result := FrostRetainedGroupEventPoint{ + BlockNumber: point.BlockNumber, + BlockHash: blockHash, + TransactionHash: transactionHash, + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + } + if result.BlockNumber == 0 { + if result != (FrostRetainedGroupEventPoint{}) { + return FrostRetainedGroupEventPoint{}, fmt.Errorf("zero retained-group event point is noncanonical") + } + return result, nil + } + if !result.valid() { + return FrostRetainedGroupEventPoint{}, fmt.Errorf("retained-group event point is invalid") + } + return result, nil +} + +func frostRetainedGroupMutationFromWire( + mutation frostRetainedGroupWireMutation, +) (FrostRetainedGroupMutation, error) { + point, err := frostRetainedGroupEventPointFromWire(mutation.Point) + if err != nil || !point.valid() { + return FrostRetainedGroupMutation{}, fmt.Errorf("mutation point is invalid") + } + walletID, err := parseFrostActivationHex32(mutation.WalletID) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + walletPublicKeyHash, err := parseFrostRetainedGroupHex20(mutation.WalletPublicKeyHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + retainedGroupHash, err := parseFrostActivationHex32(mutation.RetainedGroupHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + dkgResultHash, err := parseFrostActivationHex32(mutation.DkgResultHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + dkgSubmissionPoint, err := frostRetainedGroupEventPointFromWire(mutation.DkgSubmissionPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + dkgApprovalPoint, err := frostRetainedGroupEventPointFromWire(mutation.DkgApprovalPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + creationPoint, err := frostRetainedGroupEventPointFromWire(mutation.CreationPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + registrationPoint, err := frostRetainedGroupEventPointFromWire(mutation.BridgeRegistrationPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + quarantineID, err := parseFrostActivationHex32(mutation.QuarantineID) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + evidenceHash, err := parseFrostActivationHex32(mutation.EvidenceHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + liftCertificateHash, err := parseFrostActivationHex32( + mutation.LiftCertificateHash, + ) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + liftCertificate, err := frostRetainedGroupLiftCertificateFromWire( + mutation.LiftCertificate, + ) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + if len(mutation.OperatorIDs) > 100 || len(mutation.Reason) > frostRetainedGroupMaximumReasonBytes { + return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation exceeds field bounds") + } + operatorIDs := append([]uint32{}, mutation.OperatorIDs...) + for _, operatorID := range operatorIDs { + if operatorID == 0 { + return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation has a zero operator ID") + } + } + return FrostRetainedGroupMutation{ + Point: point, + Kind: FrostRetainedGroupMutationKind(mutation.Kind), + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + OperatorIDs: operatorIDs, + RetainedGroupHash: retainedGroupHash, + DkgResultHash: dkgResultHash, + DkgSubmissionPoint: dkgSubmissionPoint, + DkgApprovalPoint: dkgApprovalPoint, + CreationPoint: creationPoint, + BridgeRegistrationPoint: registrationPoint, + QuarantineID: quarantineID, + EvidenceHash: evidenceHash, + LiftCertificateHash: liftCertificateHash, + LiftCertificate: liftCertificate, + Reason: mutation.Reason, + }, nil +} + +func frostRetainedGroupMutationToWire( + mutation FrostRetainedGroupMutation, +) frostRetainedGroupWireMutation { + return frostRetainedGroupWireMutation{ + Point: frostRetainedGroupEventPointToWire(mutation.Point), + Kind: string(mutation.Kind), + WalletID: frostActivationHex32(mutation.WalletID), + WalletPublicKeyHash: frostActivationHex20(mutation.WalletPublicKeyHash), + OperatorIDs: append([]uint32{}, mutation.OperatorIDs...), + RetainedGroupHash: frostActivationHex32(mutation.RetainedGroupHash), + DkgResultHash: frostActivationHex32(mutation.DkgResultHash), + DkgSubmissionPoint: frostRetainedGroupEventPointToWire(mutation.DkgSubmissionPoint), + DkgApprovalPoint: frostRetainedGroupEventPointToWire(mutation.DkgApprovalPoint), + CreationPoint: frostRetainedGroupEventPointToWire(mutation.CreationPoint), + BridgeRegistrationPoint: frostRetainedGroupEventPointToWire(mutation.BridgeRegistrationPoint), + QuarantineID: frostActivationHex32(mutation.QuarantineID), + EvidenceHash: frostActivationHex32(mutation.EvidenceHash), + LiftCertificateHash: frostActivationHex32(mutation.LiftCertificateHash), + LiftCertificate: frostRetainedGroupLiftCertificateToWire(mutation.LiftCertificate), + Reason: mutation.Reason, + } +} + +func frostRetainedGroupLiftCertificateToWire( + certificate *FrostRetainedGroupQuarantineLiftCertificate, +) *frostRetainedGroupWireQuarantineLiftCertificate { + if certificate == nil { + return nil + } + body := certificate.Body + signatures := make( + []frostRetainedGroupWireQuarantineLiftSignature, + len(certificate.Signatures), + ) + for index, signature := range certificate.Signatures { + signatures[index] = frostRetainedGroupWireQuarantineLiftSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return &frostRetainedGroupWireQuarantineLiftCertificate{ + Schema: certificate.Schema, + Body: frostRetainedGroupWireQuarantineLiftBody{ + Schema: body.Schema, + ProtocolBindingHash: frostActivationHex32(body.ProtocolBindingHash), + ManifestHash: frostActivationHex32(body.ManifestHash), + ProfileHash: frostActivationHex32(body.ProfileHash), + ImplementationSetHash: frostActivationHex32(body.ImplementationSetHash), + ChainID: body.ChainID, + DomainChainID: frostActivationHex32(body.DomainChainID), + GenesisBlockHash: frostActivationHex32(body.GenesisBlockHash), + QuarantineProtocolID: frostActivationHex32(body.QuarantineProtocolID), + LiftProtocolID: frostActivationHex32(body.LiftProtocolID), + TombstoneProtocolID: frostActivationHex32(body.TombstoneProtocolID), + AuthoritySetHash: frostActivationHex32(body.AuthoritySetHash), + QuarantineID: frostActivationHex32(body.QuarantineID), + WalletID: frostActivationHex32(body.WalletID), + OriginalRaisedRecord: frostRetainedGroupWireQuarantineRaisedRecord{ + QuarantineID: frostActivationHex32( + body.OriginalRaisedRecord.QuarantineID, + ), + WalletID: frostActivationHex32( + body.OriginalRaisedRecord.WalletID, + ), + EvidenceHash: frostActivationHex32( + body.OriginalRaisedRecord.EvidenceHash, + ), + Reason: body.OriginalRaisedRecord.Reason, + RecoveryRequired: body.OriginalRaisedRecord.RecoveryRequired, + RaisedAt: frostRetainedGroupEventPointToWire( + body.OriginalRaisedRecord.RaisedAt, + ), + }, + PriorGeneration: body.PriorGeneration, + PriorEventRoot: frostActivationHex32(body.PriorEventRoot), + PriorActiveRoot: frostActivationHex32(body.PriorActiveRoot), + PriorTombstoneRoot: frostActivationHex32(body.PriorTombstoneRoot), + LiftPoint: frostRetainedGroupEventPointToWire(body.LiftPoint), + ResolutionEvidenceHash: frostActivationHex32(body.ResolutionEvidenceHash), + ResolutionFinality: frostRetainedGroupFinalityToWire( + body.ResolutionFinality, + ), + NotBeforeBlock: body.NotBeforeBlock, + ExpiresAtBlock: body.ExpiresAtBlock, + }, + BodyHash: frostActivationHex32(certificate.BodyHash), + Signatures: signatures, + } +} + +func frostRetainedGroupLiftCertificateFromWire( + certificate *frostRetainedGroupWireQuarantineLiftCertificate, +) (*FrostRetainedGroupQuarantineLiftCertificate, error) { + if certificate == nil { + return nil, nil + } + body := certificate.Body + parse := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf( + "invalid FROST quarantine lift %s: [%w]", + name, + err, + ) + } + return parsed, nil + } + protocolBindingHash, err := parse( + "protocol binding hash", + body.ProtocolBindingHash, + ) + if err != nil { + return nil, err + } + manifestHash, err := parse("manifest hash", body.ManifestHash) + if err != nil { + return nil, err + } + profileHash, err := parse("profile hash", body.ProfileHash) + if err != nil { + return nil, err + } + implementationSetHash, err := parse( + "implementation set hash", + body.ImplementationSetHash, + ) + if err != nil { + return nil, err + } + domainChainID, err := parse("domain chain ID", body.DomainChainID) + if err != nil { + return nil, err + } + genesisBlockHash, err := parse("genesis block hash", body.GenesisBlockHash) + if err != nil { + return nil, err + } + quarantineProtocolID, err := parse( + "quarantine protocol ID", + body.QuarantineProtocolID, + ) + if err != nil { + return nil, err + } + liftProtocolID, err := parse("lift protocol ID", body.LiftProtocolID) + if err != nil { + return nil, err + } + tombstoneProtocolID, err := parse( + "tombstone protocol ID", + body.TombstoneProtocolID, + ) + if err != nil { + return nil, err + } + authoritySetHash, err := parse("authority set hash", body.AuthoritySetHash) + if err != nil { + return nil, err + } + quarantineID, err := parse("quarantine ID", body.QuarantineID) + if err != nil { + return nil, err + } + walletID, err := parse("wallet ID", body.WalletID) + if err != nil { + return nil, err + } + raisedQuarantineID, err := parse( + "raised quarantine ID", + body.OriginalRaisedRecord.QuarantineID, + ) + if err != nil { + return nil, err + } + raisedWalletID, err := parse( + "raised wallet ID", + body.OriginalRaisedRecord.WalletID, + ) + if err != nil { + return nil, err + } + raisedEvidenceHash, err := parse( + "raised evidence hash", + body.OriginalRaisedRecord.EvidenceHash, + ) + if err != nil { + return nil, err + } + raisedAt, err := frostRetainedGroupEventPointFromWire( + body.OriginalRaisedRecord.RaisedAt, + ) + if err != nil { + return nil, fmt.Errorf("invalid FROST quarantine raised point: [%w]", err) + } + priorEventRoot, err := parse("prior event root", body.PriorEventRoot) + if err != nil { + return nil, err + } + priorActiveRoot, err := parse("prior active root", body.PriorActiveRoot) + if err != nil { + return nil, err + } + priorTombstoneRoot, err := parse( + "prior tombstone root", + body.PriorTombstoneRoot, + ) + if err != nil { + return nil, err + } + liftPoint, err := frostRetainedGroupEventPointFromWire(body.LiftPoint) + if err != nil { + return nil, fmt.Errorf("invalid FROST quarantine lift point: [%w]", err) + } + resolutionEvidenceHash, err := parse( + "resolution evidence hash", + body.ResolutionEvidenceHash, + ) + if err != nil { + return nil, err + } + resolutionFinality, err := frostRetainedGroupFinalityFromWire( + body.ResolutionFinality, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid FROST quarantine resolution finality: [%w]", + err, + ) + } + bodyHash, err := parse("body hash", certificate.BodyHash) + if err != nil { + return nil, err + } + signatures := make( + []FrostRetainedGroupQuarantineLiftSignature, + len(certificate.Signatures), + ) + for index, signature := range certificate.Signatures { + signatures[index] = FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return &FrostRetainedGroupQuarantineLiftCertificate{ + Schema: certificate.Schema, + Body: FrostRetainedGroupQuarantineLiftBody{ + Schema: body.Schema, + ProtocolBindingHash: protocolBindingHash, + ManifestHash: manifestHash, + ProfileHash: profileHash, + ImplementationSetHash: implementationSetHash, + ChainID: body.ChainID, + DomainChainID: domainChainID, + GenesisBlockHash: genesisBlockHash, + QuarantineProtocolID: quarantineProtocolID, + LiftProtocolID: liftProtocolID, + TombstoneProtocolID: tombstoneProtocolID, + AuthoritySetHash: authoritySetHash, + QuarantineID: quarantineID, + WalletID: walletID, + OriginalRaisedRecord: FrostRetainedGroupQuarantineRaisedRecord{ + QuarantineID: raisedQuarantineID, + WalletID: raisedWalletID, + EvidenceHash: raisedEvidenceHash, + Reason: body.OriginalRaisedRecord.Reason, + RecoveryRequired: body.OriginalRaisedRecord.RecoveryRequired, + RaisedAt: raisedAt, + }, + PriorGeneration: body.PriorGeneration, + PriorEventRoot: priorEventRoot, + PriorActiveRoot: priorActiveRoot, + PriorTombstoneRoot: priorTombstoneRoot, + LiftPoint: liftPoint, + ResolutionEvidenceHash: resolutionEvidenceHash, + ResolutionFinality: resolutionFinality, + NotBeforeBlock: body.NotBeforeBlock, + ExpiresAtBlock: body.ExpiresAtBlock, + }, + BodyHash: bodyHash, + Signatures: signatures, + }, nil +} + +func parseFrostRetainedGroupHex20(value string) ([20]byte, error) { + if len(value) != 42 || !strings.HasPrefix(value, "0x") || value != strings.ToLower(value) { + return [20]byte{}, fmt.Errorf("value is not canonical bytes20") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != 20 { + return [20]byte{}, fmt.Errorf("value is not bytes20") + } + var result [20]byte + copy(result[:], decoded) + return result, nil +} + +func addFrostRetainedGroupMutationBlockHashes( + blocks map[uint64][32]byte, + mutation FrostRetainedGroupMutation, +) error { + points := []FrostRetainedGroupEventPoint{ + mutation.Point, + mutation.DkgSubmissionPoint, + mutation.DkgApprovalPoint, + mutation.CreationPoint, + mutation.BridgeRegistrationPoint, + } + for _, point := range points { + if point.BlockNumber == 0 { + continue + } + if existing, ok := blocks[point.BlockNumber]; ok && existing != point.BlockHash { + return fmt.Errorf("retained-group history contains conflicting hashes for block [%d]", point.BlockNumber) + } + blocks[point.BlockNumber] = point.BlockHash + } + if mutation.LiftCertificate != nil { + finality := mutation.LiftCertificate.Body.ResolutionFinality + if finality.BlockNumber == 0 || finality.BlockHash == [32]byte{} { + return fmt.Errorf( + "retained-group quarantine lift resolution finality is invalid", + ) + } + if existing, ok := blocks[finality.BlockNumber]; ok && + existing != finality.BlockHash { + return fmt.Errorf( + "retained-group history contains conflicting hashes for block [%d]", + finality.BlockNumber, + ) + } + blocks[finality.BlockNumber] = finality.BlockHash + } + return nil +} + +func canonicalFrostRetainedGroupOperatorAddress(address chain.Address) (string, error) { + raw := strings.TrimSpace(address.String()) + if !common.IsHexAddress(raw) { + return "", fmt.Errorf("retained-group operator address is invalid") + } + return strings.ToLower(common.HexToAddress(raw).Hex()), nil +} + +func validFrostRetainedGroupCursor(cursor string) bool { + if cursor == "" || len(cursor) > frostRetainedGroupMaximumCursorBytes { + return false + } + for _, character := range cursor { + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '-' || character == '_') { + return false + } + } + return true +} diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go new file mode 100644 index 0000000000..4616b8f4d8 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -0,0 +1,2726 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "strings" + "sync" + "testing" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/keep-network/keep-core/pkg/chain" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + frostregistry "github.com/keep-network/keep-core/pkg/frost/registry" +) + +type frostRetainedGroupHistoryTestVerifier struct { + mutex sync.Mutex + chainID *big.Int + finalized *types.Header + headers map[uint64]*types.Header + reads map[uint64]int + reorgBlock uint64 + reorgAfterRead int + reorgHeader *types.Header + receipts map[common.Hash]*types.Receipt + code map[common.Address][]byte + storage map[common.Address]map[common.Hash][]byte + sortitionPool common.Address + operator common.Address + operatorAt uint64 + operatorID uint32 + closed bool +} + +type frostRetainedGroupCanonicalRPCTestAPI struct { + points []rpc.BlockNumberOrHash +} + +func (api *frostRetainedGroupCanonicalRPCTestAPI) GetCode( + _ context.Context, + _ common.Address, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return hexutil.Bytes{0x01}, nil +} + +func (api *frostRetainedGroupCanonicalRPCTestAPI) GetStorageAt( + _ context.Context, + _ common.Address, + _ common.Hash, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return make(hexutil.Bytes, 32), nil +} + +func (api *frostRetainedGroupCanonicalRPCTestAPI) Call( + _ context.Context, + _ map[string]interface{}, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return make(hexutil.Bytes, 32), nil +} + +func TestCanonicalFrostRetainedGroupEthereumVerifier_RequiresCanonicalHashState( + t *testing.T, +) { + server := rpc.NewServer() + api := &frostRetainedGroupCanonicalRPCTestAPI{} + if err := server.RegisterName("eth", api); err != nil { + t.Fatal(err) + } + client := rpc.DialInProc(server) + defer client.Close() + verifier := &canonicalFrostRetainedGroupEthereumVerifier{ + rpcClient: client, + } + blockHash := common.HexToHash("0x1234") + address := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + if _, err := verifier.CodeAtHash( + context.Background(), + address, + blockHash, + ); err != nil { + t.Fatal(err) + } + if _, err := verifier.StorageAtHash( + context.Background(), + address, + common.Hash{}, + blockHash, + ); err != nil { + t.Fatal(err) + } + if _, err := verifier.CallContractAtHash( + context.Background(), + ethereum.CallMsg{To: &address, Data: []byte{0x01}}, + blockHash, + ); err != nil { + t.Fatal(err) + } + if len(api.points) != 3 { + t.Fatalf("unexpected exact-hash call count [%d]", len(api.points)) + } + for _, point := range api.points { + if point.BlockHash == nil || *point.BlockHash != blockHash || + !point.RequireCanonical || point.BlockNumber != nil { + t.Fatalf("state read did not require canonical hash [%+v]", point) + } + } +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) ChainID( + context.Context, +) (*big.Int, error) { + return new(big.Int).Set(verifier.chainID), nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) HeaderByNumber( + _ context.Context, + number *big.Int, +) (*types.Header, error) { + verifier.mutex.Lock() + defer verifier.mutex.Unlock() + if number.Sign() < 0 { + return verifier.finalized, nil + } + blockNumber := number.Uint64() + verifier.reads[blockNumber]++ + if blockNumber == verifier.reorgBlock && verifier.reorgHeader != nil && + verifier.reads[blockNumber] > verifier.reorgAfterRead { + return verifier.reorgHeader, nil + } + return verifier.headers[blockNumber], nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) HeaderByHash( + _ context.Context, + hash common.Hash, +) (*types.Header, error) { + verifier.mutex.Lock() + defer verifier.mutex.Unlock() + for blockNumber, header := range verifier.headers { + if header.Hash() != hash { + continue + } + verifier.reads[blockNumber]++ + if blockNumber == verifier.reorgBlock && verifier.reorgHeader != nil && + verifier.reads[blockNumber] > verifier.reorgAfterRead { + if verifier.reorgHeader.Hash() == hash { + return verifier.reorgHeader, nil + } + return nil, nil + } + return header, nil + } + return nil, nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) Close() { + verifier.mutex.Lock() + defer verifier.mutex.Unlock() + verifier.closed = true +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) TransactionReceipt( + _ context.Context, + hash common.Hash, +) (*types.Receipt, error) { + receipt := verifier.receipts[hash] + if receipt == nil { + return nil, fmt.Errorf("missing receipt") + } + return receipt, nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) FilterLogs( + context.Context, + ethereum.FilterQuery, +) ([]types.Log, error) { + return nil, nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) CodeAtHash( + _ context.Context, + address common.Address, + hash common.Hash, +) ([]byte, error) { + if _, err := verifier.HeaderByHash(context.Background(), hash); err != nil { + return nil, err + } + code := verifier.code[address] + if len(code) == 0 { + return nil, fmt.Errorf("missing code") + } + return append([]byte{}, code...), nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) StorageAtHash( + _ context.Context, + address common.Address, + slot common.Hash, + blockHash common.Hash, +) ([]byte, error) { + if _, err := verifier.HeaderByHash(context.Background(), blockHash); err != nil { + return nil, err + } + if slots := verifier.storage[address]; slots != nil { + if value := slots[slot]; value != nil { + return append([]byte{}, value...), nil + } + } + return make([]byte, 32), nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) CallContractAtHash( + _ context.Context, + call ethereum.CallMsg, + blockHash common.Hash, +) ([]byte, error) { + if call.To == nil || *call.To != verifier.sortitionPool || + blockHash != verifier.headers[verifier.operatorAt].Hash() || len(call.Data) != 36 || + !bytes.Equal(call.Data[:4], []byte{0x5a, 0x48, 0xb4, 0x6b}) || + !bytes.Equal(call.Data[4:16], make([]byte, 12)) || + !bytes.Equal(call.Data[16:], verifier.operator[:]) { + return nil, fmt.Errorf("unexpected operator call") + } + result := make([]byte, 32) + binary.BigEndian.PutUint32(result[28:], verifier.operatorID) + return result, nil +} + +type frostRetainedGroupHistoryTestExport struct { + t *testing.T + privateKey ed25519.PrivateKey + publicKeyDER []byte + transportPrivateKey ed25519.PrivateKey + transportPublicKeyDER []byte + backendPrivateKey ed25519.PrivateKey + backendPublicKeyDER []byte + operatorPrivateKey ed25519.PrivateKey + operatorPublicKeyDER []byte + historyResponder func(frostRetainedGroupHistoryPageRequest) interface{} + operatorResponder func(frostRetainedGroupOperatorQuery) interface{} + corruptSignature bool + bindingHash [32]byte + identity FrostRetainedGroupEndpointIdentity + now func() time.Time + transportAttestationMutator func(*frostRetainedGroupTransportAttestation) + omitTransportAttestation bool + duplicateTransportAttestation bool + replayTransportAttestation bool + transportAttestationMutex sync.Mutex + savedTransportAttestation string +} + +func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + if request.Method != http.MethodPost { + http.Error(responseWriter, "method", http.StatusMethodNotAllowed) + return + } + requestBody, err := io.ReadAll(request.Body) + if err != nil { + http.Error(responseWriter, "request", http.StatusBadRequest) + return + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + var payload interface{} + switch request.URL.Path { + case "/history": + historyRequest := frostRetainedGroupHistoryPageRequest{} + if err := json.NewDecoder(bytes.NewReader(requestBody)).Decode(&historyRequest); err != nil { + http.Error(responseWriter, "request", http.StatusBadRequest) + return + } + payload = export.historyResponder(historyRequest) + case "/operator-id": + operatorRequest := frostRetainedGroupOperatorQuery{} + if err := json.NewDecoder(bytes.NewReader(requestBody)).Decode(&operatorRequest); err != nil { + http.Error(responseWriter, "request", http.StatusBadRequest) + return + } + payload = export.operatorResponder(operatorRequest) + default: + http.NotFound(responseWriter, request) + return + } + if payload == nil { + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + export.writeAttestedResponse( + responseWriter, + request, + http.StatusServiceUnavailable, + "text/plain", + []byte("missing\n"), + ) + return + } + canonical, err := canonicalFrostActivationValue(payload) + if err != nil { + export.t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signed := append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...) + signature := ed25519.Sign(export.privateKey, signed) + if export.corruptSignature { + signature[0] ^= 0xff + } + envelope := frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v3", + BindingHash: frostActivationHex32(export.bindingHash), + Payload: json.RawMessage(canonical), + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + responseBody, err := json.Marshal(envelope) + if err != nil { + export.t.Fatal(err) + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + export.writeAttestedResponse( + responseWriter, + request, + http.StatusOK, + "application/json", + responseBody, + ) +} + +func (export *frostRetainedGroupHistoryTestExport) writeAttestedResponse( + responseWriter http.ResponseWriter, + request *http.Request, + status int, + contentType string, + responseBody []byte, +) { + export.t.Helper() + localAddress, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok { + export.t.Fatal("missing test server local address") + } + localIP, err := frostRetainedGroupRemoteIP(localAddress) + if err != nil { + export.t.Fatal(err) + } + now := time.Now() + if export.now != nil { + now = export.now() + } + attestation, err := marshalFrostRetainedGroupTransportAttestation( + request, + status, + responseBody, + export.identity, + export.transportPrivateKey, + export.transportPublicKeyDER, + export.backendPrivateKey, + export.backendPublicKeyDER, + export.operatorPrivateKey, + export.operatorPublicKeyDER, + now, + localIP, + ) + if err != nil { + export.t.Fatal(err) + } + if export.transportAttestationMutator != nil { + raw, err := base64.StdEncoding.Strict().DecodeString(attestation) + if err != nil { + export.t.Fatal(err) + } + mutated := frostRetainedGroupTransportAttestation{} + if err := decodeStrictFrostActivationJSON(raw, &mutated); err != nil { + export.t.Fatal(err) + } + export.transportAttestationMutator(&mutated) + digest, err := frostRetainedGroupAttestationTranscript(mutated) + if err != nil { + export.t.Fatal(err) + } + backendDigest := frostRetainedGroupBackendAttestationDigest(digest) + mutated.BackendSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(export.backendPrivateKey, backendDigest[:]), + ) + operatorDigest := frostRetainedGroupOperatorAttestationDigest(digest) + mutated.OperatorSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(export.operatorPrivateKey, operatorDigest[:]), + ) + mutated.Signature = base64.StdEncoding.EncodeToString( + ed25519.Sign(export.transportPrivateKey, digest[:]), + ) + encoded, err := json.Marshal(mutated) + if err != nil { + export.t.Fatal(err) + } + attestation = base64.StdEncoding.EncodeToString(encoded) + } + if export.replayTransportAttestation { + export.transportAttestationMutex.Lock() + if export.savedTransportAttestation == "" { + export.savedTransportAttestation = attestation + } else { + attestation = export.savedTransportAttestation + } + export.transportAttestationMutex.Unlock() + } + responseWriter.Header().Set("Content-Type", contentType) + if !export.omitTransportAttestation { + responseWriter.Header().Add( + frostRetainedGroupTransportAttestationHeader, + attestation, + ) + if export.duplicateTransportAttestation { + responseWriter.Header().Add( + frostRetainedGroupTransportAttestationHeader, + attestation, + ) + } + } + responseWriter.WriteHeader(status) + if _, err := responseWriter.Write(responseBody); err != nil { + export.t.Fatal(err) + } +} + +type frostRetainedGroupHistorySourceFixture struct { + t *testing.T + verifier *frostRetainedGroupHistoryTestVerifier + export *frostRetainedGroupHistoryTestExport + server *httptest.Server + source *signedFrostRetainedGroupHistorySource + identity FrostRetainedGroupHistoryIdentity + profile FrostPreSignActivationProfile + runtimeManifest FrostPreSignActivationRuntimeManifest + from FrostPreSignFinality + to FrostPreSignFinality + descriptorSetHash [32]byte + snapshotID [32]byte + mutations []FrostRetainedGroupMutation + dkgFullMembers []uint32 + dkgMisbehaved []uint8 + checkpointIssuer func( + FrostRetainedGroupCheckpointCursor, + FrostPreSignFinality, + []FrostRetainedGroupMutation, + ) ([]FrostRetainedGroupCheckpointCertificate, error) + pageMutator func(*frostRetainedGroupHistoryPagePayload) + operatorMutator func(*frostRetainedGroupOperatorReceiptPayload) +} + +func (fixture *frostRetainedGroupHistorySourceFixture) checkpointAfter() FrostRetainedGroupCheckpointCursor { + return FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.runtimeManifest.QuarantineJournal. + CheckpointMinimumSequence - 1, + CertificateHash: fixture.runtimeManifest.QuarantineJournal. + CheckpointPredecessorHash, + } +} + +func newFrostRetainedGroupHistoryTLSTestServer( + t *testing.T, + handler http.Handler, + serviceIdentity string, +) (*httptest.Server, *x509.Certificate, *x509.CertPool) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serviceURI, err := url.Parse(serviceIdentity) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "retained-history-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + URIs: []*url.URL{serviceURI}, + } + certificateDER, err := x509.CreateCertificate( + rand.Reader, + template, + template, + &privateKey.PublicKey, + privateKey, + ) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(certificateDER) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(handler) + server.TLS = &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + NextProtos: []string{"http/1.1"}, + Certificates: []tls.Certificate{{ + Certificate: [][]byte{certificateDER}, + PrivateKey: privateKey, + Leaf: leaf, + }}, + } + server.StartTLS() + t.Cleanup(server.Close) + roots := x509.NewCertPool() + roots.AddCert(leaf) + return server, leaf, roots +} + +func newFrostRetainedGroupHistorySourceFixture( + t *testing.T, +) *frostRetainedGroupHistorySourceFixture { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + transportPublicKey, transportPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + transportPublicKeyDER, err := x509.MarshalPKIXPublicKey( + transportPublicKey, + ) + if err != nil { + t.Fatal(err) + } + backendPublicKey, backendPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + backendPublicKeyDER, err := x509.MarshalPKIXPublicKey(backendPublicKey) + if err != nil { + t.Fatal(err) + } + operatorPublicKey, operatorPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + operatorPublicKeyDER, err := x509.MarshalPKIXPublicKey(operatorPublicKey) + if err != nil { + t.Fatal(err) + } + headers := make(map[uint64]*types.Header) + for blockNumber := uint64(1); blockNumber <= 10; blockNumber++ { + headers[blockNumber] = &types.Header{ + Number: new(big.Int).SetUint64(blockNumber), + Time: blockNumber, + Extra: []byte{byte(blockNumber), 0x9a}, + } + } + bridgeCode := []byte{0x60, 0x01, 0x60, 0x02} + registryCode := []byte{0x60, 0x03, 0x60, 0x04} + sortitionPoolCode := []byte{0x60, 0x05, 0x60, 0x06} + profile, runtimeManifest := frostRetainedGroupHistoryTestProfile( + t, + bridgeCode, + registryCode, + sortitionPoolCode, + headers[1].Hash(), + ) + checkpointPrivateKeys := make([]ed25519.PrivateKey, 3) + checkpointPublicKeySPKIs := make([]string, 3) + checkpointAuthorities := make([]FrostRetainedGroupAuthority, 3) + for index := range checkpointAuthorities { + authority, privateKey, publicKeySPKI := journalTestAuthority( + t, + fmt.Sprintf("checkpoint-%d", index+1), + byte(0x51+index), + ) + checkpointAuthorities[index] = authority + checkpointPrivateKeys[index] = privateKey + checkpointPublicKeySPKIs[index] = publicKeySPKI + } + runtimeManifest.QuarantineJournal.CheckpointAuthorities = + checkpointAuthorities + operatorAddress := common.HexToAddress("0x1111111111111111111111111111111111111111") + verifier := &frostRetainedGroupHistoryTestVerifier{ + chainID: big.NewInt(1), + finalized: headers[10], + headers: headers, + reads: make(map[uint64]int), + receipts: make(map[common.Hash]*types.Receipt), + code: make(map[common.Address][]byte), + storage: make(map[common.Address]map[common.Hash][]byte), + sortitionPool: common.Address(profile.SortitionPool), + operator: operatorAddress, + operatorAt: 6, + operatorID: 17, + } + verifier.code[common.Address(profile.BridgeAddress)] = bridgeCode + verifier.code[common.Address(profile.FrostRegistry)] = registryCode + verifier.code[common.Address(profile.SortitionPool)] = sortitionPoolCode + export := &frostRetainedGroupHistoryTestExport{ + t: t, + privateKey: privateKey, + publicKeyDER: publicKeyDER, + transportPrivateKey: transportPrivateKey, + transportPublicKeyDER: transportPublicKeyDER, + backendPrivateKey: backendPrivateKey, + backendPublicKeyDER: backendPublicKeyDER, + operatorPrivateKey: operatorPrivateKey, + operatorPublicKeyDER: operatorPublicKeyDER, + } + exportServiceIdentity := "spiffe://export.retained.test/export" + server, leaf, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + export, + exportServiceIdentity, + ) + endpoint, canonicalEndpoint, err := validateFrostRetainedGroupTLSEndpoint( + server.URL + "/", + ) + if err != nil { + t.Fatal(err) + } + resolvedEndpoint, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + endpoint, + nil, + ) + if err != nil { + t.Fatal(err) + } + exportIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-export", + TrustDomainID: "export.retained.test", + CanonicalEndpoint: canonicalEndpoint, + CanonicalDNSName: resolvedEndpoint.canonicalDNSName, + ResolvedDNSName: resolvedEndpoint.resolvedDNSName, + ResolvedAddressSetHash: resolvedEndpoint.addressSetHash, + TLSLeafSPKIHash: sha256.Sum256(leaf.RawSubjectPublicKeyInfo), + ServiceIdentity: exportServiceIdentity, + BackendServiceFingerprint: sha256.Sum256(backendPublicKeyDER), + OperatorFingerprint: sha256.Sum256(operatorPublicKeyDER), + AttestationKeyHash: sha256.Sum256(transportPublicKeyDER), + TLSExporterProtocolID: frostRetainedGroupTLSExporterProtocolID(), + } + exportIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(exportIdentity) + verifierAddress := netip.MustParseAddr("127.0.0.2") + verifierIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-verifier", + TrustDomainID: "verifier.retained.test", + CanonicalEndpoint: "https://127.0.0.2:9443/rpc", + CanonicalDNSName: "127.0.0.2", + ResolvedDNSName: "127.0.0.2", + ResolvedAddressSetHash: frostRetainedGroupResolvedAddressSetHash([]netip.Addr{verifierAddress}), + TLSLeafSPKIHash: [32]byte{0x63}, + ServiceIdentity: "spiffe://verifier.retained.test/verifier", + BackendServiceFingerprint: [32]byte{0x64}, + OperatorFingerprint: [32]byte{0x65}, + AttestationKeyHash: [32]byte{0x66}, + TLSExporterProtocolID: frostRetainedGroupTLSExporterProtocolID(), + } + verifierIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(verifierIdentity) + identity := FrostRetainedGroupHistoryIdentity{ + Schema: frostRetainedGroupSourceIdentitySchema, + TrustDomainID: "independent-retained-history-test", + OperatorFingerprint: exportIdentity.OperatorFingerprint, + HistorySignerKeyHash: sha256.Sum256(publicKeyDER), + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(identity) + export.identity = exportIdentity + runtimeManifest.CanonicalJournal.SourceTrustDomainID = + identity.TrustDomainID + runtimeManifest.CanonicalJournal.SourceEndpointFingerprint = + identity.EndpointFingerprint + runtimeManifest.CanonicalJournal.SourceOperatorFingerprint = + identity.OperatorFingerprint + runtimeManifest.CanonicalJournal.SourceIdentity = identity + exportHTTPClient, exportTransport, err := + newFrostRetainedGroupAttestedHTTPClient( + resolvedEndpoint, + exportIdentity, + roots, + frostRetainedGroupDefaultTimeout, + ) + if err != nil { + t.Fatal(err) + } + verifierURL, _, err := validateFrostRetainedGroupTLSEndpoint( + verifierIdentity.CanonicalEndpoint, + ) + if err != nil { + t.Fatal(err) + } + resolvedVerifier, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + verifierURL, + nil, + ) + if err != nil { + t.Fatal(err) + } + source, err := newSignedFrostRetainedGroupHistorySource( + context.Background(), + endpoint, + verifier, + exportHTTPClient, + 1, + identity, + identity.HistorySignerKeyHash, + frostRetainedGroupDefaultTimeout, + &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: resolvedEndpoint, + verifierEndpoint: resolvedVerifier, + primaryTransport: &testFrostPrimaryEthereumIndependenceVerifier{}, + }, + ) + if err != nil { + t.Fatal(err) + } + source.httpTransports = []*http.Transport{exportTransport} + descriptorSetHash := [32]byte{0x42} + if err := source.BindFrostRetainedGroupActivationEvidence( + profile, + runtimeManifest, + ); err != nil { + t.Fatal(err) + } + export.bindingHash = source.evidence.bindingHash + t.Cleanup(source.Close) + fixture := &frostRetainedGroupHistorySourceFixture{ + t: t, + verifier: verifier, + export: export, + server: server, + source: source, + identity: identity, + profile: profile, + runtimeManifest: runtimeManifest, + from: FrostPreSignFinality{BlockNumber: 1, BlockHash: headers[1].Hash()}, + to: FrostPreSignFinality{BlockNumber: 6, BlockHash: headers[6].Hash()}, + descriptorSetHash: descriptorSetHash, + snapshotID: [32]byte{0x53}, + } + fixture.mutations = frostRetainedGroupHistoryTestMutations(t, headers, source.evidence) + fixture.dkgFullMembers = append( + []uint32{}, + fixture.mutations[0].OperatorIDs..., + ) + fixture.installReceipts() + fixture.checkpointIssuer = newFrostRetainedGroupTestCheckpointIssuer( + t, + source.evidence.checkpointPolicy, + fixture.from, + checkpointPrivateKeys, + checkpointPublicKeySPKIs, + ) + export.historyResponder = fixture.historyResponse + export.operatorResponder = fixture.operatorResponse + return fixture +} + +func frostRetainedGroupHistoryTestProfile( + t *testing.T, + bridgeCode []byte, + registryCode []byte, + sortitionPoolCode []byte, + deploymentBlockHash common.Hash, +) (FrostPreSignActivationProfile, FrostPreSignActivationRuntimeManifest) { + t.Helper() + emptyLinkedLibraryDescriptorHash, err := + frostRetainedGroupLinkedLibraryInventoryHash( + []FrostPreSignLinkedLibraryEvidence{}, + ) + if err != nil { + t.Fatal(err) + } + profile := FrostPreSignActivationProfile{ + DomainChainID: [32]byte{31: 0x01}, + ActivationManifestHash: [32]byte{0x02}, + BridgeAddress: [20]byte{0x11}, + RegistryAddress: [20]byte{0x12}, + CompleteRouter: [20]byte{0x13}, + FrostRegistry: [20]byte{0x14}, + ProposalValidator: [20]byte{0x15}, + SortitionPool: [20]byte{0x16}, + BridgeCodeHash: [32]byte(crypto.Keccak256Hash(bridgeCode)), + RegistryCodeHash: [32]byte{0x22}, + CompleteRouterCodeHash: [32]byte{0x23}, + FrostRegistryCodeHash: [32]byte(crypto.Keccak256Hash(registryCode)), + ProposalValidatorCodeHash: [32]byte{0x25}, + SortitionPoolCodeHash: [32]byte(crypto.Keccak256Hash(sortitionPoolCode)), + ReservationProtocolID: frostPreSignReservationProtocolID(), + EvidenceProtocolID: frostCompleteEvidenceProtocolID(), + SigningPolicyHash: frostPreSignSigningPolicyHash(), + } + inputs := []struct { + role string + name string + address [20]byte + codeHash [32]byte + }{ + {"bridge", "Bridge", profile.BridgeAddress, profile.BridgeCodeHash}, + {"completeRouter", "COMPLETE router", profile.CompleteRouter, profile.CompleteRouterCodeHash}, + {"authorizationRegistry", "authorization registry", profile.RegistryAddress, profile.RegistryCodeHash}, + {"frostWalletRegistry", "FROST wallet registry", profile.FrostRegistry, profile.FrostRegistryCodeHash}, + {"frostProposalValidator", "proposal validator", profile.ProposalValidator, profile.ProposalValidatorCodeHash}, + {"frostSortitionPool", "sortition pool", profile.SortitionPool, profile.SortitionPoolCodeHash}, + {"ecdsaFraudRouter", "ECDSA fraud router", [20]byte{0x17}, [32]byte{0x27}}, + {"ecdsaCutoverCoordinator", "ECDSA cutover coordinator", [20]byte{0x18}, [32]byte{0x28}}, + } + deployments := make([]FrostPreSignDeploymentEvidence, 0, len(inputs)) + for _, input := range inputs { + descriptor := FrostPreSignDeploymentDescriptorEvidence{ + Address: input.address, + RuntimeCodeHash: input.codeHash, + Upgradeability: "immutable", + LinkedLibraryDescriptorHash: emptyLinkedLibraryDescriptorHash, + } + descriptor.DescriptorHash = descriptor.ComputeHash() + deployments = append(deployments, FrostPreSignDeploymentEvidence{ + Role: input.role, + Name: input.name, + DeploymentBlock: 1, + RelevantEventStartBlock: 1, + Current: descriptor, + HistoricalEpochs: []FrostPreSignDeploymentEpochEvidence{{ + Start: FrostPreSignFinality{ + BlockNumber: 1, + BlockHash: [32]byte(deploymentBlockHash), + }, + Descriptor: descriptor, + }}, + }) + } + profile.ImplementationSetHash = + ComputeFrostPreSignDeploymentEvidenceHash(deployments) + profile.ProfileHash = profile.ComputeHash() + linkedLibraryDescriptorSetHash, err := + frostRetainedGroupLinkedLibraryDescriptorSetHash(deployments) + if err != nil { + t.Fatal(err) + } + checkpointAuthorities := []FrostRetainedGroupAuthority{ + {AuthorityID: "checkpoint-1", PublicKeySPKIHash: [32]byte{0x51}}, + {AuthorityID: "checkpoint-2", PublicKeySPKIHash: [32]byte{0x52}}, + {AuthorityID: "checkpoint-3", PublicKeySPKIHash: [32]byte{0x53}}, + } + liftAuthorities := []FrostRetainedGroupAuthority{ + {AuthorityID: "lift-1", PublicKeySPKIHash: [32]byte{0x54}}, + {AuthorityID: "lift-2", PublicKeySPKIHash: [32]byte{0x55}}, + {AuthorityID: "lift-3", PublicKeySPKIHash: [32]byte{0x56}}, + } + return profile, FrostPreSignActivationRuntimeManifest{ + ManifestHash: profile.ActivationManifestHash, + ActivationAuthorityKeyHash: [32]byte{0x35}, + VerifierOperatorFingerprint: [32]byte{0x36}, + HandshakeOperatorFingerprint: [32]byte{0x38}, + DomainChainID: profile.DomainChainID, + GenesisBlockHash: [32]byte{0x39}, + ProfileHash: profile.ProfileHash, + ImplementationSetHash: profile.ImplementationSetHash, + LinkedLibraryDescriptorSetHash: linkedLibraryDescriptorSetHash, + EndpointIdentitySetHash: [32]byte{0x3a}, + Deployments: deployments, + SignerProtocolID: [32]byte{0x45}, + ReservationProtocolID: profile.ReservationProtocolID, + BitcoinOutboxProtocolID: [32]byte{0x46}, + SigningPolicyHash: profile.SigningPolicyHash, + AttestationSignerKeyHash: [32]byte{0x37}, + RetainedGroupInventoryProtocolID: [32]byte{0x43}, + CanonicalJournal: FrostRetainedGroupCanonicalJournalManifest{ + StoreID: "canonical-test-store", + StoreFingerprint: [32]byte{0x47}, + ClusterFingerprint: [32]byte{0x48}, + Checkpoint: FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte(deploymentBlockHash)}, + DescriptorSetHash: [32]byte{0x42}, + SourceTrustDomainID: "independent-retained-history-test", + SourceEndpointFingerprint: [32]byte{0x31}, + }, + QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ + ProtocolID: [32]byte{0x44}, + LiftProtocolID: [32]byte{0x4b}, + TombstoneProtocolID: [32]byte{0x4c}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, + LiftAuthorityThreshold: 2, + LiftAuthorities: liftAuthorities, + StoreID: "quarantine-test-store", + StoreFingerprint: [32]byte{0x49}, + ClusterFingerprint: [32]byte{0x4a}, + }, + } +} + +func frostRetainedGroupHistoryTestMutations( + t *testing.T, + headers map[uint64]*types.Header, + evidence *frostRetainedGroupEvidenceProfile, +) []FrostRetainedGroupMutation { + t.Helper() + walletID := [32]byte{0x71} + walletPublicKeyHash := [20]byte{0x72} + operatorIDs := make([]uint32, 51) + for index := range operatorIDs { + operatorIDs[index] = uint32(index + 1) + } + dkgSubmission := FrostRetainedGroupEventPoint{ + BlockNumber: 2, + BlockHash: headers[2].Hash(), + TransactionHash: [32]byte{0xa2}, + TransactionIndex: 0, + LogIndex: 2, + } + admissionTransaction := [32]byte{0xa3} + dkgApproval := FrostRetainedGroupEventPoint{ + BlockNumber: 3, + BlockHash: headers[3].Hash(), + TransactionHash: admissionTransaction, + TransactionIndex: 3, + LogIndex: 10, + } + creation := FrostRetainedGroupEventPoint{ + BlockNumber: 3, + BlockHash: headers[3].Hash(), + TransactionHash: admissionTransaction, + TransactionIndex: 3, + LogIndex: 11, + } + registration := creation + registration.LogIndex = 12 + closing := FrostRetainedGroupEventPoint{ + BlockNumber: 4, + BlockHash: headers[4].Hash(), + TransactionHash: [32]byte{0xa4}, + TransactionIndex: 1, + LogIndex: 20, + } + closureTransaction := [32]byte{0xa5} + closed := FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: headers[5].Hash(), + TransactionHash: closureTransaction, + TransactionIndex: 2, + LogIndex: 30, + } + registryClosure := closed + registryClosure.LogIndex = 31 + dkgResult, _, dkgResultHash := frostRetainedGroupHistoryTestDkgResult( + t, + evidence, + walletID, + operatorIDs, + ) + return []FrostRetainedGroupMutation{ + { + Point: registration, + Kind: FrostRetainedGroupAdmissionMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + OperatorIDs: operatorIDs, + RetainedGroupHash: dkgResult.MembersHash, + DkgResultHash: dkgResultHash, + DkgSubmissionPoint: dkgSubmission, + DkgApprovalPoint: dkgApproval, + CreationPoint: creation, + BridgeRegistrationPoint: registration, + }, + { + Point: closing, + Kind: FrostRetainedGroupClosingMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + }, + { + Point: closed, + Kind: FrostRetainedGroupClosedMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + }, + { + Point: registryClosure, + Kind: FrostRetainedGroupRegistryClosureMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + }, + } +} + +func frostRetainedGroupHistoryTestDkgResult( + t *testing.T, + evidence *frostRetainedGroupEvidenceProfile, + walletID [32]byte, + operatorIDs []uint32, +) (frostabi.FrostDkgResult, []byte, [32]byte) { + return frostRetainedGroupHistoryTestDkgResultWithMisbehaved( + t, + evidence, + walletID, + operatorIDs, + nil, + ) +} + +func frostRetainedGroupHistoryTestDkgResultWithMisbehaved( + t *testing.T, + evidence *frostRetainedGroupEvidenceProfile, + walletID [32]byte, + fullMembers []uint32, + misbehaved []uint8, +) (frostabi.FrostDkgResult, []byte, [32]byte) { + t.Helper() + activeMembers, err := frostregistry.ActiveMembersFromMisbehaved( + frostregistry.FullMembers(fullMembers), + frostregistry.MisbehavedMemberIndices(misbehaved), + ) + if err != nil { + t.Fatal(err) + } + activeMembersHash, err := frostregistry.ActiveMembersHash(activeMembers) + if err != nil { + t.Fatal(err) + } + result := frostabi.FrostDkgResult{ + SubmitterMemberIndex: big.NewInt(1), + XOnlyOutputKey: walletID, + MisbehavedMembersIndices: append([]uint8{}, misbehaved...), + Signatures: []byte{0x01, 0x02}, + SigningMembersIndices: []*big.Int{big.NewInt(1)}, + Members: append([]uint32{}, fullMembers...), + MembersHash: activeMembersHash, + } + data, err := evidence.registryABI.Events["DkgResultSubmitted"].Inputs.NonIndexed().Pack(result) + if err != nil { + t.Fatal(err) + } + return result, data, [32]byte(crypto.Keccak256Hash(data)) +} + +func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { + fixture.t.Helper() + evidence, err := fixture.source.activationEvidence() + if err != nil { + fixture.t.Fatal(err) + } + registryAddress := common.Address( + evidence.deployments["frostWalletRegistry"].Current.Address, + ) + bridgeAddress := common.Address( + evidence.deployments["bridge"].Current.Address, + ) + admission := fixture.mutations[0] + dkgResult, dkgData, dkgResultHash := frostRetainedGroupHistoryTestDkgResultWithMisbehaved( + fixture.t, + evidence, + admission.WalletID, + fixture.dkgFullMembers, + fixture.dkgMisbehaved, + ) + admission.OperatorIDs = append( + []uint32{}, + mustFrostRetainedGroupActiveMembers( + fixture.t, + fixture.dkgFullMembers, + fixture.dkgMisbehaved, + )..., + ) + admission.RetainedGroupHash = dkgResult.MembersHash + admission.DkgResultHash = dkgResultHash + fixture.mutations[0] = admission + submissionLog := frostRetainedGroupHistoryTestLog( + admission.DkgSubmissionPoint, + registryAddress, + []common.Hash{ + evidence.registryABI.Events["DkgResultSubmitted"].ID, + common.Hash(admission.DkgResultHash), + common.BigToHash(big.NewInt(123)), + }, + dkgData, + ) + fixture.verifier.receipts[common.Hash(admission.DkgSubmissionPoint.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt(admission.DkgSubmissionPoint, submissionLog) + + approvalLog := frostRetainedGroupHistoryTestLog( + admission.DkgApprovalPoint, + registryAddress, + []common.Hash{ + evidence.registryABI.Events["DkgResultApproved"].ID, + common.Hash(admission.DkgResultHash), + common.BytesToHash(common.HexToAddress("0x2222222222222222222222222222222222222222").Bytes()), + }, + nil, + ) + creationLog := frostRetainedGroupHistoryTestLog( + admission.CreationPoint, + registryAddress, + []common.Hash{ + evidence.registryABI.Events["WalletCreated"].ID, + common.Hash(admission.WalletID), + common.Hash(admission.DkgResultHash), + }, + nil, + ) + registrationLog := frostRetainedGroupHistoryTestLog( + admission.BridgeRegistrationPoint, + bridgeAddress, + []common.Hash{ + evidence.bridgeABI.Events["NewWalletRegisteredV2"].ID, + common.Hash(admission.WalletID), + {}, + frostRetainedGroupBytes20Topic(admission.WalletPublicKeyHash), + }, + nil, + ) + fixture.verifier.receipts[common.Hash(admission.DkgApprovalPoint.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt( + admission.DkgApprovalPoint, + approvalLog, + creationLog, + registrationLog, + ) + + closing := fixture.mutations[1] + closingLog := frostRetainedGroupHistoryTestLog( + closing.Point, + bridgeAddress, + []common.Hash{ + evidence.bridgeABI.Events["WalletClosing"].ID, + {}, + frostRetainedGroupBytes20Topic(closing.WalletPublicKeyHash), + }, + nil, + ) + fixture.verifier.receipts[common.Hash(closing.Point.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt(closing.Point, closingLog) + + closed := fixture.mutations[2] + registryClosure := fixture.mutations[3] + closedLog := frostRetainedGroupHistoryTestLog( + closed.Point, + bridgeAddress, + []common.Hash{ + evidence.bridgeABI.Events["WalletClosed"].ID, + {}, + frostRetainedGroupBytes20Topic(closed.WalletPublicKeyHash), + }, + nil, + ) + registryClosureLog := frostRetainedGroupHistoryTestLog( + registryClosure.Point, + registryAddress, + []common.Hash{ + evidence.registryABI.Events["WalletClosed"].ID, + common.Hash(registryClosure.WalletID), + }, + nil, + ) + fixture.verifier.receipts[common.Hash(closed.Point.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt(closed.Point, closedLog, registryClosureLog) +} + +func mustFrostRetainedGroupActiveMembers( + t *testing.T, + fullMembers []uint32, + misbehaved []uint8, +) []uint32 { + t.Helper() + activeMembers, err := frostregistry.ActiveMembersFromMisbehaved( + frostregistry.FullMembers(fullMembers), + frostregistry.MisbehavedMemberIndices(misbehaved), + ) + if err != nil { + t.Fatal(err) + } + return append([]uint32{}, activeMembers...) +} + +func frostRetainedGroupHistoryTestLog( + point FrostRetainedGroupEventPoint, + address common.Address, + topics []common.Hash, + data []byte, +) *types.Log { + return &types.Log{ + Address: address, + Topics: append([]common.Hash{}, topics...), + Data: append([]byte{}, data...), + BlockNumber: point.BlockNumber, + TxHash: common.Hash(point.TransactionHash), + TxIndex: uint(point.TransactionIndex), + BlockHash: common.Hash(point.BlockHash), + Index: uint(point.LogIndex), + } +} + +func frostRetainedGroupHistoryTestReceipt( + point FrostRetainedGroupEventPoint, + logs ...*types.Log, +) *types.Receipt { + return &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.Hash(point.TransactionHash), + BlockHash: common.Hash(point.BlockHash), + BlockNumber: new(big.Int).SetUint64(point.BlockNumber), + TransactionIndex: uint(point.TransactionIndex), + Logs: logs, + } +} + +func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( + query frostRetainedGroupHistoryQuery, + checkpointAfterWire frostRetainedGroupWireCheckpointCursor, +) []*frostRetainedGroupHistoryPagePayload { + fixture.t.Helper() + queryHash, err := frostRetainedGroupDomainHash(frostRetainedGroupHistoryQueryDomain, query) + if err != nil { + fixture.t.Fatal(err) + } + wireMutations := make([]frostRetainedGroupWireMutation, len(fixture.mutations)) + for index, mutation := range fixture.mutations { + wireMutations[index] = frostRetainedGroupMutationToWire(mutation) + } + checkpointAfterHash, err := parseFrostActivationHex32( + checkpointAfterWire.CertificateHash, + ) + if err != nil { + fixture.t.Fatal(err) + } + checkpointAfter := FrostRetainedGroupCheckpointCursor{ + Sequence: checkpointAfterWire.Sequence, + CertificateHash: checkpointAfterHash, + } + checkpointTarget, err := frostRetainedGroupFinalityFromWire(query.To) + if err != nil { + fixture.t.Fatal(err) + } + checkpoints, err := fixture.checkpointIssuer( + checkpointAfter, + checkpointTarget, + fixture.mutations, + ) + if err != nil { + fixture.t.Fatal(err) + } + checkpointComplete := true + if len(checkpoints) > frostRetainedGroupMaximumCheckpointsPerPage { + checkpoints = checkpoints[:frostRetainedGroupMaximumCheckpointsPerPage] + checkpointComplete = false + } + checkpointHashes := make([][32]byte, len(checkpoints)) + wireCheckpoints := make( + []frostRetainedGroupWireCheckpointCertificate, + len(checkpoints), + ) + for index, checkpoint := range checkpoints { + checkpointHashes[index], err = + frostRetainedGroupCheckpointCertificateHash(checkpoint) + if err != nil { + fixture.t.Fatal(err) + } + wireCheckpoints[index] = + frostRetainedGroupCheckpointCertificateToWire(checkpoint) + } + checkpointTipHash := checkpointAfter.CertificateHash + if len(checkpointHashes) > 0 { + checkpointTipHash = checkpointHashes[len(checkpointHashes)-1] + } + pageCount := (len(wireMutations) + 1) / 2 + if pageCount == 0 { + pageCount = 1 + } + pages := make([]*frostRetainedGroupHistoryPagePayload, pageCount) + previousPageHash := [32]byte{} + identity := frostRetainedGroupIdentityToWire(fixture.identity) + for index := 0; index < pageCount; index++ { + start := index * 2 + end := start + 2 + if end > len(wireMutations) { + end = len(wireMutations) + } + cursor := "" + if index > 0 { + cursor = "page_" + string(rune('0'+index)) + } + nextCursor := "" + if index+1 < pageCount { + nextCursor = "page_" + string(rune('0'+index+1)) + } + page := &frostRetainedGroupHistoryPagePayload{ + Schema: frostRetainedGroupHistoryPageSchema, + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + Identity: identity, + ChainID: 1, + QueryHash: frostActivationHex32(queryHash), + SnapshotID: frostActivationHex32(fixture.snapshotID), + PageIndex: uint64(index), + Cursor: cursor, + PreviousPageHash: frostActivationHex32(previousPageHash), + From: query.From, + To: query.To, + EmptyAtFrom: true, + DescriptorSetHash: frostActivationHex32(fixture.descriptorSetHash), + CheckpointAfter: checkpointAfterWire, + Mutations: append([]frostRetainedGroupWireMutation{}, wireMutations[start:end]...), + NextCursor: nextCursor, + Complete: index+1 == pageCount, + } + pages[index] = page + canonical, err := canonicalFrostActivationValue(page) + if err != nil { + fixture.t.Fatal(err) + } + previousPageHash = sha256.Sum256(canonical) + } + historyRoot, err := frostRetainedGroupHistoryRoot( + fixture.source.evidence.bindingHash, + queryHash, + wireMutations, + ) + if err != nil { + fixture.t.Fatal(err) + } + pages[len(pages)-1].Receipt = &frostRetainedGroupHistoryReceipt{ + PageCount: uint64(len(pages)), + MutationCount: uint64(len(wireMutations)), + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + HistoryRoot: frostActivationHex32(historyRoot), + CheckpointAfter: checkpointAfterWire, + CheckpointCertificates: wireCheckpoints, + CheckpointChainRoot: frostActivationHex32( + frostRetainedGroupCheckpointChainRoot( + fixture.source.evidence.bindingHash, + checkpointAfter, + checkpointHashes, + ), + ), + CheckpointTipHash: frostActivationHex32(checkpointTipHash), + CheckpointComplete: checkpointComplete, + } + return pages +} + +func (fixture *frostRetainedGroupHistorySourceFixture) historyResponse( + request frostRetainedGroupHistoryPageRequest, +) interface{} { + if request.BindingHash != + frostActivationHex32(fixture.source.evidence.bindingHash) { + return nil + } + pages := fixture.historyPages(request.Query, request.CheckpointAfter) + for _, page := range pages { + if page.Cursor == request.Cursor { + copy := *page + copy.Mutations = append([]frostRetainedGroupWireMutation{}, page.Mutations...) + if page.Receipt != nil { + receipt := *page.Receipt + copy.Receipt = &receipt + } + if fixture.pageMutator != nil { + fixture.pageMutator(©) + } + return © + } + } + return nil +} + +func (fixture *frostRetainedGroupHistorySourceFixture) operatorResponse( + query frostRetainedGroupOperatorQuery, +) interface{} { + queryHash, err := frostRetainedGroupDomainHash(frostRetainedGroupOperatorQueryDomain, query) + if err != nil { + fixture.t.Fatal(err) + } + payload := &frostRetainedGroupOperatorReceiptPayload{ + Schema: frostRetainedGroupOperatorReceiptSchema, + BindingHash: frostActivationHex32( + fixture.source.evidence.bindingHash, + ), + Identity: frostRetainedGroupIdentityToWire(fixture.identity), + ChainID: 1, + QueryHash: frostActivationHex32(queryHash), + OperatorAddress: query.OperatorAddress, + At: query.At, + OperatorID: 17, + Found: true, + } + if fixture.operatorMutator != nil { + fixture.operatorMutator(payload) + } + return payload +} + +func TestSignedFrostRetainedGroupHistorySource_ReadsCompletePaginatedHistory( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + identity, err := fixture.source.Identity(context.Background()) + if err != nil || identity != fixture.identity { + t.Fatalf("unexpected identity: [%v] [%v]", identity, err) + } + history, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err != nil { + t.Fatal(err) + } + if !history.Complete || !history.EmptyAtFrom || len(history.Mutations) != 4 || + history.DescriptorSetHash != fixture.descriptorSetHash { + t.Fatalf("unexpected complete history: [%+v]", history) + } + operatorID, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err != nil || operatorID != 17 { + t.Fatalf("unexpected operator ID: [%d] [%v]", operatorID, err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_PaginatesLongCheckpointChain( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + checkpointCount := frostRetainedGroupMaximumCheckpointsPerPage + 2 + targetBlock := uint64(checkpointCount + 2) + finalizedBlock := targetBlock + 10 + for blockNumber := uint64(11); blockNumber <= finalizedBlock; blockNumber++ { + fixture.verifier.headers[blockNumber] = &types.Header{ + Number: new(big.Int).SetUint64(blockNumber), + Time: blockNumber, + Extra: []byte{byte(blockNumber), 0x9a}, + } + } + fixture.to = FrostPreSignFinality{ + BlockNumber: targetBlock, + BlockHash: fixture.verifier.headers[targetBlock].Hash(), + } + fixture.verifier.finalized = fixture.verifier.headers[finalizedBlock] + + cursor := fixture.checkpointAfter() + for blockNumber := uint64(3); blockNumber <= targetBlock; blockNumber++ { + point := FrostPreSignFinality{ + BlockNumber: blockNumber, + BlockHash: fixture.verifier.headers[blockNumber].Hash(), + } + prefix := make([]FrostRetainedGroupMutation, 0, len(fixture.mutations)) + for _, mutation := range fixture.mutations { + if mutation.Point.BlockNumber <= blockNumber { + prefix = append(prefix, mutation) + } + } + certificates, err := fixture.checkpointIssuer( + cursor, + point, + prefix, + ) + if err != nil { + t.Fatal(err) + } + if len(certificates) != 1 { + t.Fatalf( + "expected one newly issued checkpoint, got [%d]", + len(certificates), + ) + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificates[0]) + if err != nil { + t.Fatal(err) + } + cursor = FrostRetainedGroupCheckpointCursor{ + Sequence: certificates[0].Body.Sequence, + CertificateHash: certificateHash, + } + } + + first, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err != nil { + t.Fatal(err) + } + if first.CheckpointComplete || + len(first.Checkpoints) != + frostRetainedGroupMaximumCheckpointsPerPage { + t.Fatalf( + "unexpected first checkpoint page: complete [%t], count [%d]", + first.CheckpointComplete, + len(first.Checkpoints), + ) + } + firstTail := FrostRetainedGroupCheckpointCursor{ + Sequence: first.Checkpoints[len(first.Checkpoints)-1].Body.Sequence, + CertificateHash: first.CheckpointTipHash, + } + second, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + firstTail, + ) + if err != nil { + t.Fatal(err) + } + if !second.CheckpointComplete || + len(second.Checkpoints) != 2 || + second.CheckpointTipHash != cursor.CertificateHash || + second.HistoryRoot != first.HistoryRoot { + t.Fatalf( + "unexpected final checkpoint page: complete [%t], count [%d]", + second.CheckpointComplete, + len(second.Checkpoints), + ) + } +} + +func TestSignedFrostRetainedGroupHistorySource_ProtocolBindingCommitsRuntime( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + baseline := fixture.source.evidence.bindingHash + testCases := map[string]func( + *FrostPreSignActivationProfile, + *FrostPreSignActivationRuntimeManifest, + ){ + "domain chain": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.DomainChainID[31] ^= 0xff + }, + "genesis": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.GenesisBlockHash[0] ^= 0xff + }, + "checkpoint": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.Checkpoint.BlockHash[0] ^= 0xff + }, + "manifest": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.ManifestHash[0] ^= 0xff + }, + "profile": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.ProfileHash[0] ^= 0xff + }, + "implementation": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.ImplementationSetHash[0] ^= 0xff + }, + "descriptor": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.DescriptorSetHash[0] ^= 0xff + }, + "linked libraries": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.LinkedLibraryDescriptorSetHash[0] ^= 0xff + }, + "endpoint identities": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.EndpointIdentitySetHash[0] ^= 0xff + }, + "protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.SignerProtocolID[0] ^= 0xff + }, + "evidence protocol": func( + profile *FrostPreSignActivationProfile, + _ *FrostPreSignActivationRuntimeManifest, + ) { + profile.EvidenceProtocolID[0] ^= 0xff + }, + "canonical store": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.StoreID += "-other" + }, + "canonical cluster": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.ClusterFingerprint[0] ^= 0xff + }, + "quarantine store": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.StoreFingerprint[0] ^= 0xff + }, + "quarantine protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.ProtocolID[0] ^= 0xff + }, + "quarantine lift protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.LiftProtocolID[0] ^= 0xff + }, + "quarantine tombstone protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.TombstoneProtocolID[0] ^= 0xff + }, + "checkpoint authority set": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.CheckpointAuthorities[0]. + PublicKeySPKIHash[0] ^= 0xff + }, + "checkpoint minimum sequence": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.CheckpointMinimumSequence++ + runtime.QuarantineJournal.CheckpointPredecessorHash = + [32]byte{0x7f} + }, + "checkpoint predecessor": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.CheckpointMinimumSequence = 2 + runtime.QuarantineJournal.CheckpointPredecessorHash = + [32]byte{0x7e} + }, + "lift authority set": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash[0] ^= 0xff + }, + } + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + profile := fixture.profile + runtime := fixture.runtimeManifest + runtime.QuarantineJournal.CheckpointAuthorities = append( + []FrostRetainedGroupAuthority{}, + fixture.runtimeManifest.QuarantineJournal. + CheckpointAuthorities..., + ) + runtime.QuarantineJournal.LiftAuthorities = append( + []FrostRetainedGroupAuthority{}, + fixture.runtimeManifest.QuarantineJournal.LiftAuthorities..., + ) + mutate(&profile, &runtime) + binding, err := fixture.source.computeProtocolBinding(profile, runtime) + if err != nil { + t.Fatal(err) + } + if binding == baseline { + t.Fatalf("%s was omitted from the protocol binding", name) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsInconsistentRuntimeEvidence( + t *testing.T, +) { + testCases := map[string]struct { + mutate func(*FrostPreSignActivationProfile, *FrostPreSignActivationRuntimeManifest) + expected string + }{ + "profile role": { + mutate: func( + profile *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + for index := range runtime.Deployments { + deployment := &runtime.Deployments[index] + if deployment.Role != "bridge" { + continue + } + deployment.Current.Address[0] ^= 0xff + deployment.Current.DescriptorHash = + deployment.Current.ComputeHash() + last := len(deployment.HistoricalEpochs) - 1 + deployment.HistoricalEpochs[last].Descriptor = + cloneFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ) + } + profile.ImplementationSetHash = + ComputeFrostPreSignDeploymentEvidenceHash( + runtime.Deployments, + ) + profile.ProfileHash = profile.ComputeHash() + runtime.ImplementationSetHash = + profile.ImplementationSetHash + runtime.ProfileHash = profile.ProfileHash + }, + expected: "differs from the activation profile", + }, + "recursive descriptor": { + mutate: func( + profile *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + for index := range runtime.Deployments { + deployment := &runtime.Deployments[index] + if deployment.Role != "bridge" { + continue + } + deployment.Current.LinkedLibraryDescriptorHash[0] ^= 0xff + deployment.Current.DescriptorHash = + deployment.Current.ComputeHash() + last := len(deployment.HistoricalEpochs) - 1 + deployment.HistoricalEpochs[last].Descriptor = + cloneFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ) + } + profile.ImplementationSetHash = + ComputeFrostPreSignDeploymentEvidenceHash( + runtime.Deployments, + ) + profile.ProfileHash = profile.ComputeHash() + runtime.ImplementationSetHash = + profile.ImplementationSetHash + runtime.ProfileHash = profile.ProfileHash + }, + expected: "linked-library descriptor hash mismatch", + }, + "global descriptor set": { + mutate: func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.LinkedLibraryDescriptorSetHash[0] ^= 0xff + }, + expected: "descriptor set differs", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + profile := fixture.profile + runtime := fixture.runtimeManifest + runtime.Deployments = make( + []FrostPreSignDeploymentEvidence, + len(fixture.runtimeManifest.Deployments), + ) + for index, deployment := range fixture.runtimeManifest.Deployments { + runtime.Deployments[index] = + cloneFrostRetainedGroupDeploymentEvidence(deployment) + } + testCase.mutate(&profile, &runtime) + unbound := &signedFrostRetainedGroupHistorySource{ + chainID: fixture.source.chainID, + identity: fixture.source.identity, + } + err := unbound.BindFrostRetainedGroupActivationEvidence( + profile, + runtime, + ) + if err == nil || !strings.Contains(err.Error(), testCase.expected) { + t.Fatalf( + "expected %s inconsistency rejection, got [%v]", + name, + err, + ) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_DecodesCanonicalVerifiedBytes( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + raw := json.RawMessage("{\n \"z\": 1,\n \"a\": 2\n}") + canonical, err := canonicalFrostActivationValue(raw) + if err != nil { + t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signature := ed25519.Sign( + fixture.export.privateKey, + append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), + ) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v3", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + Payload: raw, + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + capture := json.RawMessage{} + if err := fixture.source.verifySignedEnvelope(envelope, &capture); err != nil { + t.Fatal(err) + } + if !bytes.Equal(capture, canonical) { + t.Fatalf( + "signed payload decoder did not consume exact verified bytes\nexpected: %s\nactual: %s", + canonical, + capture, + ) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsCrossBindingEnvelopeAndRoot( + t *testing.T, +) { + t.Run("signed envelope", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + raw := json.RawMessage(`{"bindingHash":"0x01"}`) + canonical, err := canonicalFrostActivationValue(raw) + if err != nil { + t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signature := ed25519.Sign( + fixture.export.privateKey, + append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), + ) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v3", + BindingHash: frostActivationHex32([32]byte{0xff}), + Payload: raw, + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + capture := json.RawMessage{} + err = fixture.source.verifySignedEnvelope(envelope, &capture) + if err == nil || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("expected cross-binding envelope rejection, got [%v]", err) + } + }) + + t.Run("history root", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.pageMutator = func(page *frostRetainedGroupHistoryPagePayload) { + if !page.Complete { + return + } + queryHash, err := parseFrostActivationHex32(page.QueryHash) + if err != nil { + t.Fatal(err) + } + mutationHashes := make([][32]byte, 0, len(fixture.mutations)) + for _, mutation := range fixture.mutations { + canonical, err := canonicalFrostActivationValue( + frostRetainedGroupMutationToWire(mutation), + ) + if err != nil { + t.Fatal(err) + } + mutationHashes = append(mutationHashes, sha256.Sum256(canonical)) + } + page.Receipt.HistoryRoot = frostActivationHex32( + frostRetainedGroupHistoryRootFromHashes( + [32]byte{0xfe}, + queryHash, + mutationHashes, + ), + ) + } + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || + !strings.Contains(err.Error(), "does not cover the exact mutation sequence") { + t.Fatalf("expected cross-binding history-root rejection, got [%v]", err) + } + }) +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateSignedPayloadKey( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v3", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + Payload: json.RawMessage(`{"schema":"v1","schema":"v2"}`), + PayloadSHA256: frostActivationHex32([32]byte{0x01}), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), + } + target := frostRetainedGroupHistoryPagePayload{} + err := fixture.source.verifySignedEnvelope(envelope, &target) + if err == nil || !strings.Contains(err.Error(), "duplicate key") { + t.Fatalf("expected duplicate signed payload key rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsNonExactSignedPayloadKey( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + payload := json.RawMessage(`{"Schema":"tbtc-frost-retained-group-history-page/v2"}`) + canonical, err := canonicalFrostActivationValue(payload) + if err != nil { + t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signature := ed25519.Sign( + fixture.export.privateKey, + append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), + ) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v3", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + Payload: payload, + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + target := frostRetainedGroupHistoryPagePayload{} + err = fixture.source.verifySignedEnvelope(envelope, &target) + if err == nil || !strings.Contains(err.Error(), "non-exact or unknown key") { + t.Fatalf("expected non-exact signed payload key rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsOmittedPage(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.export.historyResponder = func(request frostRetainedGroupHistoryPageRequest) interface{} { + pages := fixture.historyPages( + request.Query, + request.CheckpointAfter, + ) + return pages[len(pages)-1] + } + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "wrong identity or position") { + t.Fatalf("expected omitted-page rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsTruncationAndForgery( + t *testing.T, +) { + t.Run("truncated pagination", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + defaultResponder := fixture.export.historyResponder + fixture.export.historyResponder = func( + request frostRetainedGroupHistoryPageRequest, + ) interface{} { + if request.Cursor != "" { + return nil + } + return defaultResponder(request) + } + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "HTTP status [503]") { + t.Fatalf("expected truncated pagination rejection, got [%v]", err) + } + }) + + t.Run("forged envelope", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.export.corruptSignature = true + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "signature is invalid") { + t.Fatalf("expected forged envelope rejection, got [%v]", err) + } + }) +} + +func TestSignedFrostRetainedGroupHistorySource_EnforcesAggregateResourceLimits( + t *testing.T, +) { + testCases := map[string]struct { + configure func(*signedFrostRetainedGroupHistorySource) + expected string + }{ + "aggregate response bytes": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumResponseBytes = 1 + }, + expected: "aggregate response-byte limit", + }, + "page count": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumPages = 1 + }, + expected: "exceeded the page limit", + }, + "mutation count": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumMutations = 3 + }, + expected: "exceeds the mutation limit", + }, + "unique block count": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumUniqueBlocks = 2 + }, + expected: "exceeds the unique-block limit", + }, + "end-to-end duration": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumReadDuration = time.Nanosecond + }, + expected: "context deadline exceeded", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + testCase.configure(fixture.source) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), testCase.expected) { + t.Fatalf("expected %s rejection, got [%v]", name, err) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_CountsCheckpointPointsTowardUniqueBlockLimit( + t *testing.T, +) { + testCases := map[string]struct { + maximumUniqueBlocks uint64 + rejected bool + }{ + "rejects checkpoint over the limit": { + maximumUniqueBlocks: 6, + rejected: true, + }, + "accepts checkpoint at the limit": { + maximumUniqueBlocks: 7, + rejected: false, + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.to = FrostPreSignFinality{ + BlockNumber: 10, + BlockHash: fixture.verifier.headers[10].Hash(), + } + _, err := fixture.checkpointIssuer( + fixture.checkpointAfter(), + FrostPreSignFinality{ + BlockNumber: 7, + BlockHash: fixture.verifier.headers[7].Hash(), + }, + fixture.mutations, + ) + if err != nil { + t.Fatal(err) + } + // The history bounds and mutation evidence consume exactly six + // distinct blocks. The independently signed checkpoint at block 7 + // must count as a seventh canonical RPC lookup, even though the + // final checkpoint at block 10 is already represented by the + // history target. + fixture.source.maximumUniqueBlocks = + testCase.maximumUniqueBlocks + + history, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if testCase.rejected { + if err == nil || + !strings.Contains( + err.Error(), + "exceeds the unique-block limit", + ) { + t.Fatalf( + "expected checkpoint unique-block rejection, got [%v]", + err, + ) + } + if fixture.verifier.reads[7] != 0 { + t.Fatal( + "over-limit checkpoint reached canonical RPC verification", + ) + } + return + } + if err != nil { + t.Fatalf("checkpoint at the unique-block limit was rejected: [%v]", err) + } + if history == nil || len(history.Checkpoints) != 2 || + fixture.verifier.reads[7] == 0 { + t.Fatal( + "checkpoint at the unique-block limit was not fully verified", + ) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsOversizedReceiptLogSet( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + submissionHash := common.Hash( + fixture.mutations[0].DkgSubmissionPoint.TransactionHash, + ) + receipt := fixture.verifier.receipts[submissionHash] + receipt.Logs = make([]*types.Log, frostRetainedGroupMaximumReceiptLogs+1) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "log limit") { + t.Fatalf("expected oversized receipt-log rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateAndReorderedHistory( + t *testing.T, +) { + tests := map[string]func(*frostRetainedGroupHistorySourceFixture){ + "duplicate event": func(fixture *frostRetainedGroupHistorySourceFixture) { + fixture.mutations = append(fixture.mutations, fixture.mutations[3]) + }, + "reordered event": func(fixture *frostRetainedGroupHistorySourceFixture) { + fixture.mutations[0], fixture.mutations[1] = fixture.mutations[1], fixture.mutations[0] + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + mutate(fixture) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil { + t.Fatal("expected malformed exact history to be rejected") + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_AcceptsFilteredStakeWeightedSeats( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fullMembers := make([]uint32, 52) + for index := range fullMembers { + fullMembers[index] = uint32(index + 1) + } + // Repeated nonzero IDs are distinct stake-weighted sortition seats. The + // second seat is excluded using the contract's strict 1-based index. + fullMembers[51] = fullMembers[0] + fixture.dkgFullMembers = fullMembers + fixture.dkgMisbehaved = []uint8{2} + fixture.installReceipts() + + history, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err != nil { + t.Fatal(err) + } + active := history.Mutations[0].OperatorIDs + if len(active) != 51 || active[0] != 1 || active[50] != 1 { + t.Fatalf("unexpected ordered active DKG members: [%v]", active) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsInvalidMisbehavedIndices( + t *testing.T, +) { + testCases := map[string][]uint8{ + "zero": {0}, + "duplicate": {2, 2}, + "not sorted": {3, 2}, + "out of range": { + 52, + }, + } + for name, misbehaved := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + evidence, err := fixture.source.activationEvidence() + if err != nil { + t.Fatal(err) + } + admission := &fixture.mutations[0] + result, _, _ := frostRetainedGroupHistoryTestDkgResult( + t, + evidence, + admission.WalletID, + fixture.dkgFullMembers, + ) + result.MisbehavedMembersIndices = append([]uint8{}, misbehaved...) + data, err := evidence.registryABI.Events["DkgResultSubmitted"].Inputs.NonIndexed().Pack(result) + if err != nil { + t.Fatal(err) + } + resultHash := [32]byte(crypto.Keccak256Hash(data)) + admission.DkgResultHash = resultHash + + submissionTransactionHash := common.Hash( + admission.DkgSubmissionPoint.TransactionHash, + ) + submissionReceipt := fixture.verifier.receipts[submissionTransactionHash] + submissionReceipt.Logs[0].Topics[1] = common.Hash(resultHash) + submissionReceipt.Logs[0].Data = data + approvalTransactionHash := common.Hash( + admission.DkgApprovalPoint.TransactionHash, + ) + approvalReceipt := fixture.verifier.receipts[approvalTransactionHash] + approvalReceipt.Logs[0].Topics[1] = common.Hash(resultHash) + approvalReceipt.Logs[1].Topics[2] = common.Hash(resultHash) + + _, err = fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains( + err.Error(), + "invalid misbehaved member indices", + ) { + t.Fatalf("expected strict 1-based index rejection, got [%v]", err) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsIdentityCheckpointAndReceiptDrift( + t *testing.T, +) { + tests := map[string]func(*frostRetainedGroupHistoryPagePayload){ + "identity": func(page *frostRetainedGroupHistoryPagePayload) { + page.Identity.TrustDomainID = "wrong-domain" + }, + "checkpoint": func(page *frostRetainedGroupHistoryPagePayload) { + page.From.BlockHash = frostActivationHex32([32]byte{0xff}) + }, + "manifest descriptor": func(page *frostRetainedGroupHistoryPagePayload) { + page.DescriptorSetHash = frostActivationHex32([32]byte{0xdd}) + }, + "protocol binding": func(page *frostRetainedGroupHistoryPagePayload) { + page.BindingHash = frostActivationHex32([32]byte{0xdc}) + }, + "receipt binding": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.BindingHash = frostActivationHex32([32]byte{0xdb}) + } + }, + "receipt": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.HistoryRoot = frostActivationHex32([32]byte{0xee}) + } + }, + "checkpoint chain root": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.CheckpointChainRoot = + frostActivationHex32([32]byte{0xed}) + } + }, + "checkpoint tip": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.CheckpointTipHash = + frostActivationHex32([32]byte{0xec}) + } + }, + "checkpoint completion": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.CheckpointComplete = false + } + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.pageMutator = mutate + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil { + t.Fatalf("expected %s drift to be rejected", name) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsSemanticForgeryInCanonicalBlock( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + for index := range fixture.mutations { + fixture.mutations[index].WalletPublicKeyHash[0] ^= 0xff + } + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "Bridge registration log") { + t.Fatalf("expected canonical-block semantic forgery rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsWrongReceiptLog( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + admission := fixture.mutations[0] + receipt := fixture.verifier.receipts[common.Hash(admission.BridgeRegistrationPoint.TransactionHash)] + receipt.Logs[2].Topics[3] = common.Hash{0xff} + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "Bridge registration log") { + t.Fatalf("expected wrong receipt-log rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsManifestCodeDrift( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.verifier.code[common.Address(fixture.profile.BridgeAddress)] = []byte{0xff} + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "signed activation manifest") { + t.Fatalf("expected manifest code-drift rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsDeploymentTransitionEvent( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + deployment := cloneFrostRetainedGroupDeploymentEvidence( + fixture.source.evidence.deployments["bridge"], + ) + firstEnd := FrostPreSignFinality{ + BlockNumber: 2, + BlockHash: fixture.verifier.headers[2].Hash(), + } + deployment.HistoricalEpochs = []FrostPreSignDeploymentEpochEvidence{ + { + Start: deployment.HistoricalEpochs[0].Start, + End: &firstEnd, + Descriptor: deployment.HistoricalEpochs[0].Descriptor, + }, + { + Start: FrostPreSignFinality{ + BlockNumber: 3, + BlockHash: fixture.verifier.headers[3].Hash(), + }, + Descriptor: deployment.Current, + }, + } + + if _, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + 3, + fixture.verifier.headers[3].Hash(), + true, + ); err == nil || !strings.Contains(err.Error(), "implementation-transition block") { + t.Fatalf("expected implementation-transition event rejection, got [%v]", err) + } + if _, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + 3, + fixture.verifier.headers[3].Hash(), + false, + ); err != nil { + t.Fatalf("expected exact transition state read to select the new epoch: [%v]", err) + } + if _, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + 2, + [32]byte{0xff}, + false, + ); err == nil || !strings.Contains(err.Error(), "signed deployment boundary") { + t.Fatalf("expected exact epoch-boundary hash rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsEIP1967SlotDrift( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + descriptor := cloneFrostRetainedGroupDeploymentDescriptor( + fixture.source.evidence.deployments["bridge"].Current, + ) + proxyAddress := common.Address(descriptor.Address) + implementationAddress := common.HexToAddress( + "0x2222222222222222222222222222222222222222", + ) + adminAddress := common.HexToAddress( + "0x3333333333333333333333333333333333333333", + ) + implementationCode := []byte{0x60, 0x51} + adminCode := []byte{0x60, 0x52} + implementationSlotValue := [32]byte{} + copy(implementationSlotValue[12:], implementationAddress[:]) + adminSlotValue := [32]byte{} + copy(adminSlotValue[12:], adminAddress[:]) + + descriptor.Upgradeability = "eip1967" + descriptor.ImplementationAddress = [20]byte(implementationAddress) + descriptor.ImplementationCodeHash = [32]byte( + crypto.Keccak256Hash(implementationCode), + ) + descriptor.AdminAddress = [20]byte(adminAddress) + descriptor.AdminCodeHash = [32]byte(crypto.Keccak256Hash(adminCode)) + descriptor.ImplementationSlotValue = implementationSlotValue + descriptor.AdminSlotValue = adminSlotValue + descriptor.DescriptorHash = descriptor.ComputeHash() + fixture.verifier.code[implementationAddress] = implementationCode + fixture.verifier.code[adminAddress] = adminCode + fixture.verifier.storage[proxyAddress] = map[common.Hash][]byte{ + frostRetainedGroupEIP1967Slot("eip1967.proxy.implementation"): make( + []byte, + 32, + ), + frostRetainedGroupEIP1967Slot("eip1967.proxy.admin"): append( + []byte{}, + adminSlotValue[:]..., + ), + } + + err := fixture.source.authenticateContractDeployment( + context.Background(), + descriptor, + 2, + fixture.verifier.headers[2].Hash(), + make(frostRetainedGroupCodeCache), + ) + if err == nil || !strings.Contains(err.Error(), "slot value mismatch") { + t.Fatalf("expected EIP-1967 slot-drift rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsLinkedLibraryDrift( + t *testing.T, +) { + testCases := map[string]struct { + copyReference bool + actualCode []byte + expected string + }{ + "reference": { + actualCode: []byte{0x60, 0x61}, + expected: "reference", + }, + "runtime code": { + copyReference: true, + actualCode: []byte{0xff}, + expected: "signed activation manifest", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + descriptor := cloneFrostRetainedGroupDeploymentDescriptor( + fixture.source.evidence.deployments["bridge"].Current, + ) + ownerAddress := common.Address(descriptor.Address) + libraryAddress := common.HexToAddress( + "0x4444444444444444444444444444444444444444", + ) + expectedLibraryCode := []byte{0x60, 0x61} + ownerCode := make([]byte, 24) + if testCase.copyReference { + copy(ownerCode[2:], libraryAddress[:]) + } + descriptor.RuntimeCodeHash = [32]byte(crypto.Keccak256Hash(ownerCode)) + descriptor.LinkedLibraries = []FrostPreSignLinkedLibraryEvidence{{ + ProtocolRole: "bridge-library", + Address: [20]byte(libraryAddress), + RuntimeCodeHash: [32]byte(crypto.Keccak256Hash(expectedLibraryCode)), + LinkedLibraryDescriptorHash: [32]byte{0x67}, + References: []FrostPreSignLinkedLibraryReference{{ + Start: 2, + Length: 20, + }}, + }} + descriptor.DescriptorHash = descriptor.ComputeHash() + fixture.verifier.code[ownerAddress] = ownerCode + fixture.verifier.code[libraryAddress] = testCase.actualCode + + err := fixture.source.authenticateContractDeployment( + context.Background(), + descriptor, + 2, + fixture.verifier.headers[2].Hash(), + make(frostRetainedGroupCodeCache), + ) + if err == nil || !strings.Contains(err.Error(), testCase.expected) { + t.Fatalf("expected linked-library %s drift rejection, got [%v]", name, err) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsWrongEndpointAndReorg( + t *testing.T, +) { + resolve := func(raw string) frostRetainedGroupResolvedEndpoint { + endpoint, _, err := validateFrostRetainedGroupTLSEndpoint(raw) + if err != nil { + t.Fatal(err) + } + resolved, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + endpoint, + nil, + ) + if err != nil { + t.Fatal(err) + } + return resolved + } + exportEndpoint := resolve("https://127.0.0.1:9443/export") + verifierAlias := resolve("https://127.0.0.1:9444/rpc") + primaryAlias := resolve("https://127.0.0.1:9445/rpc") + if !frostRetainedGroupEndpointSetsOverlap( + exportEndpoint, + verifierAlias, + ) || !frostRetainedGroupEndpointSetsOverlap( + exportEndpoint, + primaryAlias, + ) { + t.Fatal("expected resolved shared-backend aliases to be rejected") + } + + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.verifier.reorgBlock = fixture.to.BlockNumber + fixture.verifier.reorgAfterRead = 2 + fixture.verifier.reorgHeader = &types.Header{ + Number: new(big.Int).SetUint64(fixture.to.BlockNumber), + Time: 999, + Extra: []byte{0xff}, + } + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "canonical chain") { + t.Fatalf("expected finalized-chain reorg rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsWrongOperatorReceipt( + t *testing.T, +) { + t.Run("wrong binding", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.operatorMutator = func(payload *frostRetainedGroupOperatorReceiptPayload) { + payload.BindingHash = frostActivationHex32([32]byte{0x98}) + } + _, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "differently bound") { + t.Fatalf("expected operator binding rejection, got [%v]", err) + } + }) + + t.Run("wrong point", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.operatorMutator = func(payload *frostRetainedGroupOperatorReceiptPayload) { + payload.At.BlockHash = frostActivationHex32([32]byte{0x99}) + } + _, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "differently bound") { + t.Fatalf("expected operator receipt rejection, got [%v]", err) + } + }) + + t.Run("wrong ID", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.operatorMutator = func(payload *frostRetainedGroupOperatorReceiptPayload) { + payload.OperatorID++ + } + _, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "disagrees with exact finalized") { + t.Fatalf("expected independent operator-ID rejection, got [%v]", err) + } + }) +} diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 98b1025321..e501bda357 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -3,8 +3,12 @@ package tbtc import ( "bytes" "context" + "crypto/ed25519" "crypto/rand" "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" "encoding/hex" "encoding/json" "errors" @@ -24,33 +28,84 @@ import ( ) const ( - frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v1" - frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v1" - frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v1" - frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v1" + frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v4" + frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v3" + frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v3" + frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v4" frostRetainedGroupJournalLockFile = ".lock" frostRetainedGroupJournalMetadataFile = "metadata.json" frostRetainedGroupJournalStateFile = "state.json" frostRetainedGroupJournalBatchPrefix = "batch-" + frostRetainedGroupLiftCertificatePrefix = "lift-certificate-" frostRetainedGroupJournalFileSuffix = ".json" frostRetainedGroupJournalTempSuffix = ".tmp" frostRetainedGroupJournalMaximumFile = 8 * 1024 * 1024 frostRetainedGroupCanonicalDirectory = "canonical" frostRetainedGroupQuarantineDirectory = "quarantine" - frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v1" - frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v1" - frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v1" + frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v3" + frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v3" + frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v3" + + frostRetainedGroupJournalMetadataSchemaV1 = "tbtc-frost-retained-group-journal-metadata/v1" + frostRetainedGroupJournalBatchSchemaV1 = "tbtc-frost-retained-group-journal-batch/v1" + frostRetainedGroupJournalStateSchemaV1 = "tbtc-frost-retained-group-journal-state/v1" + frostRetainedGroupQuarantineMetadataV1 = "tbtc-frost-retained-group-quarantine-metadata/v1" + frostRetainedGroupQuarantineBatchV1 = "tbtc-frost-retained-group-quarantine-batch/v1" + frostRetainedGroupQuarantineStateV1 = "tbtc-frost-retained-group-quarantine-state/v1" + frostRetainedGroupJournalMetadataSchemaV2 = "tbtc-frost-retained-group-journal-metadata/v2" + frostRetainedGroupJournalMetadataSchemaV3 = "tbtc-frost-retained-group-journal-metadata/v3" + frostRetainedGroupJournalBatchSchemaV2 = "tbtc-frost-retained-group-journal-batch/v2" + frostRetainedGroupJournalStateSchemaV2 = "tbtc-frost-retained-group-journal-state/v2" + frostRetainedGroupQuarantineMetadataV2 = "tbtc-frost-retained-group-quarantine-metadata/v2" + frostRetainedGroupQuarantineBatchV2 = "tbtc-frost-retained-group-quarantine-batch/v2" + frostRetainedGroupQuarantineStateV2 = "tbtc-frost-retained-group-quarantine-state/v2" frostRetainedGroupInventoryEntriesDomain = "tbtc-p2tr-frost-wallet-group-inventory-entries-v1\x00" frostRetainedGroupInventoryLeafDomain = "tbtc-p2tr-frost-wallet-group-inventory-leaf-v1\x00" frostRetainedGroupInventoryNodeDomain = "tbtc-p2tr-frost-wallet-group-inventory-node-v1\x00" frostRetainedGroupInventoryRootDomain = "tbtc-p2tr-frost-wallet-group-inventory-root-v1\x00" - frostRetainedGroupBatchDomain = "tbtc-frost-retained-group-journal-batch-v1\x00" - frostRetainedGroupQuarantineBatchDomain = "tbtc-frost-retained-group-quarantine-batch-v1\x00" - frostRetainedGroupQuarantineDomain = "tbtc-frost-retained-group-quarantine-v1\x00" + frostRetainedGroupBatchDomain = "tbtc-frost-retained-group-journal-batch-v3\x00" + frostRetainedGroupQuarantineBatchDomain = "tbtc-frost-retained-group-quarantine-batch-v3\x00" + frostRetainedGroupQuarantineDomain = "tbtc-frost-retained-group-quarantine-event-v3\x00" + frostRetainedGroupQuarantineActiveDomain = "tbtc-frost-retained-group-quarantine-active-root-v1\x00" + frostRetainedGroupTombstoneRootDomain = "tbtc-frost-retained-group-quarantine-tombstone-root-v1\x00" + frostRetainedGroupLiftAuthorityDomain = "tbtc-frost-retained-group-quarantine-lift-authority-set-v1\x00" + frostRetainedGroupLiftBodyDomain = "tbtc-frost-retained-group-quarantine-lift-body-v1\x00" + frostRetainedGroupLiftSignatureDomain = "tbtc-frost-retained-group-quarantine-lift-signature-v1\x00" + frostRetainedGroupLiftCertificateDomain = "tbtc-frost-retained-group-quarantine-lift-certificate-v1\x00" + + frostRetainedGroupLiftAuthoritySetSchema = "tbtc-frost-retained-group-quarantine-lift-authority-set/v1" + frostRetainedGroupLiftBodySchema = "tbtc-frost-retained-group-quarantine-lift-body/v1" + frostRetainedGroupLiftCertificateSchema = "tbtc-frost-retained-group-quarantine-lift-certificate/v1" + frostRetainedGroupMaximumCanonicalJSONInteger uint64 = 9007199254740991 +) + +var errFrostRetainedGroupCheckpointRecoveryProgress = errors.New( + "FROST checkpoint recovery advanced the durable head", ) +func frostRetainedGroupCheckpointRecoveryProgressError( + sequence uint64, + cause error, +) error { + if cause == nil { + return fmt.Errorf( + "%w after [%d] authenticated page; retry from durable sequence [%d]", + errFrostRetainedGroupCheckpointRecoveryProgress, + frostRetainedGroupCheckpointPagesPerReconciliation, + sequence, + ) + } + return fmt.Errorf( + "%w after [%d] authenticated page; retry from durable sequence [%d]; post-publication verification failed: %w", + errFrostRetainedGroupCheckpointRecoveryProgress, + frostRetainedGroupCheckpointPagesPerReconciliation, + sequence, + cause, + ) +} + // FrostRetainedGroupEventPoint identifies one canonical Ethereum log. The // transaction identity and ordering indexes prove Registry closure follows // the matching Bridge terminal transition in the same transaction. @@ -116,41 +171,113 @@ const ( FrostRetainedGroupTerminated FrostRetainedGroupLifecycle = "Terminated" ) +const ( + frostRetainedGroupQuarantineActive = "active" + frostRetainedGroupQuarantineLifted = "lifted" +) + func (frgl FrostRetainedGroupLifecycle) terminal() bool { return frgl == FrostRetainedGroupClosed || frgl == FrostRetainedGroupTerminated } // FrostRetainedGroupMutation is one complete source-authenticated semantic // history item. Admission carries exact ordered DKG operator IDs. A lift is -// accepted only with a nonzero authentication commitment for its exact ID. +// accepted only with a manifest-pinned quorum certificate for the exact +// durable quarantine state it resolves. type FrostRetainedGroupMutation struct { - Point FrostRetainedGroupEventPoint `json:"point"` - Kind FrostRetainedGroupMutationKind `json:"kind"` - WalletID [32]byte `json:"walletID"` - WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` - OperatorIDs []uint32 `json:"operatorIDs,omitempty"` - RetainedGroupHash [32]byte `json:"retainedGroupHash,omitempty"` - CreationPoint FrostRetainedGroupEventPoint `json:"creationPoint,omitempty"` - BridgeRegistrationPoint FrostRetainedGroupEventPoint `json:"bridgeRegistrationPoint,omitempty"` - QuarantineID [32]byte `json:"quarantineID,omitempty"` - EvidenceHash [32]byte `json:"evidenceHash,omitempty"` - AuthenticationHash [32]byte `json:"authenticationHash,omitempty"` - Reason string `json:"reason,omitempty"` -} - -type FrostRetainedGroupHistoryIdentity struct { - TrustDomainID string - EndpointFingerprint [32]byte - OperatorFingerprint [32]byte + Point FrostRetainedGroupEventPoint `json:"point"` + Kind FrostRetainedGroupMutationKind `json:"kind"` + WalletID [32]byte `json:"walletID"` + WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` + OperatorIDs []uint32 `json:"operatorIDs,omitempty"` + RetainedGroupHash [32]byte `json:"retainedGroupHash,omitempty"` + DkgResultHash [32]byte `json:"dkgResultHash,omitempty"` + DkgSubmissionPoint FrostRetainedGroupEventPoint `json:"dkgSubmissionPoint,omitempty"` + DkgApprovalPoint FrostRetainedGroupEventPoint `json:"dkgApprovalPoint,omitempty"` + CreationPoint FrostRetainedGroupEventPoint `json:"creationPoint,omitempty"` + BridgeRegistrationPoint FrostRetainedGroupEventPoint `json:"bridgeRegistrationPoint,omitempty"` + QuarantineID [32]byte `json:"quarantineID,omitempty"` + EvidenceHash [32]byte `json:"evidenceHash,omitempty"` + LiftCertificateHash [32]byte `json:"liftCertificateHash,omitempty"` + LiftCertificate *FrostRetainedGroupQuarantineLiftCertificate `json:"liftCertificate,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// FrostRetainedGroupAuthority is one manifest-pinned Ed25519 authority. +// AuthorityID is the stable, human-auditable identity used to order both the +// manifest set and certificate signatures. PublicKeySPKIHash pins the exact +// DER SubjectPublicKeyInfo supplied by a lift certificate. +type FrostRetainedGroupAuthority struct { + AuthorityID string `json:"authorityID"` + PublicKeySPKIHash [32]byte `json:"publicKeySpkiHash"` +} + +type FrostRetainedGroupQuarantineRaisedRecord struct { + QuarantineID [32]byte `json:"quarantineID"` + WalletID [32]byte `json:"walletID"` + EvidenceHash [32]byte `json:"evidenceHash"` + Reason string `json:"reason"` + RecoveryRequired bool `json:"recoveryRequired"` + RaisedAt FrostRetainedGroupEventPoint `json:"raisedAt"` +} + +// FrostRetainedGroupQuarantineLiftBody binds a lift to the signed production +// deployment and to the exact durable state immediately preceding the lift. +// NotBeforeBlock and ExpiresAtBlock are inclusive canonical Ethereum block +// bounds; wall-clock time is deliberately excluded. +type FrostRetainedGroupQuarantineLiftBody struct { + Schema string `json:"schema"` + ProtocolBindingHash [32]byte `json:"protocolBindingHash"` + ManifestHash [32]byte `json:"manifestHash"` + ProfileHash [32]byte `json:"profileHash"` + ImplementationSetHash [32]byte `json:"implementationSetHash"` + ChainID uint64 `json:"chainID"` + DomainChainID [32]byte `json:"domainChainID"` + GenesisBlockHash [32]byte `json:"genesisBlockHash"` + QuarantineProtocolID [32]byte `json:"quarantineProtocolID"` + LiftProtocolID [32]byte `json:"liftProtocolID"` + TombstoneProtocolID [32]byte `json:"tombstoneProtocolID"` + AuthoritySetHash [32]byte `json:"authoritySetHash"` + QuarantineID [32]byte `json:"quarantineID"` + WalletID [32]byte `json:"walletID"` + OriginalRaisedRecord FrostRetainedGroupQuarantineRaisedRecord `json:"originalRaisedRecord"` + PriorGeneration uint64 `json:"priorGeneration"` + PriorEventRoot [32]byte `json:"priorEventRoot"` + PriorActiveRoot [32]byte `json:"priorActiveRoot"` + PriorTombstoneRoot [32]byte `json:"priorTombstoneRoot"` + LiftPoint FrostRetainedGroupEventPoint `json:"liftPoint"` + ResolutionEvidenceHash [32]byte `json:"resolutionEvidenceHash"` + ResolutionFinality FrostPreSignFinality `json:"resolutionFinality"` + NotBeforeBlock uint64 `json:"notBeforeBlock"` + ExpiresAtBlock uint64 `json:"expiresAtBlock"` +} + +type FrostRetainedGroupQuarantineLiftSignature struct { + AuthorityID string `json:"authorityID"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type FrostRetainedGroupQuarantineLiftCertificate struct { + Schema string `json:"schema"` + Body FrostRetainedGroupQuarantineLiftBody `json:"body"` + BodyHash [32]byte `json:"bodyHash"` + Signatures []FrostRetainedGroupQuarantineLiftSignature `json:"signatures"` } type FrostRetainedGroupHistory struct { - From FrostPreSignFinality - To FrostPreSignFinality - Mutations []FrostRetainedGroupMutation - Complete bool - EmptyAtFrom bool - DescriptorSetHash [32]byte + From FrostPreSignFinality + To FrostPreSignFinality + Mutations []FrostRetainedGroupMutation + HistoryRoot [32]byte + CheckpointAfter FrostRetainedGroupCheckpointCursor + Checkpoints []FrostRetainedGroupCheckpointCertificate + CheckpointChainRoot [32]byte + CheckpointTipHash [32]byte + CheckpointComplete bool + Complete bool + EmptyAtFrom bool + DescriptorSetHash [32]byte } // FrostRetainedGroupHistorySource is independent of the primary deployment @@ -164,6 +291,7 @@ type FrostRetainedGroupHistorySource interface { context.Context, FrostPreSignFinality, FrostPreSignFinality, + FrostRetainedGroupCheckpointCursor, ) (*FrostRetainedGroupHistory, error) ResolveOperatorID( context.Context, @@ -183,28 +311,510 @@ type FrostRetainedGroupCanonicalJournalManifest struct { SourceTrustDomainID string SourceEndpointFingerprint [32]byte SourceOperatorFingerprint [32]byte + SourceIdentity FrostRetainedGroupHistoryIdentity MinimumGeneration uint64 } type FrostRetainedGroupQuarantineJournalManifest struct { - ProtocolID [32]byte - StoreID string - StoreFingerprint [32]byte - ClusterFingerprint [32]byte - MinimumGeneration uint64 + ProtocolID [32]byte + LiftProtocolID [32]byte + TombstoneProtocolID [32]byte + CheckpointAuthorityThreshold uint64 + CheckpointAuthorities []FrostRetainedGroupAuthority + CheckpointMinimumSequence uint64 + CheckpointPredecessorHash [32]byte + LiftAuthorityThreshold uint64 + LiftAuthorities []FrostRetainedGroupAuthority + StoreID string + StoreFingerprint [32]byte + ClusterFingerprint [32]byte + MinimumGeneration uint64 +} + +type frostRetainedGroupQuarantineLiftPolicy struct { + ProtocolBindingHash [32]byte + ManifestHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + ChainID uint64 + DomainChainID [32]byte + GenesisBlockHash [32]byte + QuarantineProtocolID [32]byte + LiftProtocolID [32]byte + TombstoneProtocolID [32]byte + AuthoritySetHash [32]byte + AuthorityThreshold uint64 + Authorities []FrostRetainedGroupAuthority +} + +func frostRetainedGroupLiftAuthoritySetHash( + threshold uint64, + authorities []FrostRetainedGroupAuthority, +) ([32]byte, error) { + return frostRetainedGroupAuthoritySetHash( + frostRetainedGroupLiftAuthoritySetSchema, + threshold, + authorities, + ) +} + +func frostRetainedGroupAuthoritySetHash( + schema string, + threshold uint64, + authorities []FrostRetainedGroupAuthority, +) ([32]byte, error) { + if strings.TrimSpace(schema) == "" || threshold < 2 || len(authorities) < 3 || + threshold > uint64(len(authorities)) || + threshold <= uint64(len(authorities))/2 { + return [32]byte{}, fmt.Errorf( + "FROST retained-group authority set is not a production strict majority", + ) + } + seenHashes := make(map[[32]byte]bool, len(authorities)) + previousID := "" + for index, authority := range authorities { + if !validFrostRetainedGroupAuthorityID(authority.AuthorityID) || + (index > 0 && authority.AuthorityID <= previousID) || + authority.PublicKeySPKIHash == [32]byte{} || + seenHashes[authority.PublicKeySPKIHash] { + return [32]byte{}, fmt.Errorf( + "FROST retained-group authority set is not strictly sorted and unique", + ) + } + previousID = authority.AuthorityID + seenHashes[authority.PublicKeySPKIHash] = true + } + type wireAuthority struct { + AuthorityID string `json:"authorityID"` + PublicKeySPKIHash string `json:"publicKeySpkiHash"` + } + wireAuthorities := make([]wireAuthority, len(authorities)) + for index, authority := range authorities { + wireAuthorities[index] = wireAuthority{ + AuthorityID: authority.AuthorityID, + PublicKeySPKIHash: frostActivationHex32(authority.PublicKeySPKIHash), + } + } + commitment := struct { + Schema string `json:"schema"` + Threshold uint64 `json:"threshold"` + Authorities []wireAuthority `json:"authorities"` + }{ + Schema: schema, + Threshold: threshold, + Authorities: wireAuthorities, + } + payload, err := frostRetainedGroupCanonicalValue(commitment) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftAuthorityDomain)) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func validFrostRetainedGroupAuthorityID(value string) bool { + if value == "" || len(value) > 64 { + return false + } + for index := range value { + character := value[index] + if !((character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + (index > 0 && (character == '-' || character == '_'))) { + return false + } + } + return true +} + +func frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) (frostRetainedGroupQuarantineLiftPolicy, error) { + quarantine := runtimeManifest.QuarantineJournal + liftAuthoritySetHash, err := frostRetainedGroupLiftAuthoritySetHash( + quarantine.LiftAuthorityThreshold, + quarantine.LiftAuthorities, + ) + if err != nil { + return frostRetainedGroupQuarantineLiftPolicy{}, err + } + if _, err := frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + quarantine.CheckpointAuthorityThreshold, + quarantine.CheckpointAuthorities, + ); err != nil { + return frostRetainedGroupQuarantineLiftPolicy{}, err + } + if bindingHash == [32]byte{} || + runtimeManifest.ManifestHash == [32]byte{} || + runtimeManifest.ProfileHash == [32]byte{} || + runtimeManifest.ImplementationSetHash == [32]byte{} || + runtimeManifest.DomainChainID == [32]byte{} || + runtimeManifest.GenesisBlockHash == [32]byte{} || + quarantine.ProtocolID == [32]byte{} || + quarantine.LiftProtocolID == [32]byte{} || + quarantine.TombstoneProtocolID == [32]byte{} || + quarantine.ProtocolID == quarantine.LiftProtocolID || + quarantine.ProtocolID == quarantine.TombstoneProtocolID || + quarantine.LiftProtocolID == quarantine.TombstoneProtocolID { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group lift policy is incomplete", + ) + } + for _, value := range runtimeManifest.DomainChainID[:24] { + if value != 0 { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group lift chain ID exceeds uint64", + ) + } + } + chainID := binary.BigEndian.Uint64(runtimeManifest.DomainChainID[24:]) + if chainID == 0 { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group lift chain ID is zero", + ) + } + forbidden := map[[32]byte]string{ + runtimeManifest.ActivationAuthorityKeyHash: "activation", + runtimeManifest.AttestationSignerKeyHash: "runtime attestation", + runtimeManifest.HandshakeOperatorFingerprint: "runtime exporter", + runtimeManifest.CanonicalJournal.SourceOperatorFingerprint: "retained history source", + runtimeManifest.VerifierOperatorFingerprint: "history verifier", + runtimeManifest.CanonicalJournal.SourceIdentity.HistorySignerKeyHash: "retained history signer", + runtimeManifest.CanonicalJournal.SourceIdentity.Export.TLSLeafSPKIHash: "retained export TLS leaf", + runtimeManifest.CanonicalJournal.SourceIdentity.Verifier.TLSLeafSPKIHash: "retained verifier TLS leaf", + runtimeManifest.CanonicalJournal.SourceIdentity.Export.BackendServiceFingerprint: "retained export backend", + runtimeManifest.CanonicalJournal.SourceIdentity.Verifier.BackendServiceFingerprint: "retained verifier backend", + runtimeManifest.CanonicalJournal.SourceIdentity.Export.AttestationKeyHash: "retained export attestation", + runtimeManifest.CanonicalJournal.SourceIdentity.Verifier.AttestationKeyHash: "retained verifier attestation", + } + for hash, role := range forbidden { + if hash == [32]byte{} { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group %s role identity is unavailable", + role, + ) + } + } + for _, authority := range quarantine.CheckpointAuthorities { + if role, exists := forbidden[authority.PublicKeySPKIHash]; exists { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST checkpoint authority aliases the %s role", + role, + ) + } + forbidden[authority.PublicKeySPKIHash] = "checkpoint authority" + } + for _, authority := range quarantine.LiftAuthorities { + if role, exists := forbidden[authority.PublicKeySPKIHash]; exists { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST quarantine lift authority aliases the %s role", + role, + ) + } + forbidden[authority.PublicKeySPKIHash] = "quarantine lift authority" + } + return frostRetainedGroupQuarantineLiftPolicy{ + ProtocolBindingHash: bindingHash, + ManifestHash: runtimeManifest.ManifestHash, + ProfileHash: runtimeManifest.ProfileHash, + ImplementationSetHash: runtimeManifest.ImplementationSetHash, + ChainID: chainID, + DomainChainID: runtimeManifest.DomainChainID, + GenesisBlockHash: runtimeManifest.GenesisBlockHash, + QuarantineProtocolID: quarantine.ProtocolID, + LiftProtocolID: quarantine.LiftProtocolID, + TombstoneProtocolID: quarantine.TombstoneProtocolID, + AuthoritySetHash: liftAuthoritySetHash, + AuthorityThreshold: quarantine.LiftAuthorityThreshold, + Authorities: append( + []FrostRetainedGroupAuthority{}, + quarantine.LiftAuthorities..., + ), + }, nil +} + +func frostRetainedGroupLiftBodyHash( + body FrostRetainedGroupQuarantineLiftBody, +) ([32]byte, error) { + if body.Schema != frostRetainedGroupLiftBodySchema { + return [32]byte{}, fmt.Errorf( + "unsupported FROST quarantine lift body schema", + ) + } + wireCertificate := frostRetainedGroupLiftCertificateToWire( + &FrostRetainedGroupQuarantineLiftCertificate{Body: body}, + ) + if wireCertificate == nil { + return [32]byte{}, fmt.Errorf( + "cannot project FROST quarantine lift body to its wire representation", + ) + } + payload, err := frostRetainedGroupCanonicalValue(wireCertificate.Body) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftBodyDomain)) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func frostRetainedGroupLiftSignatureHash(bodyHash [32]byte) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftSignatureDomain)) + hasher.Write(bodyHash[:]) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostRetainedGroupLiftCertificateHash( + certificate FrostRetainedGroupQuarantineLiftCertificate, +) ([32]byte, error) { + wireCertificate := frostRetainedGroupLiftCertificateToWire(&certificate) + if wireCertificate == nil { + return [32]byte{}, fmt.Errorf( + "cannot project FROST quarantine lift certificate to its wire representation", + ) + } + payload, err := frostRetainedGroupCanonicalValue(wireCertificate) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftCertificateDomain)) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func validateFrostRetainedGroupLiftCertificateShape( + policy frostRetainedGroupQuarantineLiftPolicy, + certificate *FrostRetainedGroupQuarantineLiftCertificate, +) ([32]byte, error) { + if certificate == nil || + certificate.Schema != frostRetainedGroupLiftCertificateSchema || + certificate.BodyHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate is absent or has an unsupported schema", + ) + } + body := certificate.Body + bodyHash, err := frostRetainedGroupLiftBodyHash(body) + if err != nil || bodyHash != certificate.BodyHash { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate body hash mismatch", + ) + } + if body.ProtocolBindingHash != policy.ProtocolBindingHash || + body.ManifestHash != policy.ManifestHash || + body.ProfileHash != policy.ProfileHash || + body.ImplementationSetHash != policy.ImplementationSetHash || + body.ChainID != policy.ChainID || + body.DomainChainID != policy.DomainChainID || + body.GenesisBlockHash != policy.GenesisBlockHash || + body.QuarantineProtocolID != policy.QuarantineProtocolID || + body.LiftProtocolID != policy.LiftProtocolID || + body.TombstoneProtocolID != policy.TombstoneProtocolID || + body.AuthoritySetHash != policy.AuthoritySetHash { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate differs from the signed production policy", + ) + } + raised := body.OriginalRaisedRecord + if body.QuarantineID == [32]byte{} || + body.WalletID == [32]byte{} || + raised.QuarantineID != body.QuarantineID || + raised.WalletID != body.WalletID || + raised.EvidenceHash == [32]byte{} || + strings.TrimSpace(raised.Reason) == "" || + len(raised.Reason) > frostRetainedGroupMaximumReasonBytes || + !raised.RaisedAt.valid() || + body.PriorGeneration == 0 || + body.PriorEventRoot == [32]byte{} || + body.PriorActiveRoot == [32]byte{} || + body.PriorTombstoneRoot == [32]byte{} || + !body.LiftPoint.valid() || + body.ResolutionEvidenceHash == [32]byte{} || + body.ResolutionFinality.BlockNumber == 0 || + body.ResolutionFinality.BlockHash == [32]byte{} || + body.ResolutionFinality.BlockNumber < raised.RaisedAt.BlockNumber || + (body.ResolutionFinality.BlockNumber == raised.RaisedAt.BlockNumber && + body.ResolutionFinality.BlockHash != raised.RaisedAt.BlockHash) || + body.ResolutionFinality.BlockNumber > body.LiftPoint.BlockNumber || + (body.ResolutionFinality.BlockNumber == body.LiftPoint.BlockNumber && + body.ResolutionFinality.BlockHash != body.LiftPoint.BlockHash) || + body.NotBeforeBlock == 0 || + body.ExpiresAtBlock < body.NotBeforeBlock || + body.LiftPoint.BlockNumber < body.NotBeforeBlock || + body.LiftPoint.BlockNumber > body.ExpiresAtBlock || + body.ResolutionFinality.BlockNumber > body.ExpiresAtBlock || + body.ChainID > frostRetainedGroupMaximumCanonicalJSONInteger || + raised.RaisedAt.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.PriorGeneration > frostRetainedGroupMaximumCanonicalJSONInteger || + body.LiftPoint.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.ResolutionFinality.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.NotBeforeBlock > frostRetainedGroupMaximumCanonicalJSONInteger || + body.ExpiresAtBlock > frostRetainedGroupMaximumCanonicalJSONInteger { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate body is incomplete or outside its canonical block window", + ) + } + if uint64(len(certificate.Signatures)) < policy.AuthorityThreshold || + len(certificate.Signatures) > len(policy.Authorities) { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate does not carry the required quorum", + ) + } + authorityByID := make( + map[string]FrostRetainedGroupAuthority, + len(policy.Authorities), + ) + for _, authority := range policy.Authorities { + authorityByID[authority.AuthorityID] = authority + } + signatureHash := frostRetainedGroupLiftSignatureHash(bodyHash) + previousID := "" + for index, signature := range certificate.Signatures { + if !validFrostRetainedGroupAuthorityID(signature.AuthorityID) || + (index > 0 && signature.AuthorityID <= previousID) { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift signatures are not strictly sorted and unique", + ) + } + previousID = signature.AuthorityID + authority, known := authorityByID[signature.AuthorityID] + if !known { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate contains an unknown authority [%s]", + signature.AuthorityID, + ) + } + if len(signature.SignerPublicKeySPKI) > 2048 || + len(signature.Signature) > 128 { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] credential exceeds its bound", + signature.AuthorityID, + ) + } + publicKeyDER, err := base64.StdEncoding.Strict().DecodeString( + signature.SignerPublicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 || + base64.StdEncoding.EncodeToString(publicKeyDER) != + signature.SignerPublicKeySPKI || + sha256.Sum256(publicKeyDER) != authority.PublicKeySPKIHash { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] supplied an unpinned key", + signature.AuthorityID, + ) + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot parse FROST quarantine lift authority [%s] key: [%w]", + signature.AuthorityID, + err, + ) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] key is not Ed25519", + signature.AuthorityID, + ) + } + if err := validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + publicKey, + ); err != nil { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] key is not a nonidentity prime-order Ed25519 point: [%w]", + signature.AuthorityID, + err, + ) + } + signatureBytes, err := base64.StdEncoding.Strict().DecodeString( + signature.Signature, + ) + if err != nil || len(signatureBytes) != ed25519.SignatureSize || + base64.StdEncoding.EncodeToString(signatureBytes) != + signature.Signature || + !ed25519.Verify(publicKey, signatureHash[:], signatureBytes) { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] signature is invalid", + signature.AuthorityID, + ) + } + } + certificateHash, err := frostRetainedGroupLiftCertificateHash(*certificate) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot hash FROST quarantine lift certificate: [%w]", + err, + ) + } + if certificateHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate hash is zero", + ) + } + return certificateHash, nil +} + +func validateFrostRetainedGroupLiftCertificate( + policy frostRetainedGroupQuarantineLiftPolicy, + state frostRetainedGroupQuarantineJournalState, + mutation FrostRetainedGroupMutation, + quarantine frostRetainedGroupQuarantineState, +) ([32]byte, error) { + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + policy, + mutation.LiftCertificate, + ) + if err != nil { + return [32]byte{}, err + } + body := mutation.LiftCertificate.Body + if mutation.Kind != FrostRetainedGroupQuarantineLiftMutation || + mutation.QuarantineID != body.QuarantineID || + mutation.WalletID != body.WalletID || + mutation.Point != body.LiftPoint || + mutation.LiftCertificateHash != certificateHash || + quarantine.Status != frostRetainedGroupQuarantineActive || + quarantine.RaisedRecord != body.OriginalRaisedRecord || + state.Generation != body.PriorGeneration || + state.Root != body.PriorEventRoot || + state.ActiveRoot != body.PriorActiveRoot || + state.TombstoneRoot != body.PriorTombstoneRoot { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate does not bind the exact active durable state", + ) + } + return certificateHash, nil } type frostRetainedGroupJournalMetadata struct { - Schema string `json:"schema"` - ManifestHash [32]byte `json:"manifestHash"` - StoreID string `json:"storeID"` - StoreFingerprint [32]byte `json:"storeFingerprint"` - ClusterFingerprint [32]byte `json:"clusterFingerprint"` - Checkpoint FrostPreSignFinality `json:"checkpoint"` - DescriptorSetHash [32]byte `json:"descriptorSetHash"` - SourceTrustDomainID string `json:"sourceTrustDomainID"` - SourceEndpointFingerprint [32]byte `json:"sourceEndpointFingerprint"` - SourceOperatorFingerprint [32]byte `json:"sourceOperatorFingerprint"` + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` + StoreID string `json:"storeID"` + StoreFingerprint [32]byte `json:"storeFingerprint"` + ClusterFingerprint [32]byte `json:"clusterFingerprint"` + Checkpoint FrostPreSignFinality `json:"checkpoint"` + DescriptorSetHash [32]byte `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint [32]byte `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint [32]byte `json:"sourceOperatorFingerprint"` + SourceIdentity FrostRetainedGroupHistoryIdentity `json:"sourceIdentity"` } type frostRetainedGroupWalletState struct { @@ -222,16 +832,24 @@ type frostRetainedGroupWalletState struct { } type frostRetainedGroupQuarantineState struct { - QuarantineID [32]byte `json:"quarantineID"` - WalletID [32]byte `json:"walletID"` - EvidenceHash [32]byte `json:"evidenceHash"` - Reason string `json:"reason"` - RecoveryRequired bool `json:"recoveryRequired"` - RaisedAt FrostRetainedGroupEventPoint `json:"raisedAt"` + RaisedRecord FrostRetainedGroupQuarantineRaisedRecord `json:"raisedRecord"` + Status string `json:"status"` + LiftCertificateHash [32]byte `json:"liftCertificateHash,omitempty"` + LiftedAt FrostRetainedGroupEventPoint `json:"liftedAt,omitempty"` +} + +type frostRetainedGroupQuarantineTombstone struct { + QuarantineID [32]byte `json:"quarantineID"` + WalletID [32]byte `json:"walletID"` + LiftCertificateHash [32]byte `json:"liftCertificateHash"` + LiftedAt FrostRetainedGroupEventPoint `json:"liftedAt"` + ResolutionEvidenceHash [32]byte `json:"resolutionEvidenceHash"` + ResolutionFinality FrostPreSignFinality `json:"resolutionFinality"` } type frostRetainedGroupJournalState struct { Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` BatchSequence uint64 `json:"batchSequence"` CurrentPoint FrostPreSignFinality `json:"currentPoint"` SnapshotGeneration uint64 `json:"snapshotGeneration"` @@ -242,6 +860,7 @@ type frostRetainedGroupJournalState struct { type frostRetainedGroupJournalBatch struct { Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` Sequence uint64 `json:"sequence"` From FrostPreSignFinality `json:"from"` To FrostPreSignFinality `json:"to"` @@ -251,27 +870,38 @@ type frostRetainedGroupJournalBatch struct { } type frostRetainedGroupQuarantineMetadata struct { - Schema string `json:"schema"` - ManifestHash [32]byte `json:"manifestHash"` - ProtocolID [32]byte `json:"protocolID"` - StoreID string `json:"storeID"` - StoreFingerprint [32]byte `json:"storeFingerprint"` - ClusterFingerprint [32]byte `json:"clusterFingerprint"` - Checkpoint FrostPreSignFinality `json:"checkpoint"` + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` + ProtocolID [32]byte `json:"protocolID"` + LiftProtocolID [32]byte `json:"liftProtocolID"` + TombstoneProtocolID [32]byte `json:"tombstoneProtocolID"` + LiftAuthoritySetHash [32]byte `json:"liftAuthoritySetHash"` + LiftAuthorityThreshold uint64 `json:"liftAuthorityThreshold"` + LiftAuthorities []FrostRetainedGroupAuthority `json:"liftAuthorities"` + StoreID string `json:"storeID"` + StoreFingerprint [32]byte `json:"storeFingerprint"` + ClusterFingerprint [32]byte `json:"clusterFingerprint"` + Checkpoint FrostPreSignFinality `json:"checkpoint"` } type frostRetainedGroupQuarantineJournalState struct { - Schema string `json:"schema"` - BatchSequence uint64 `json:"batchSequence"` - CurrentPoint FrostPreSignFinality `json:"currentPoint"` - Generation uint64 `json:"generation"` - BatchRoot [32]byte `json:"batchRoot"` - Root [32]byte `json:"root"` - Quarantines []frostRetainedGroupQuarantineState `json:"quarantines"` + Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` + BatchSequence uint64 `json:"batchSequence"` + CurrentPoint FrostPreSignFinality `json:"currentPoint"` + Generation uint64 `json:"generation"` + BatchRoot [32]byte `json:"batchRoot"` + Root [32]byte `json:"root"` + ActiveRoot [32]byte `json:"activeRoot"` + TombstoneRoot [32]byte `json:"tombstoneRoot"` + Quarantines []frostRetainedGroupQuarantineState `json:"quarantines"` + Tombstones []frostRetainedGroupQuarantineTombstone `json:"tombstones"` } type frostRetainedGroupQuarantineJournalBatch struct { Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` Sequence uint64 `json:"sequence"` From FrostPreSignFinality `json:"from"` To FrostPreSignFinality `json:"to"` @@ -280,6 +910,27 @@ type frostRetainedGroupQuarantineJournalBatch struct { Checksum [32]byte `json:"checksum"` } +type frostRetainedGroupWireQuarantineJournalMutation struct { + Point frostRetainedGroupWireEventPoint `json:"point"` + Kind string `json:"kind"` + WalletID string `json:"walletID"` + QuarantineID string `json:"quarantineID"` + EvidenceHash string `json:"evidenceHash"` + LiftCertificateHash string `json:"liftCertificateHash"` + Reason string `json:"reason"` +} + +type frostRetainedGroupWireQuarantineJournalBatch struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + Sequence uint64 `json:"sequence"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` + PriorBatchRoot string `json:"priorBatchRoot"` + Mutations []frostRetainedGroupWireQuarantineJournalMutation `json:"mutations"` + Checksum string `json:"checksum"` +} + type frostRetainedGroupEnvelope struct { Payload json.RawMessage `json:"payload"` Checksum [32]byte `json:"checksum"` @@ -287,6 +938,7 @@ type frostRetainedGroupEnvelope struct { type frostRetainedGroupJournalSnapshot struct { Schema string + BindingHash [32]byte StoreID string StoreFingerprint [32]byte ClusterFingerprint [32]byte @@ -304,43 +956,84 @@ type frostRetainedGroupJournalSnapshot struct { QuarantineMinimumGeneration uint64 QuarantineGeneration uint64 QuarantineRoot [32]byte + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte QuarantineCount uint64 + QuarantineTombstoneCount uint64 + CheckpointMinimumSequence uint64 + CheckpointPredecessorHash [32]byte + CheckpointSequence uint64 + CheckpointCertificateHash [32]byte + CheckpointHistoryRoot [32]byte LocalSessionCount uint64 Complete bool } type frostRetainedGroupJournal struct { - mutex sync.Mutex - rootDirectory string - directory string - quarantineDirectory string - metadata frostRetainedGroupJournalMetadata - quarantineMetadata frostRetainedGroupQuarantineMetadata - minimumGeneration uint64 - quarantineMinimumGeneration uint64 - source FrostRetainedGroupHistorySource - walletRegistry *walletRegistry - operatorAddress chain.Address - lockFile *os.File - quarantineLockFile *os.File - state frostRetainedGroupJournalState - quarantineState frostRetainedGroupQuarantineJournalState - mutations []FrostRetainedGroupMutation - quarantineMutations []FrostRetainedGroupMutation - persistFailureHook func(string) error - closed bool + mutex sync.Mutex + rootDirectory string + directory string + quarantineDirectory string + checkpointDirectory string + metadata frostRetainedGroupJournalMetadata + quarantineMetadata frostRetainedGroupQuarantineMetadata + minimumGeneration uint64 + quarantineMinimumGeneration uint64 + source FrostRetainedGroupHistorySource + walletRegistry *walletRegistry + operatorAddress chain.Address + lockFile *os.File + quarantineLockFile *os.File + checkpointLockFile *os.File + state frostRetainedGroupJournalState + quarantineState frostRetainedGroupQuarantineJournalState + mutations []FrostRetainedGroupMutation + quarantineMutations []FrostRetainedGroupMutation + liftPolicy frostRetainedGroupQuarantineLiftPolicy + liftCertificates map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate + checkpointPolicy frostRetainedGroupCheckpointPolicy + checkpointState frostRetainedGroupCheckpointJournalState + checkpointCertificates map[uint64]FrostRetainedGroupCheckpointCertificate + checkpointHashes map[uint64][32]byte + persistFailureHook func(string) error + checkpointPersistFailureHook func(string) error + closed bool } func newFrostRetainedGroupJournal( directory string, - manifestHash [32]byte, - manifest FrostRetainedGroupCanonicalJournalManifest, - quarantineManifest FrostRetainedGroupQuarantineJournalManifest, + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, source FrostRetainedGroupHistorySource, walletRegistry *walletRegistry, operatorAddress chain.Address, ) (*frostRetainedGroupJournal, error) { - if strings.TrimSpace(directory) == "" || manifestHash == [32]byte{} || + manifest := runtimeManifest.CanonicalJournal + quarantineManifest := runtimeManifest.QuarantineJournal + liftPolicy, liftPolicyErr := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if liftPolicyErr != nil { + return nil, fmt.Errorf( + "invalid FROST retained-group quarantine lift policy: [%w]", + liftPolicyErr, + ) + } + checkpointPolicy, checkpointPolicyErr := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if checkpointPolicyErr != nil { + return nil, fmt.Errorf( + "invalid FROST retained-group checkpoint policy: [%w]", + checkpointPolicyErr, + ) + } + if strings.TrimSpace(directory) == "" || + runtimeManifest.ManifestHash == [32]byte{} || + bindingHash == [32]byte{} || strings.TrimSpace(manifest.StoreID) == "" || manifest.StoreFingerprint == [32]byte{} || manifest.ClusterFingerprint == [32]byte{} || @@ -349,7 +1042,13 @@ func newFrostRetainedGroupJournal( strings.TrimSpace(manifest.SourceTrustDomainID) == "" || manifest.SourceEndpointFingerprint == [32]byte{} || manifest.SourceOperatorFingerprint == [32]byte{} || + validateFrostRetainedGroupHistoryIdentity(manifest.SourceIdentity) != nil || + manifest.SourceTrustDomainID != manifest.SourceIdentity.TrustDomainID || + manifest.SourceEndpointFingerprint != manifest.SourceIdentity.EndpointFingerprint || + manifest.SourceOperatorFingerprint != manifest.SourceIdentity.OperatorFingerprint || quarantineManifest.ProtocolID == [32]byte{} || + quarantineManifest.LiftProtocolID == [32]byte{} || + quarantineManifest.TombstoneProtocolID == [32]byte{} || strings.TrimSpace(quarantineManifest.StoreID) == "" || quarantineManifest.StoreFingerprint == [32]byte{} || quarantineManifest.ClusterFingerprint == [32]byte{} || @@ -375,7 +1074,12 @@ func newFrostRetainedGroupJournal( } canonicalDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupCanonicalDirectory) quarantineDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupQuarantineDirectory) - for _, child := range []string{canonicalDirectory, quarantineDirectory} { + checkpointDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupCheckpointDirectory) + for _, child := range []string{ + canonicalDirectory, + quarantineDirectory, + checkpointDirectory, + } { if err := os.MkdirAll(child, 0700); err != nil { return nil, fmt.Errorf("cannot create FROST retained-group journal store: [%w]", err) } @@ -396,13 +1100,25 @@ func newFrostRetainedGroupJournal( _ = lockFile.Close() return nil, err } + checkpointLockFile, err := acquireFrostRetainedGroupJournalLock( + checkpointDirectory, + ) + if err != nil { + _ = unix.Flock(int(quarantineLockFile.Fd()), unix.LOCK_UN) + _ = quarantineLockFile.Close() + _ = unix.Flock(int(lockFile.Fd()), unix.LOCK_UN) + _ = lockFile.Close() + return nil, err + } journal := &frostRetainedGroupJournal{ rootDirectory: cleanRootDirectory, directory: canonicalDirectory, quarantineDirectory: quarantineDirectory, + checkpointDirectory: checkpointDirectory, metadata: frostRetainedGroupJournalMetadata{ Schema: frostRetainedGroupJournalMetadataSchema, - ManifestHash: manifestHash, + ManifestHash: runtimeManifest.ManifestHash, + BindingHash: bindingHash, StoreID: manifest.StoreID, StoreFingerprint: manifest.StoreFingerprint, ClusterFingerprint: manifest.ClusterFingerprint, @@ -411,11 +1127,21 @@ func newFrostRetainedGroupJournal( SourceTrustDomainID: manifest.SourceTrustDomainID, SourceEndpointFingerprint: manifest.SourceEndpointFingerprint, SourceOperatorFingerprint: manifest.SourceOperatorFingerprint, + SourceIdentity: manifest.SourceIdentity, }, quarantineMetadata: frostRetainedGroupQuarantineMetadata{ - Schema: frostRetainedGroupQuarantineMetadataSchema, - ManifestHash: manifestHash, - ProtocolID: quarantineManifest.ProtocolID, + Schema: frostRetainedGroupQuarantineMetadataSchema, + ManifestHash: runtimeManifest.ManifestHash, + BindingHash: bindingHash, + ProtocolID: quarantineManifest.ProtocolID, + LiftProtocolID: quarantineManifest.LiftProtocolID, + TombstoneProtocolID: quarantineManifest.TombstoneProtocolID, + LiftAuthoritySetHash: liftPolicy.AuthoritySetHash, + LiftAuthorityThreshold: liftPolicy.AuthorityThreshold, + LiftAuthorities: append( + []FrostRetainedGroupAuthority{}, + liftPolicy.Authorities..., + ), StoreID: quarantineManifest.StoreID, StoreFingerprint: quarantineManifest.StoreFingerprint, ClusterFingerprint: quarantineManifest.ClusterFingerprint, @@ -428,6 +1154,12 @@ func newFrostRetainedGroupJournal( operatorAddress: operatorAddress, lockFile: lockFile, quarantineLockFile: quarantineLockFile, + checkpointLockFile: checkpointLockFile, + liftPolicy: liftPolicy, + liftCertificates: make(map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate), + checkpointPolicy: checkpointPolicy, + checkpointCertificates: make(map[uint64]FrostRetainedGroupCheckpointCertificate), + checkpointHashes: make(map[uint64][32]byte), } if err := journal.initialize(); err != nil { _ = journal.close() @@ -444,7 +1176,8 @@ func validateFrostRetainedGroupJournalRoot(directory string) error { for _, entry := range entries { if entry.Type()&os.ModeSymlink != 0 || (entry.Name() != frostRetainedGroupCanonicalDirectory && - entry.Name() != frostRetainedGroupQuarantineDirectory) || + entry.Name() != frostRetainedGroupQuarantineDirectory && + entry.Name() != frostRetainedGroupCheckpointDirectory) || !entry.IsDir() { return fmt.Errorf("unsafe entry in FROST retained-group journal root: [%s]", entry.Name()) } @@ -493,7 +1226,11 @@ func (frgj *frostRetainedGroupJournal) close() error { } frgj.closed = true var result error - for _, lock := range []*os.File{frgj.quarantineLockFile, frgj.lockFile} { + for _, lock := range []*os.File{ + frgj.checkpointLockFile, + frgj.quarantineLockFile, + frgj.lockFile, + } { if lock == nil { continue } @@ -506,6 +1243,7 @@ func (frgj *frostRetainedGroupJournal) close() error { } frgj.lockFile = nil frgj.quarantineLockFile = nil + frgj.checkpointLockFile = nil return result } @@ -513,6 +1251,14 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.verifyHistorySourceIdentity(context.Background()); err != nil { return err } + if err := recoverFrostRetainedGroupJournalTemporaryFiles( + frgj.directory, + ); err != nil { + return fmt.Errorf( + "cannot recover interrupted FROST retained-group journal persistence: [%w]", + err, + ) + } entries, err := os.ReadDir(frgj.directory) if err != nil { @@ -538,8 +1284,6 @@ func (frgj *frostRetainedGroupJournal) initialize() error { case strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) && strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): batchNames = append(batchNames, name) - case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): - return fmt.Errorf("interrupted FROST retained-group journal temp is present: [%s]", name) default: return fmt.Errorf("unexpected file in FROST retained-group journal: [%s]", name) } @@ -550,6 +1294,11 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.readEnvelope(frostRetainedGroupJournalMetadataFile, &stored); err != nil { return fmt.Errorf("cannot read FROST retained-group journal metadata: [%w]", err) } + if stored.Schema == frostRetainedGroupJournalMetadataSchemaV1 || + stored.Schema == frostRetainedGroupJournalMetadataSchemaV2 || + stored.Schema == frostRetainedGroupJournalMetadataSchemaV3 { + return frostRetainedGroupLegacySchemaError("canonical metadata") + } if stored != frgj.metadata { return fmt.Errorf("FROST retained-group journal metadata differs from signed manifest") } @@ -564,6 +1313,7 @@ func (frgj *frostRetainedGroupJournal) initialize() error { initial := frostRetainedGroupJournalState{ Schema: frostRetainedGroupJournalStateSchema, + BindingHash: frgj.metadata.BindingHash, CurrentPoint: frgj.metadata.Checkpoint, Wallets: []frostRetainedGroupWalletState{}, } @@ -577,6 +1327,10 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.readEnvelope(frostRetainedGroupJournalStateFile, &stored); err != nil { return fmt.Errorf("cannot read FROST retained-group journal state: [%w]", err) } + if stored.Schema == frostRetainedGroupJournalStateSchemaV1 || + stored.Schema == frostRetainedGroupJournalStateSchemaV2 { + return frostRetainedGroupLegacySchemaError("canonical state") + } if stored.Schema != frostRetainedGroupJournalStateSchema { return fmt.Errorf("unsupported FROST retained-group journal state schema") } @@ -632,7 +1386,10 @@ func (frgj *frostRetainedGroupJournal) initialize() error { return fmt.Errorf("cannot integrate orphan FROST retained-group batch: [%w]", err) } } - return frgj.initializeQuarantine() + if err := frgj.initializeQuarantine(); err != nil { + return err + } + return frgj.initializeCheckpointJournal() } func (frgj *frostRetainedGroupJournal) verifyHistorySourceIdentity( @@ -644,7 +1401,8 @@ func (frgj *frostRetainedGroupJournal) verifyHistorySourceIdentity( } if identity.TrustDomainID != frgj.metadata.SourceTrustDomainID || identity.EndpointFingerprint != frgj.metadata.SourceEndpointFingerprint || - identity.OperatorFingerprint != frgj.metadata.SourceOperatorFingerprint { + identity.OperatorFingerprint != frgj.metadata.SourceOperatorFingerprint || + identity != frgj.metadata.SourceIdentity { return fmt.Errorf("FROST retained-group history source identity differs from signed manifest") } return nil @@ -669,6 +1427,14 @@ func equalFrostRetainedGroupStates( } func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { + if err := recoverFrostRetainedGroupJournalTemporaryFiles( + frgj.quarantineDirectory, + ); err != nil { + return fmt.Errorf( + "cannot recover interrupted FROST retained-group quarantine persistence: [%w]", + err, + ) + } entries, err := os.ReadDir(frgj.quarantineDirectory) if err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine journal: [%w]", err) @@ -677,6 +1443,7 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { metadataExists := false stateExists := false batchNames := make([]string, 0) + certificateNames := make([]string, 0) for _, entry := range entries { name := entry.Name() if name == frostRetainedGroupJournalLockFile { @@ -693,8 +1460,9 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { case strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) && strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): batchNames = append(batchNames, name) - case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): - return fmt.Errorf("interrupted FROST retained-group quarantine temp is present: [%s]", name) + case strings.HasPrefix(name, frostRetainedGroupLiftCertificatePrefix) && + strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): + certificateNames = append(certificateNames, name) default: return fmt.Errorf("unexpected file in FROST retained-group quarantine journal: [%s]", name) } @@ -709,11 +1477,20 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine metadata: [%w]", err) } - if stored != frgj.quarantineMetadata { + if stored.Schema == frostRetainedGroupQuarantineMetadataV1 || + stored.Schema == frostRetainedGroupQuarantineMetadataV2 { + return frostRetainedGroupLegacySchemaError("quarantine metadata") + } + storedBytes, storedErr := frostRetainedGroupCanonicalValue(stored) + expectedBytes, expectedErr := frostRetainedGroupCanonicalValue( + frgj.quarantineMetadata, + ) + if storedErr != nil || expectedErr != nil || + !bytes.Equal(storedBytes, expectedBytes) { return fmt.Errorf("FROST retained-group quarantine metadata differs from signed manifest") } } else { - if stateExists || len(batchNames) != 0 { + if stateExists || len(batchNames) != 0 || len(certificateNames) != 0 { return fmt.Errorf("FROST retained-group quarantine journal has state without immutable metadata") } if err := persistFrostRetainedGroupEnvelopeAt( @@ -725,12 +1502,78 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { return fmt.Errorf("cannot persist FROST retained-group quarantine metadata: [%w]", err) } } + for _, name := range certificateNames { + wireCertificate := frostRetainedGroupWireQuarantineLiftCertificate{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + name, + &wireCertificate, + ); err != nil { + return fmt.Errorf( + "cannot read immutable FROST quarantine lift certificate [%s]: [%w]", + name, + err, + ) + } + certificate, err := frostRetainedGroupLiftCertificateFromWire( + &wireCertificate, + ) + if err != nil || certificate == nil { + return fmt.Errorf( + "cannot decode immutable FROST quarantine lift certificate [%s]: [%v]", + name, + err, + ) + } + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + frgj.liftPolicy, + certificate, + ) + if err != nil { + return fmt.Errorf( + "invalid immutable FROST quarantine lift certificate [%s]: [%w]", + name, + err, + ) + } + if name != frostRetainedGroupLiftCertificateFileName(certificateHash) { + return fmt.Errorf( + "immutable FROST quarantine lift certificate filename [%s] does not match its digest", + name, + ) + } + if _, exists := frgj.liftCertificates[certificateHash]; exists { + return fmt.Errorf( + "duplicate immutable FROST quarantine lift certificate [%s]", + name, + ) + } + frgj.liftCertificates[certificateHash] = *certificate + } + emptyActiveRoot, err := frostRetainedGroupQuarantineActiveRoot( + frgj.quarantineMetadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + return err + } + emptyTombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + frgj.quarantineMetadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + return err + } initial := frostRetainedGroupQuarantineJournalState{ - Schema: frostRetainedGroupQuarantineStateSchema, - CurrentPoint: frgj.quarantineMetadata.Checkpoint, - Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), - Quarantines: []frostRetainedGroupQuarantineState{}, + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: frgj.quarantineMetadata.BindingHash, + CurrentPoint: frgj.quarantineMetadata.Checkpoint, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + ActiveRoot: emptyActiveRoot, + TombstoneRoot: emptyTombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, } stored := initial if stateExists { @@ -741,6 +1584,10 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine state: [%w]", err) } + if stored.Schema == frostRetainedGroupQuarantineStateV1 || + stored.Schema == frostRetainedGroupQuarantineStateV2 { + return frostRetainedGroupLegacySchemaError("quarantine state") + } if stored.Schema != frostRetainedGroupQuarantineStateSchema { return fmt.Errorf("unsupported FROST retained-group quarantine state schema") } @@ -761,18 +1608,40 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { if name != frostRetainedGroupBatchFileName(expectedSequence) { return fmt.Errorf("FROST retained-group quarantine batch sequence has a gap at [%d]", expectedSequence) } - batch := frostRetainedGroupQuarantineJournalBatch{} + wireBatch := frostRetainedGroupWireQuarantineJournalBatch{} if err := readFrostRetainedGroupEnvelopeAt( frgj.quarantineDirectory, name, - &batch, + &wireBatch, ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) } + batch, err := frostRetainedGroupQuarantineBatchFromWire( + wireBatch, + frgj.liftCertificates, + ) + if err != nil { + return fmt.Errorf( + "cannot decode FROST retained-group quarantine batch [%d]: [%w]", + expectedSequence, + err, + ) + } if err := validateFrostRetainedGroupQuarantineBatch(batch, rebuilt); err != nil { return fmt.Errorf("invalid FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) } - if err := applyFrostRetainedGroupQuarantineMutations(&rebuilt, batch.Mutations); err != nil { + if err := frgj.validatePersistedLiftCertificates(batch.Mutations); err != nil { + return fmt.Errorf( + "invalid persisted FROST quarantine lift certificate in batch [%d]: [%w]", + expectedSequence, + err, + ) + } + if err := applyFrostRetainedGroupQuarantineMutations( + &rebuilt, + batch.Mutations, + frgj.liftPolicy, + ); err != nil { return fmt.Errorf("cannot replay FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) } rebuilt.BatchSequence = batch.Sequence @@ -826,11 +1695,315 @@ func frostRetainedGroupBatchFileName(sequence uint64) string { return fmt.Sprintf("%s%020d%s", frostRetainedGroupJournalBatchPrefix, sequence, frostRetainedGroupJournalFileSuffix) } +func frostRetainedGroupLiftCertificateFileName( + certificateHash [32]byte, +) string { + return fmt.Sprintf( + "%s%s%s", + frostRetainedGroupLiftCertificatePrefix, + hex.EncodeToString(certificateHash[:]), + frostRetainedGroupJournalFileSuffix, + ) +} + +func (frgj *frostRetainedGroupJournal) validatePersistedLiftCertificates( + mutations []FrostRetainedGroupMutation, +) error { + for _, mutation := range mutations { + if mutation.Kind != FrostRetainedGroupQuarantineLiftMutation { + continue + } + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + frgj.liftPolicy, + mutation.LiftCertificate, + ) + if err != nil { + return err + } + if mutation.LiftCertificateHash != certificateHash { + return fmt.Errorf( + "lift mutation certificate reference does not match its immutable certificate", + ) + } + stored, exists := frgj.liftCertificates[certificateHash] + if !exists { + return fmt.Errorf( + "immutable lift certificate [%x] is absent", + certificateHash, + ) + } + equal, err := equalFrostRetainedGroupLiftCertificates( + stored, + *mutation.LiftCertificate, + ) + if err != nil { + return fmt.Errorf( + "immutable lift certificate [%x] is not byte-identical: [%w]", + certificateHash, + err, + ) + } + if !equal { + return fmt.Errorf( + "immutable lift certificate [%x] is not byte-identical", + certificateHash, + ) + } + } + return nil +} + +func (frgj *frostRetainedGroupJournal) ensureLiftCertificatesPersisted( + mutations []FrostRetainedGroupMutation, +) error { + for _, mutation := range mutations { + if mutation.Kind != FrostRetainedGroupQuarantineLiftMutation { + continue + } + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + frgj.liftPolicy, + mutation.LiftCertificate, + ) + if err != nil { + return err + } + if mutation.LiftCertificateHash != certificateHash { + return fmt.Errorf( + "lift mutation certificate reference does not match its immutable certificate", + ) + } + if stored, exists := frgj.liftCertificates[certificateHash]; exists { + equal, err := equalFrostRetainedGroupLiftCertificates( + stored, + *mutation.LiftCertificate, + ) + if err != nil { + return fmt.Errorf( + "immutable lift certificate [%x] conflicts with a persisted certificate: [%w]", + certificateHash, + err, + ) + } + if !equal { + return fmt.Errorf( + "immutable lift certificate [%x] conflicts with a persisted certificate", + certificateHash, + ) + } + continue + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupLiftCertificateFileName(certificateHash), + frostRetainedGroupLiftCertificateToWire( + mutation.LiftCertificate, + ), + false, + ); err != nil { + return fmt.Errorf( + "cannot persist immutable FROST quarantine lift certificate [%x]: [%w]", + certificateHash, + err, + ) + } + certificate := *mutation.LiftCertificate + certificate.Signatures = append( + []FrostRetainedGroupQuarantineLiftSignature{}, + mutation.LiftCertificate.Signatures..., + ) + frgj.liftCertificates[certificateHash] = certificate + } + return nil +} + +func equalFrostRetainedGroupLiftCertificates( + left FrostRetainedGroupQuarantineLiftCertificate, + right FrostRetainedGroupQuarantineLiftCertificate, +) (bool, error) { + leftWire := frostRetainedGroupLiftCertificateToWire(&left) + rightWire := frostRetainedGroupLiftCertificateToWire(&right) + leftBytes, err := frostRetainedGroupCanonicalValue(leftWire) + if err != nil { + return false, err + } + rightBytes, err := frostRetainedGroupCanonicalValue(rightWire) + if err != nil { + return false, err + } + return bytes.Equal(leftBytes, rightBytes), nil +} + +func frostRetainedGroupQuarantineBatchToWire( + batch frostRetainedGroupQuarantineJournalBatch, +) (frostRetainedGroupWireQuarantineJournalBatch, error) { + mutations := make( + []frostRetainedGroupWireQuarantineJournalMutation, + len(batch.Mutations), + ) + for index, mutation := range batch.Mutations { + if !isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + return frostRetainedGroupWireQuarantineJournalBatch{}, fmt.Errorf( + "canonical inventory mutation cannot enter a quarantine batch", + ) + } + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + if mutation.LiftCertificateHash == [32]byte{} || + mutation.LiftCertificate == nil { + return frostRetainedGroupWireQuarantineJournalBatch{}, fmt.Errorf( + "quarantine lift batch mutation has no certificate reference", + ) + } + } else if mutation.LiftCertificateHash != [32]byte{} || + mutation.LiftCertificate != nil { + return frostRetainedGroupWireQuarantineJournalBatch{}, fmt.Errorf( + "non-lift quarantine batch mutation carries a certificate reference", + ) + } + mutations[index] = frostRetainedGroupWireQuarantineJournalMutation{ + Point: frostRetainedGroupEventPointToWire(mutation.Point), + Kind: string(mutation.Kind), + WalletID: frostActivationHex32(mutation.WalletID), + QuarantineID: frostActivationHex32(mutation.QuarantineID), + EvidenceHash: frostActivationHex32(mutation.EvidenceHash), + LiftCertificateHash: frostActivationHex32(mutation.LiftCertificateHash), + Reason: mutation.Reason, + } + } + return frostRetainedGroupWireQuarantineJournalBatch{ + Schema: batch.Schema, + BindingHash: frostActivationHex32(batch.BindingHash), + Sequence: batch.Sequence, + From: frostRetainedGroupFinalityToWire(batch.From), + To: frostRetainedGroupFinalityToWire(batch.To), + PriorBatchRoot: frostActivationHex32(batch.PriorBatchRoot), + Mutations: mutations, + Checksum: frostActivationHex32(batch.Checksum), + }, nil +} + +func frostRetainedGroupQuarantineBatchFromWire( + wire frostRetainedGroupWireQuarantineJournalBatch, + certificates map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate, +) (frostRetainedGroupQuarantineJournalBatch, error) { + bindingHash, err := parseFrostActivationHex32(wire.BindingHash) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + from, err := frostRetainedGroupFinalityFromWire(wire.From) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + to, err := frostRetainedGroupFinalityFromWire(wire.To) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + priorBatchRoot, err := parseFrostActivationHex32(wire.PriorBatchRoot) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + checksum, err := parseFrostActivationHex32(wire.Checksum) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + mutations := make([]FrostRetainedGroupMutation, len(wire.Mutations)) + for index, wireMutation := range wire.Mutations { + point, err := frostRetainedGroupEventPointFromWire(wireMutation.Point) + if err != nil || !point.valid() { + return frostRetainedGroupQuarantineJournalBatch{}, fmt.Errorf( + "quarantine batch mutation [%d] point is invalid", + index, + ) + } + walletID, err := parseFrostActivationHex32(wireMutation.WalletID) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + quarantineID, err := parseFrostActivationHex32( + wireMutation.QuarantineID, + ) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + evidenceHash, err := parseFrostActivationHex32(wireMutation.EvidenceHash) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + certificateHash, err := parseFrostActivationHex32( + wireMutation.LiftCertificateHash, + ) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + mutation := FrostRetainedGroupMutation{ + Point: point, + Kind: FrostRetainedGroupMutationKind(wireMutation.Kind), + WalletID: walletID, + QuarantineID: quarantineID, + EvidenceHash: evidenceHash, + LiftCertificateHash: certificateHash, + Reason: wireMutation.Reason, + } + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + certificate, exists := certificates[certificateHash] + if certificateHash == [32]byte{} || !exists { + return frostRetainedGroupQuarantineJournalBatch{}, fmt.Errorf( + "quarantine lift batch mutation [%d] references an absent certificate", + index, + ) + } + certificate.Signatures = append( + []FrostRetainedGroupQuarantineLiftSignature{}, + certificate.Signatures..., + ) + mutation.LiftCertificate = &certificate + } else if certificateHash != [32]byte{} { + return frostRetainedGroupQuarantineJournalBatch{}, fmt.Errorf( + "non-lift quarantine batch mutation [%d] references a certificate", + index, + ) + } + mutations[index] = mutation + } + return frostRetainedGroupQuarantineJournalBatch{ + Schema: wire.Schema, + BindingHash: bindingHash, + Sequence: wire.Sequence, + From: from, + To: to, + PriorBatchRoot: priorBatchRoot, + Mutations: mutations, + Checksum: checksum, + }, nil +} + +func frostRetainedGroupQuarantineBatchCanonicalValue( + batch frostRetainedGroupQuarantineJournalBatch, +) ([]byte, error) { + wire, err := frostRetainedGroupQuarantineBatchToWire(batch) + if err != nil { + return nil, err + } + return frostRetainedGroupCanonicalValue(wire) +} + +func frostRetainedGroupLegacySchemaError(component string) error { + return fmt.Errorf( + "prior FROST retained-group %s store schema is not safely migratable; "+ + "the signed activation manifest must provision a new empty v3 store identity", + component, + ) +} + func validateFrostRetainedGroupBatch( batch frostRetainedGroupJournalBatch, prior frostRetainedGroupJournalState, ) error { + if batch.Schema == frostRetainedGroupJournalBatchSchemaV1 || + batch.Schema == frostRetainedGroupJournalBatchSchemaV2 { + return frostRetainedGroupLegacySchemaError("canonical batch") + } if batch.Schema != frostRetainedGroupJournalBatchSchema || + batch.BindingHash == [32]byte{} || batch.BindingHash != prior.BindingHash || batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || batch.To.BlockHash == [32]byte{} || batch.Checksum == [32]byte{} { @@ -860,7 +2033,12 @@ func validateFrostRetainedGroupQuarantineBatch( batch frostRetainedGroupQuarantineJournalBatch, prior frostRetainedGroupQuarantineJournalState, ) error { + if batch.Schema == frostRetainedGroupQuarantineBatchV1 || + batch.Schema == frostRetainedGroupQuarantineBatchV2 { + return frostRetainedGroupLegacySchemaError("quarantine batch") + } if batch.Schema != frostRetainedGroupQuarantineBatchSchema || + batch.BindingHash == [32]byte{} || batch.BindingHash != prior.BindingHash || batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || batch.To.BlockHash == [32]byte{} || batch.Checksum == [32]byte{} { @@ -868,7 +2046,7 @@ func validateFrostRetainedGroupQuarantineBatch( } declared := batch.Checksum batch.Checksum = [32]byte{} - payload, err := frostRetainedGroupCanonicalValue(batch) + payload, err := frostRetainedGroupQuarantineBatchCanonicalValue(batch) if err != nil { return err } @@ -1075,6 +2253,53 @@ func validateFrostRetainedGroupJournalFileName(name string) error { name == frostRetainedGroupJournalStateFile { return nil } + if strings.HasPrefix(name, frostRetainedGroupCheckpointFilePrefix) { + checkpointText := strings.TrimSuffix( + strings.TrimPrefix(name, frostRetainedGroupCheckpointFilePrefix), + frostRetainedGroupJournalFileSuffix, + ) + sequenceText, digestText, separated := strings.Cut(checkpointText, "-") + digestBytes, digestErr := hex.DecodeString(digestText) + sequence, sequenceErr := strconv.ParseUint(sequenceText, 10, 64) + if !separated || len(sequenceText) != 20 || sequenceErr != nil || + sequence == 0 || digestErr != nil || len(digestBytes) != 32 { + return fmt.Errorf( + "noncanonical FROST retained-group checkpoint certificate name: [%s]", + name, + ) + } + digest := [32]byte{} + copy(digest[:], digestBytes) + if frostRetainedGroupCheckpointFileName(sequence, digest) != name { + return fmt.Errorf( + "noncanonical FROST retained-group checkpoint certificate name: [%s]", + name, + ) + } + return nil + } + if strings.HasPrefix(name, frostRetainedGroupLiftCertificatePrefix) { + digestText := strings.TrimSuffix( + strings.TrimPrefix(name, frostRetainedGroupLiftCertificatePrefix), + frostRetainedGroupJournalFileSuffix, + ) + digestBytes, err := hex.DecodeString(digestText) + if err != nil || len(digestBytes) != 32 { + return fmt.Errorf( + "noncanonical FROST retained-group lift certificate name: [%s]", + name, + ) + } + digest := [32]byte{} + copy(digest[:], digestBytes) + if frostRetainedGroupLiftCertificateFileName(digest) != name { + return fmt.Errorf( + "noncanonical FROST retained-group lift certificate name: [%s]", + name, + ) + } + return nil + } if !strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) || !strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix) { return fmt.Errorf("unsupported FROST retained-group journal file name: [%s]", name) @@ -1128,35 +2353,149 @@ func openFrostRetainedGroupJournalDirectory(directory string) (*os.File, error) return file, nil } -func validateFrostRetainedGroupJournalFileAt( - directoryDescriptor int, +func validateFrostRetainedGroupJournalFileAt( + directoryDescriptor int, + name string, +) (bool, error) { + var info unix.Stat_t + err := unix.Fstatat( + directoryDescriptor, + name, + &info, + unix.AT_SYMLINK_NOFOLLOW, + ) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if uint32(info.Mode)&unix.S_IFMT != unix.S_IFREG || + uint32(info.Mode)&0777 != 0600 { + return false, fmt.Errorf("journal destination is unsafe: [%s]", name) + } + if info.Uid != uint32(os.Geteuid()) { + return false, fmt.Errorf( + "Bitcoin broadcast storage is owned by uid [%d], expected [%d]", + info.Uid, + os.Geteuid(), + ) + } + return true, nil +} + +// recoverFrostRetainedGroupJournalTemporaryFiles removes interrupted +// pre-publication files after validating that they have exactly the private, +// owner-bound shape and unpredictable name emitted by persistFrostRetainedGroupEnvelopeAt. +// A temporary name is never a commit point: immutable files commit at Linkat +// and replaceable state commits at Renameat. The final namespace is replayed +// after this cleanup, so an already-linked immutable file is retained and an +// unpublished state file is deterministically rebuilt from its immutable +// prefix. +func recoverFrostRetainedGroupJournalTemporaryFiles( + directory string, +) error { + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + directoryFile, err := openFrostRetainedGroupJournalDirectory(directory) + if err != nil { + return err + } + defer directoryFile.Close() + directoryDescriptor := int(directoryFile.Fd()) + + removed := false + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix) { + continue + } + if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() { + return fmt.Errorf( + "interrupted journal temporary file is unsafe: [%s]", + name, + ) + } + if _, err := frostRetainedGroupJournalTemporaryFinalName(name); err != nil { + return err + } + exists, err := validateFrostRetainedGroupJournalFileAt( + directoryDescriptor, + name, + ) + if err != nil { + return fmt.Errorf( + "interrupted journal temporary file is unsafe [%s]: [%w]", + name, + err, + ) + } + if !exists { + return fmt.Errorf( + "interrupted journal temporary file disappeared: [%s]", + name, + ) + } + if err := unix.Unlinkat(directoryDescriptor, name, 0); err != nil { + return fmt.Errorf( + "cannot remove interrupted journal temporary file [%s]: [%w]", + name, + err, + ) + } + removed = true + } + if removed { + if err := directoryFile.Sync(); err != nil { + return fmt.Errorf( + "cannot sync interrupted journal temporary-file recovery: [%w]", + err, + ) + } + } + return nil +} + +func frostRetainedGroupJournalTemporaryFinalName( name string, -) (bool, error) { - var info unix.Stat_t - err := unix.Fstatat( - directoryDescriptor, - name, - &info, - unix.AT_SYMLINK_NOFOLLOW, - ) - if errors.Is(err, os.ErrNotExist) { - return false, nil +) (string, error) { + const entropyBytes = 16 + const entropyCharacters = entropyBytes * 2 + + if !strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix) { + return "", fmt.Errorf( + "interrupted journal temporary file name is invalid: [%s]", + name, + ) } - if err != nil { - return false, err + trimmed := strings.TrimSuffix(name, frostRetainedGroupJournalTempSuffix) + delimiterIndex := len(trimmed) - entropyCharacters - 1 + if delimiterIndex <= 0 || trimmed[delimiterIndex] != '-' { + return "", fmt.Errorf( + "interrupted journal temporary file name is invalid: [%s]", + name, + ) } - if uint32(info.Mode)&unix.S_IFMT != unix.S_IFREG || - uint32(info.Mode)&0777 != 0600 { - return false, fmt.Errorf("journal destination is unsafe: [%s]", name) + entropyText := trimmed[delimiterIndex+1:] + entropy, err := hex.DecodeString(entropyText) + if err != nil || len(entropy) != entropyBytes || + hex.EncodeToString(entropy) != entropyText { + return "", fmt.Errorf( + "interrupted journal temporary file name is invalid: [%s]", + name, + ) } - if info.Uid != uint32(os.Geteuid()) { - return false, fmt.Errorf( - "Bitcoin broadcast storage is owned by uid [%d], expected [%d]", - info.Uid, - os.Geteuid(), + finalName := trimmed[:delimiterIndex] + if err := validateFrostRetainedGroupJournalFileName(finalName); err != nil { + return "", fmt.Errorf( + "interrupted journal temporary destination is invalid [%s]: [%w]", + name, + err, ) } - return true, nil + return finalName, nil } func createFrostRetainedGroupJournalTemporaryFileAt( @@ -1224,13 +2563,26 @@ func applyFrostRetainedGroupMutations( if state == nil { return fmt.Errorf("FROST retained-group state is nil") } + if len(state.Wallets) > frostRetainedGroupMaximumWallets { + return fmt.Errorf("FROST retained-group wallet state exceeds the wallet limit") + } wallets := make(map[[32]byte]frostRetainedGroupWalletState, len(state.Wallets)) + publicKeyHashes := make( + map[[20]byte][32]byte, + len(state.Wallets), + ) for _, wallet := range state.Wallets { - if wallet.WalletID == [32]byte{} || wallets[wallet.WalletID].WalletID != [32]byte{} { + if wallet.WalletID == [32]byte{} || + wallet.WalletPublicKeyHash == [20]byte{} || + wallets[wallet.WalletID].WalletID != [32]byte{} { return fmt.Errorf("FROST retained-group wallet state is duplicate or invalid") } + if _, exists := publicKeyHashes[wallet.WalletPublicKeyHash]; exists { + return fmt.Errorf("FROST retained-group wallet state repeats a public-key hash") + } wallet.OperatorIDs = append([]uint32{}, wallet.OperatorIDs...) wallets[wallet.WalletID] = wallet + publicKeyHashes[wallet.WalletPublicKeyHash] = wallet.WalletID } var previous FrostRetainedGroupEventPoint for index, mutation := range mutations { @@ -1248,14 +2600,24 @@ func applyFrostRetainedGroupMutations( case FrostRetainedGroupAdmissionMutation: if mutation.WalletID == [32]byte{} || mutation.WalletPublicKeyHash == [20]byte{} || len(mutation.OperatorIDs) < 51 || len(mutation.OperatorIDs) > 100 || - mutation.RetainedGroupHash == [32]byte{} || + mutation.RetainedGroupHash == [32]byte{} || mutation.DkgResultHash == [32]byte{} || + !mutation.DkgSubmissionPoint.valid() || !mutation.DkgApprovalPoint.valid() || !mutation.CreationPoint.valid() || !mutation.BridgeRegistrationPoint.valid() || mutation.Point != mutation.BridgeRegistrationPoint || + compareFrostRetainedGroupEventPoints(mutation.DkgSubmissionPoint, mutation.DkgApprovalPoint) >= 0 || + !sameFrostRetainedGroupTransaction(mutation.DkgApprovalPoint, mutation.CreationPoint) || + compareFrostRetainedGroupEventPoints(mutation.DkgApprovalPoint, mutation.CreationPoint) >= 0 || !sameFrostRetainedGroupTransaction(mutation.CreationPoint, mutation.BridgeRegistrationPoint) || compareFrostRetainedGroupEventPoints(mutation.CreationPoint, mutation.BridgeRegistrationPoint) >= 0 || wallet.WalletID != [32]byte{} || mutation.QuarantineID != [32]byte{} { return fmt.Errorf("invalid or duplicate FROST retained-group admission") } + if len(wallets) >= frostRetainedGroupMaximumWallets { + return fmt.Errorf("FROST retained-group admission exceeds the wallet limit") + } + if _, exists := publicKeyHashes[mutation.WalletPublicKeyHash]; exists { + return fmt.Errorf("FROST retained-group admission reuses a wallet public-key hash") + } for _, operatorID := range mutation.OperatorIDs { if operatorID == 0 { return fmt.Errorf("FROST retained-group admission contains zero operator ID") @@ -1272,6 +2634,7 @@ func applyFrostRetainedGroupMutations( LifecyclePoint: mutation.BridgeRegistrationPoint, LastBridgePoint: mutation.Point, } + publicKeyHashes[mutation.WalletPublicKeyHash] = mutation.WalletID state.SnapshotGeneration++ case FrostRetainedGroupMovingFundsMutation, FrostRetainedGroupClosingMutation, @@ -1368,6 +2731,7 @@ func frostRetainedGroupCanonicalMutations( func applyFrostRetainedGroupQuarantineMutations( state *frostRetainedGroupQuarantineJournalState, mutations []FrostRetainedGroupMutation, + policy frostRetainedGroupQuarantineLiftPolicy, ) error { if state == nil { return fmt.Errorf("FROST retained-group quarantine state is nil") @@ -1377,11 +2741,66 @@ func applyFrostRetainedGroupQuarantineMutations( len(state.Quarantines), ) for _, quarantine := range state.Quarantines { - if quarantine.QuarantineID == [32]byte{} || - quarantines[quarantine.QuarantineID].QuarantineID != [32]byte{} { + quarantineID := quarantine.RaisedRecord.QuarantineID + if quarantineID == [32]byte{} || + quarantine.RaisedRecord.WalletID == [32]byte{} || + quarantine.RaisedRecord.EvidenceHash == [32]byte{} || + strings.TrimSpace(quarantine.RaisedRecord.Reason) == "" || + !quarantine.RaisedRecord.RaisedAt.valid() || + (quarantine.Status != frostRetainedGroupQuarantineActive && + quarantine.Status != frostRetainedGroupQuarantineLifted) { return fmt.Errorf("FROST retained-group quarantine state is duplicate or invalid") } - quarantines[quarantine.QuarantineID] = quarantine + if _, exists := quarantines[quarantineID]; exists { + return fmt.Errorf("FROST retained-group quarantine state is duplicate or invalid") + } + quarantines[quarantineID] = quarantine + } + tombstones := make( + map[[32]byte]frostRetainedGroupQuarantineTombstone, + len(state.Tombstones), + ) + for _, tombstone := range state.Tombstones { + if tombstone.QuarantineID == [32]byte{} || + tombstone.WalletID == [32]byte{} || + tombstone.LiftCertificateHash == [32]byte{} || + !tombstone.LiftedAt.valid() || + tombstone.ResolutionEvidenceHash == [32]byte{} || + tombstone.ResolutionFinality.BlockNumber == 0 || + tombstone.ResolutionFinality.BlockHash == [32]byte{} { + return fmt.Errorf("FROST retained-group tombstone state is invalid") + } + if _, exists := tombstones[tombstone.QuarantineID]; exists { + return fmt.Errorf("FROST retained-group tombstone state is duplicate") + } + quarantine, exists := quarantines[tombstone.QuarantineID] + if !exists || quarantine.Status != frostRetainedGroupQuarantineLifted || + quarantine.RaisedRecord.WalletID != tombstone.WalletID || + quarantine.LiftCertificateHash != tombstone.LiftCertificateHash || + quarantine.LiftedAt != tombstone.LiftedAt { + return fmt.Errorf("FROST retained-group tombstone does not match its lifted record") + } + tombstones[tombstone.QuarantineID] = tombstone + } + for quarantineID, quarantine := range quarantines { + _, hasTombstone := tombstones[quarantineID] + if (quarantine.Status == frostRetainedGroupQuarantineLifted) != hasTombstone { + return fmt.Errorf("FROST retained-group lifted state and tombstones disagree") + } + } + activeRoot, err := frostRetainedGroupQuarantineActiveRoot( + state.BindingHash, + quarantines, + ) + if err != nil || activeRoot != state.ActiveRoot { + return fmt.Errorf("FROST retained-group active quarantine root mismatch") + } + tombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + state.BindingHash, + tombstones, + ) + if err != nil || tombstoneRoot != state.TombstoneRoot { + return fmt.Errorf("FROST retained-group quarantine tombstone root mismatch") } var previous FrostRetainedGroupEventPoint for index, mutation := range mutations { @@ -1399,39 +2818,257 @@ func applyFrostRetainedGroupQuarantineMutations( previous = mutation.Point switch mutation.Kind { case FrostRetainedGroupQuarantineMutation, FrostRetainedGroupRecoveryRequiredMutation: - if mutation.QuarantineID == [32]byte{} || mutation.EvidenceHash == [32]byte{} || + if mutation.QuarantineID == [32]byte{} || + mutation.WalletID == [32]byte{} || + mutation.EvidenceHash == [32]byte{} || strings.TrimSpace(mutation.Reason) == "" || - quarantines[mutation.QuarantineID].QuarantineID != [32]byte{} { + mutation.LiftCertificateHash != [32]byte{} || + mutation.LiftCertificate != nil || + !frostRetainedGroupQuarantineMutationInventoryFieldsEmpty(mutation) { + return fmt.Errorf("invalid or duplicate FROST retained-group quarantine") + } + if _, exists := quarantines[mutation.QuarantineID]; exists { return fmt.Errorf("invalid or duplicate FROST retained-group quarantine") } + if _, exists := tombstones[mutation.QuarantineID]; exists { + return fmt.Errorf("FROST retained-group quarantine ID has a permanent tombstone") + } quarantines[mutation.QuarantineID] = frostRetainedGroupQuarantineState{ - QuarantineID: mutation.QuarantineID, - WalletID: mutation.WalletID, - EvidenceHash: mutation.EvidenceHash, - Reason: mutation.Reason, - RecoveryRequired: mutation.Kind == FrostRetainedGroupRecoveryRequiredMutation, - RaisedAt: mutation.Point, + RaisedRecord: FrostRetainedGroupQuarantineRaisedRecord{ + QuarantineID: mutation.QuarantineID, + WalletID: mutation.WalletID, + EvidenceHash: mutation.EvidenceHash, + Reason: mutation.Reason, + RecoveryRequired: mutation.Kind == FrostRetainedGroupRecoveryRequiredMutation, + RaisedAt: mutation.Point, + }, + Status: frostRetainedGroupQuarantineActive, } case FrostRetainedGroupQuarantineLiftMutation: - quarantine := quarantines[mutation.QuarantineID] - if quarantine.QuarantineID == [32]byte{} || mutation.AuthenticationHash == [32]byte{} || - mutation.WalletID != quarantine.WalletID { - return fmt.Errorf("unauthenticated or unknown FROST retained-group quarantine lift") + quarantine, exists := quarantines[mutation.QuarantineID] + if !exists || mutation.EvidenceHash != [32]byte{} || + mutation.Reason != "" || + mutation.LiftCertificateHash == [32]byte{} || + !frostRetainedGroupQuarantineMutationInventoryFieldsEmpty(mutation) { + return fmt.Errorf("unknown or malformed FROST retained-group quarantine lift") + } + if _, exists := tombstones[mutation.QuarantineID]; exists { + return fmt.Errorf("FROST retained-group quarantine lift was already tombstoned") + } + certificateHash, err := validateFrostRetainedGroupLiftCertificate( + policy, + *state, + mutation, + quarantine, + ) + if err != nil { + return err } - delete(quarantines, mutation.QuarantineID) + body := mutation.LiftCertificate.Body + quarantine.Status = frostRetainedGroupQuarantineLifted + quarantine.LiftCertificateHash = certificateHash + quarantine.LiftedAt = mutation.Point + quarantines[mutation.QuarantineID] = quarantine + tombstones[mutation.QuarantineID] = + frostRetainedGroupQuarantineTombstone{ + QuarantineID: mutation.QuarantineID, + WalletID: mutation.WalletID, + LiftCertificateHash: certificateHash, + LiftedAt: mutation.Point, + ResolutionEvidenceHash: body.ResolutionEvidenceHash, + ResolutionFinality: body.ResolutionFinality, + } + } + if err := appendFrostRetainedGroupQuarantineRoot(state, mutation); err != nil { + return err + } + if err := setFrostRetainedGroupQuarantineCollections( + state, + quarantines, + tombstones, + ); err != nil { + return err } - appendFrostRetainedGroupQuarantineRoot(state, mutation) } - state.Quarantines = state.Quarantines[:0] + return setFrostRetainedGroupQuarantineCollections( + state, + quarantines, + tombstones, + ) +} + +func frostRetainedGroupQuarantineMutationInventoryFieldsEmpty( + mutation FrostRetainedGroupMutation, +) bool { + return mutation.WalletPublicKeyHash == [20]byte{} && + len(mutation.OperatorIDs) == 0 && + mutation.RetainedGroupHash == [32]byte{} && + mutation.DkgResultHash == [32]byte{} && + mutation.DkgSubmissionPoint == (FrostRetainedGroupEventPoint{}) && + mutation.DkgApprovalPoint == (FrostRetainedGroupEventPoint{}) && + mutation.CreationPoint == (FrostRetainedGroupEventPoint{}) && + mutation.BridgeRegistrationPoint == (FrostRetainedGroupEventPoint{}) +} + +func setFrostRetainedGroupQuarantineCollections( + state *frostRetainedGroupQuarantineJournalState, + quarantines map[[32]byte]frostRetainedGroupQuarantineState, + tombstones map[[32]byte]frostRetainedGroupQuarantineTombstone, +) error { + state.Quarantines = make( + []frostRetainedGroupQuarantineState, + 0, + len(quarantines), + ) for _, quarantine := range quarantines { state.Quarantines = append(state.Quarantines, quarantine) } sort.Slice(state.Quarantines, func(i, j int) bool { - return bytes.Compare(state.Quarantines[i].QuarantineID[:], state.Quarantines[j].QuarantineID[:]) < 0 + return bytes.Compare( + state.Quarantines[i].RaisedRecord.QuarantineID[:], + state.Quarantines[j].RaisedRecord.QuarantineID[:], + ) < 0 + }) + state.Tombstones = make( + []frostRetainedGroupQuarantineTombstone, + 0, + len(tombstones), + ) + for _, tombstone := range tombstones { + state.Tombstones = append(state.Tombstones, tombstone) + } + sort.Slice(state.Tombstones, func(i, j int) bool { + return bytes.Compare( + state.Tombstones[i].QuarantineID[:], + state.Tombstones[j].QuarantineID[:], + ) < 0 }) + activeRoot, err := frostRetainedGroupQuarantineActiveRoot( + state.BindingHash, + quarantines, + ) + if err != nil { + return err + } + tombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + state.BindingHash, + tombstones, + ) + if err != nil { + return err + } + state.ActiveRoot = activeRoot + state.TombstoneRoot = tombstoneRoot return nil } +func frostRetainedGroupQuarantineActiveRoot( + bindingHash [32]byte, + quarantines map[[32]byte]frostRetainedGroupQuarantineState, +) ([32]byte, error) { + active := make([]frostRetainedGroupWireQuarantineRaisedRecord, 0) + for _, quarantine := range quarantines { + if quarantine.Status == frostRetainedGroupQuarantineActive { + record := quarantine.RaisedRecord + active = append( + active, + frostRetainedGroupWireQuarantineRaisedRecord{ + QuarantineID: frostActivationHex32(record.QuarantineID), + WalletID: frostActivationHex32(record.WalletID), + EvidenceHash: frostActivationHex32(record.EvidenceHash), + Reason: record.Reason, + RecoveryRequired: record.RecoveryRequired, + RaisedAt: frostRetainedGroupEventPointToWire( + record.RaisedAt, + ), + }, + ) + } + } + sort.Slice(active, func(i, j int) bool { + return active[i].QuarantineID < active[j].QuarantineID + }) + return frostRetainedGroupCollectionRoot( + frostRetainedGroupQuarantineActiveDomain, + bindingHash, + active, + ) +} + +func frostRetainedGroupQuarantineTombstoneRoot( + bindingHash [32]byte, + tombstones map[[32]byte]frostRetainedGroupQuarantineTombstone, +) ([32]byte, error) { + type wireTombstone struct { + QuarantineID string `json:"quarantineID"` + WalletID string `json:"walletID"` + LiftCertificateHash string `json:"liftCertificateHash"` + LiftedAt frostRetainedGroupWireEventPoint `json:"liftedAt"` + ResolutionEvidenceHash string `json:"resolutionEvidenceHash"` + ResolutionFinality frostRetainedGroupWireFinality `json:"resolutionFinality"` + } + ordered := make( + []wireTombstone, + 0, + len(tombstones), + ) + for _, tombstone := range tombstones { + ordered = append(ordered, wireTombstone{ + QuarantineID: frostActivationHex32(tombstone.QuarantineID), + WalletID: frostActivationHex32(tombstone.WalletID), + LiftCertificateHash: frostActivationHex32(tombstone.LiftCertificateHash), + LiftedAt: frostRetainedGroupEventPointToWire(tombstone.LiftedAt), + ResolutionEvidenceHash: frostActivationHex32(tombstone.ResolutionEvidenceHash), + ResolutionFinality: frostRetainedGroupFinalityToWire( + tombstone.ResolutionFinality, + ), + }) + } + sort.Slice(ordered, func(i, j int) bool { + return ordered[i].QuarantineID < ordered[j].QuarantineID + }) + return frostRetainedGroupCollectionRoot( + frostRetainedGroupTombstoneRootDomain, + bindingHash, + ordered, + ) +} + +func frostRetainedGroupCollectionRoot( + domain string, + bindingHash [32]byte, + collection interface{}, +) ([32]byte, error) { + if bindingHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST retained-group collection root has an empty protocol binding", + ) + } + payload, err := frostRetainedGroupCanonicalValue(collection) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(domain)) + hasher.Write(bindingHash[:]) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func frostRetainedGroupActiveQuarantineCount( + state frostRetainedGroupQuarantineJournalState, +) uint64 { + count := uint64(0) + for _, quarantine := range state.Quarantines { + if quarantine.Status == frostRetainedGroupQuarantineActive { + count++ + } + } + return count +} + func validFrostRetainedGroupTransition( current FrostRetainedGroupLifecycle, next FrostRetainedGroupLifecycle, @@ -1462,8 +3099,20 @@ func sameFrostRetainedGroupTransaction( func appendFrostRetainedGroupQuarantineRoot( state *frostRetainedGroupQuarantineJournalState, mutation FrostRetainedGroupMutation, -) { - payload, _ := frostRetainedGroupCanonicalValue(mutation) +) error { + wireMutation := frostRetainedGroupWireQuarantineJournalMutation{ + Point: frostRetainedGroupEventPointToWire(mutation.Point), + Kind: string(mutation.Kind), + WalletID: frostActivationHex32(mutation.WalletID), + QuarantineID: frostActivationHex32(mutation.QuarantineID), + EvidenceHash: frostActivationHex32(mutation.EvidenceHash), + LiftCertificateHash: frostActivationHex32(mutation.LiftCertificateHash), + Reason: mutation.Reason, + } + payload, err := frostRetainedGroupCanonicalValue(wireMutation) + if err != nil { + return err + } leaf := sha256.Sum256(payload) hasher := sha256.New() hasher.Write([]byte(frostRetainedGroupQuarantineDomain)) @@ -1471,11 +3120,17 @@ func appendFrostRetainedGroupQuarantineRoot( hasher.Write(leaf[:]) copy(state.Root[:], hasher.Sum(nil)) state.Generation++ + return nil } func frostRetainedGroupInventoryRoot( state frostRetainedGroupJournalState, ) ([32]byte, uint64, uint64, uint64, error) { + if len(state.Wallets) > frostRetainedGroupMaximumWallets { + return [32]byte{}, 0, 0, 0, fmt.Errorf( + "FROST retained-group inventory exceeds the wallet limit", + ) + } type inventoryEventPoint struct { BlockNumber uint64 `json:"blockNumber"` BlockHash string `json:"blockHash"` @@ -1619,10 +3274,52 @@ func cloneFrostRetainedGroupMutations( copy(result, mutations) for index := range result { result[index].OperatorIDs = append([]uint32{}, mutations[index].OperatorIDs...) + if mutations[index].LiftCertificate != nil { + certificate := *mutations[index].LiftCertificate + certificate.Signatures = append( + []FrostRetainedGroupQuarantineLiftSignature{}, + mutations[index].LiftCertificate.Signatures..., + ) + result[index].LiftCertificate = &certificate + } } return result } +func equalFrostRetainedGroupSemanticHistories( + first *FrostRetainedGroupHistory, + second *FrostRetainedGroupHistory, +) (bool, error) { + if first == nil || second == nil || + first.From != second.From || + first.To != second.To || + first.HistoryRoot != second.HistoryRoot || + first.Complete != second.Complete || + first.EmptyAtFrom != second.EmptyAtFrom || + first.DescriptorSetHash != second.DescriptorSetHash || + len(first.Mutations) != len(second.Mutations) { + return false, nil + } + for index := range first.Mutations { + firstMutation, err := frostRetainedGroupCanonicalValue( + first.Mutations[index], + ) + if err != nil { + return false, err + } + secondMutation, err := frostRetainedGroupCanonicalValue( + second.Mutations[index], + ) + if err != nil { + return false, err + } + if !bytes.Equal(firstMutation, secondMutation) { + return false, nil + } + } + return true, nil +} + func (frgj *frostRetainedGroupJournal) reconcile( ctx context.Context, target FrostPreSignFinality, @@ -1630,6 +3327,12 @@ func (frgj *frostRetainedGroupJournal) reconcile( if ctx == nil { return nil, fmt.Errorf("FROST retained-group reconciliation context is nil") } + reconciliationContext, cancel := context.WithTimeout( + ctx, + frostRetainedGroupMaximumReconciliationDuration, + ) + defer cancel() + ctx = reconciliationContext frgj.mutex.Lock() defer frgj.mutex.Unlock() if frgj.closed { @@ -1638,7 +3341,11 @@ func (frgj *frostRetainedGroupJournal) reconcile( if target.BlockNumber == 0 || target.BlockHash == [32]byte{} || target.BlockNumber < frgj.metadata.Checkpoint.BlockNumber || target.BlockNumber < frgj.state.CurrentPoint.BlockNumber || - target.BlockNumber < frgj.quarantineState.CurrentPoint.BlockNumber { + target.BlockNumber < frgj.quarantineState.CurrentPoint.BlockNumber || + (frgj.checkpointState.Sequence >= + frgj.checkpointPolicy.MinimumSequence && + target.BlockNumber < + frgj.checkpointState.Point.BlockNumber) { return nil, fmt.Errorf("FROST retained-group target is invalid or retrograde") } if err := frgj.verifyHistorySourceIdentity(ctx); err != nil { @@ -1667,17 +3374,202 @@ func (frgj *frostRetainedGroupJournal) reconcile( return nil, fmt.Errorf("cannot verify FROST retained-group %s: [%w]", name, err) } } - history, err := frgj.source.ReadCompleteHistory(ctx, frgj.metadata.Checkpoint, target) - if err != nil { - return nil, fmt.Errorf("cannot reconstruct complete FROST retained-group history: [%w]", err) + checkpointAfter := FrostRetainedGroupCheckpointCursor{ + Sequence: frgj.checkpointState.Sequence, + CertificateHash: frgj.checkpointState.CertificateHash, + } + checkpointCursor := checkpointAfter + var history *FrostRetainedGroupHistory + checkpointHashes := make([][32]byte, 0) + checkpointCertificates := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + ) + checkpointRecoveryComplete := false + for checkpointPage := 0; checkpointPage < + frostRetainedGroupCheckpointPagesPerReconciliation; checkpointPage++ { + page, err := frgj.source.ReadCompleteHistory( + ctx, + frgj.metadata.Checkpoint, + target, + checkpointCursor, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot reconstruct complete FROST retained-group history: [%w]", + err, + ) + } + if page == nil || !page.Complete || !page.EmptyAtFrom || + page.From != frgj.metadata.Checkpoint || + page.To != target || + page.DescriptorSetHash != frgj.metadata.DescriptorSetHash { + return nil, fmt.Errorf( + "FROST retained-group history receipt is incomplete or differently bound", + ) + } + if len(page.Mutations) > frostRetainedGroupMaximumMutations { + return nil, fmt.Errorf( + "FROST retained-group history exceeds the mutation limit", + ) + } + if len(page.Checkpoints) > + frostRetainedGroupMaximumCheckpointsPerPage { + return nil, fmt.Errorf( + "FROST retained-group checkpoint page exceeds its bound", + ) + } + if err := validateCompleteFrostRetainedGroupHistory( + page, + frgj.liftPolicy, + ); err != nil { + return nil, err + } + if page.CheckpointAfter != checkpointCursor { + return nil, fmt.Errorf( + "FROST retained-group history checkpoint cursor differs from the requested certified head", + ) + } + if history == nil { + history = page + } else { + equal, err := equalFrostRetainedGroupSemanticHistories( + history, + page, + ) + if err != nil { + return nil, err + } + if !equal { + return nil, fmt.Errorf( + "FROST retained-group history changed between checkpoint pages", + ) + } + } + pageCheckpointHashes, err := + validateFrostRetainedGroupCheckpointSuffix( + frgj.checkpointPolicy, + checkpointCursor, + page.Checkpoints, + ) + if err != nil { + return nil, err + } + if len(page.Checkpoints) == 0 && !page.CheckpointComplete { + return nil, fmt.Errorf( + "nonfinal FROST checkpoint page made no progress", + ) + } + if len(page.Checkpoints) > 0 { + if err := validateFrostRetainedGroupCheckpointSemantics( + frgj.checkpointPolicy, + page, + pageCheckpointHashes, + ); err != nil { + return nil, err + } + for index, certificate := range page.Checkpoints { + if err := frgj.source.VerifyPoint( + ctx, + certificate.Body.Point, + ); err != nil { + return nil, fmt.Errorf( + "cannot verify FROST checkpoint certificate point [%d:%d]: [%w]", + checkpointPage, + index, + err, + ) + } + } + checkpointCertificates = append( + checkpointCertificates, + page.Checkpoints..., + ) + checkpointHashes = append( + checkpointHashes, + pageCheckpointHashes..., + ) + tail := len(page.Checkpoints) - 1 + checkpointCursor = FrostRetainedGroupCheckpointCursor{ + Sequence: page.Checkpoints[tail].Body.Sequence, + CertificateHash: pageCheckpointHashes[tail], + } + } + if page.CheckpointComplete { + checkpointRecoveryComplete = true + break + } } - if history == nil || !history.Complete || !history.EmptyAtFrom || - history.From != frgj.metadata.Checkpoint || - history.To != target || history.DescriptorSetHash != frgj.metadata.DescriptorSetHash { - return nil, fmt.Errorf("FROST retained-group history receipt is incomplete or differently bound") + if history == nil { + return nil, fmt.Errorf( + "FROST retained-group history source returned no checkpoint page", + ) } - if err := validateCompleteFrostRetainedGroupHistory(history); err != nil { - return nil, err + history.CheckpointAfter = checkpointAfter + history.Checkpoints = checkpointCertificates + history.CheckpointChainRoot = + frostRetainedGroupCheckpointChainRoot( + frgj.checkpointPolicy.ProtocolBindingHash, + checkpointAfter, + checkpointHashes, + ) + history.CheckpointTipHash = checkpointAfter.CertificateHash + if len(checkpointHashes) > 0 { + history.CheckpointTipHash = + checkpointHashes[len(checkpointHashes)-1] + } + history.CheckpointComplete = checkpointRecoveryComplete + if len(checkpointCertificates) > 0 { + // Revalidate the cross-page predecessor chain and the exact aggregate + // semantic binding. Per-page bounds remain enforced above. + checkpointHashes, err = + validateFrostRetainedGroupCheckpointSuffix( + frgj.checkpointPolicy, + checkpointAfter, + checkpointCertificates, + ) + if err != nil { + return nil, err + } + if err := validateFrostRetainedGroupCheckpointSemantics( + frgj.checkpointPolicy, + history, + checkpointHashes, + ); err != nil { + return nil, err + } + } + if len(history.Checkpoints) == 0 { + if !history.CheckpointComplete || + frgj.checkpointState.Sequence < + frgj.checkpointPolicy.MinimumSequence || + frgj.checkpointState.Point != target || + frgj.checkpointState.HistoryRoot != history.HistoryRoot || + history.CheckpointTipHash != + frgj.checkpointState.CertificateHash || + history.CheckpointChainRoot != + frostRetainedGroupCheckpointChainRoot( + frgj.checkpointPolicy.ProtocolBindingHash, + checkpointAfter, + nil, + ) { + return nil, fmt.Errorf( + "canonical FROST retained-group history rewrote, omitted, or reordered the durable certified head", + ) + } + } else { + if frgj.checkpointState.Sequence >= + frgj.checkpointPolicy.MinimumSequence && + (history.Checkpoints[0].Body.Point.BlockNumber <= + frgj.checkpointState.Point.BlockNumber || + history.Checkpoints[0].Body.CanonicalGeneration < + frgj.checkpointState.CanonicalGeneration || + history.Checkpoints[0].Body.QuarantineGeneration < + frgj.checkpointState.QuarantineGeneration) { + return nil, fmt.Errorf( + "FROST checkpoint suffix does not monotonically advance the durable head", + ) + } } canonicalInventoryMutations := frostRetainedGroupCanonicalMutations(history.Mutations) if len(frgj.mutations) > len(canonicalInventoryMutations) { @@ -1729,6 +3621,7 @@ func (frgj *frostRetainedGroupJournal) reconcile( } batch := frostRetainedGroupJournalBatch{ Schema: frostRetainedGroupJournalBatchSchema, + BindingHash: frgj.metadata.BindingHash, Sequence: frgj.state.BatchSequence + 1, From: frgj.state.CurrentPoint, To: target, @@ -1761,6 +3654,13 @@ func (frgj *frostRetainedGroupJournal) reconcile( frgj.state = candidate frgj.mutations = append(frgj.mutations, suffix...) } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-canonical-before-quarantine", + ); err != nil { + return nil, err + } + } quarantineSuffix := cloneFrostRetainedGroupMutations( canonicalQuarantineMutations[len(frgj.quarantineMutations):], ) @@ -1772,18 +3672,44 @@ func (frgj *frostRetainedGroupJournal) reconcile( } if target != frgj.quarantineState.CurrentPoint || len(quarantineSuffix) != 0 { candidate := cloneFrostRetainedGroupQuarantineState(frgj.quarantineState) - if err := applyFrostRetainedGroupQuarantineMutations(&candidate, quarantineSuffix); err != nil { + if err := applyFrostRetainedGroupQuarantineMutations( + &candidate, + quarantineSuffix, + frgj.liftPolicy, + ); err != nil { return nil, err } + hasLift := false + for _, mutation := range quarantineSuffix { + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + hasLift = true + break + } + } + if hasLift { + if err := frgj.ensureLiftCertificatesPersisted(quarantineSuffix); err != nil { + return nil, err + } + if frgj.persistFailureHook != nil { + if err := frgj.persistFailureHook( + "after-quarantine-lift-certificate-before-batch", + ); err != nil { + return nil, err + } + } + } batch := frostRetainedGroupQuarantineJournalBatch{ Schema: frostRetainedGroupQuarantineBatchSchema, + BindingHash: frgj.quarantineMetadata.BindingHash, Sequence: frgj.quarantineState.BatchSequence + 1, From: frgj.quarantineState.CurrentPoint, To: target, PriorBatchRoot: frgj.quarantineState.BatchRoot, Mutations: quarantineSuffix, } - checksumPayload, err := frostRetainedGroupCanonicalValue(batch) + checksumPayload, err := frostRetainedGroupQuarantineBatchCanonicalValue( + batch, + ) if err != nil { return nil, err } @@ -1794,10 +3720,14 @@ func (frgj *frostRetainedGroupJournal) reconcile( batch.PriorBatchRoot, batch.Checksum, ) + wireBatch, err := frostRetainedGroupQuarantineBatchToWire(batch) + if err != nil { + return nil, err + } if err := persistFrostRetainedGroupEnvelopeAt( frgj.quarantineDirectory, frostRetainedGroupBatchFileName(batch.Sequence), - &batch, + &wireBatch, false, ); err != nil { return nil, fmt.Errorf("cannot append FROST retained-group quarantine batch: [%w]", err) @@ -1818,23 +3748,75 @@ func (frgj *frostRetainedGroupJournal) reconcile( frgj.quarantineState = candidate frgj.quarantineMutations = append(frgj.quarantineMutations, quarantineSuffix...) } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-semantic-journals-before-checkpoints", + ); err != nil { + return nil, err + } + } + if len(history.Checkpoints) > 0 { + if err := frgj.persistCheckpointSuffix( + history.Checkpoints, + checkpointHashes, + ); err != nil { + return nil, err + } + } else if err := frgj.validateCheckpointAgainstDurableState( + frgj.checkpointState, + ); err != nil { + return nil, err + } + checkpointRecoveryAdvanced := + !history.CheckpointComplete && + frgj.checkpointState.Sequence > checkpointAfter.Sequence + withCheckpointRecoveryProgress := func(cause error) error { + if !checkpointRecoveryAdvanced { + return cause + } + return frostRetainedGroupCheckpointRecoveryProgressError( + frgj.checkpointState.Sequence, + cause, + ) + } afterHead, err := frgj.source.FinalizedHead(ctx) if err != nil { - return nil, fmt.Errorf("cannot read independent finalized head after journal replay: [%w]", err) + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "cannot read independent finalized head after journal replay: [%w]", + err, + )) } if afterHead.BlockNumber < beforeHead.BlockNumber || afterHead.BlockNumber < target.BlockNumber || afterHead.BlockHash == [32]byte{} || (beforeHead.BlockNumber == afterHead.BlockNumber && beforeHead.BlockHash != afterHead.BlockHash) { - return nil, fmt.Errorf("independent finalized head changed inconsistently during journal replay") + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "independent finalized head changed inconsistently during journal replay", + )) } if afterHead.BlockNumber == target.BlockNumber && afterHead.BlockHash != target.BlockHash { - return nil, fmt.Errorf("independent finalized head disagrees with challenged target after replay") + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "independent finalized head disagrees with challenged target after replay", + )) } if err := frgj.source.VerifyPoint(ctx, afterHead); err != nil { - return nil, fmt.Errorf("cannot verify independent finalized head after journal replay: [%w]", err) + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "cannot verify independent finalized head after journal replay: [%w]", + err, + )) } if err := frgj.source.VerifyPoint(ctx, target); err != nil { - return nil, fmt.Errorf("challenged FROST retained-group point changed during replay: [%w]", err) + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "challenged FROST retained-group point changed during replay: [%w]", + err, + )) + } + if !history.CheckpointComplete { + if !checkpointRecoveryAdvanced { + return nil, fmt.Errorf( + "incomplete FROST checkpoint recovery did not advance the durable head", + ) + } + return nil, withCheckpointRecoveryProgress(nil) } localSessionCount, err := frgj.reconcileLocalSessions(ctx, target) if err != nil { @@ -1849,11 +3831,23 @@ func (frgj *frostRetainedGroupJournal) reconcile( } if frgj.quarantineState.CurrentPoint != target || frgj.quarantineState.Generation < frgj.quarantineMinimumGeneration || - frgj.quarantineState.Root == [32]byte{} { + frgj.quarantineState.Root == [32]byte{} || + frgj.quarantineState.ActiveRoot == [32]byte{} || + frgj.quarantineState.TombstoneRoot == [32]byte{} { return nil, fmt.Errorf("independent FROST quarantine journal is not activation-ready") } + if frgj.checkpointState.Point != target || + frgj.checkpointState.Sequence < + frgj.checkpointPolicy.MinimumSequence || + frgj.checkpointState.CertificateHash == [32]byte{} || + frgj.checkpointState.HistoryRoot != history.HistoryRoot { + return nil, fmt.Errorf( + "quorum-certified FROST checkpoint journal is not activation-ready", + ) + } return &frostRetainedGroupJournalSnapshot{ Schema: frostRetainedGroupJournalSnapshotSchema, + BindingHash: frgj.metadata.BindingHash, StoreID: frgj.metadata.StoreID, StoreFingerprint: frgj.metadata.StoreFingerprint, ClusterFingerprint: frgj.metadata.ClusterFingerprint, @@ -1871,7 +3865,15 @@ func (frgj *frostRetainedGroupJournal) reconcile( QuarantineMinimumGeneration: frgj.quarantineMinimumGeneration, QuarantineGeneration: frgj.quarantineState.Generation, QuarantineRoot: frgj.quarantineState.Root, - QuarantineCount: uint64(len(frgj.quarantineState.Quarantines)), + QuarantineActiveRoot: frgj.quarantineState.ActiveRoot, + QuarantineTombstoneRoot: frgj.quarantineState.TombstoneRoot, + QuarantineCount: frostRetainedGroupActiveQuarantineCount(frgj.quarantineState), + QuarantineTombstoneCount: uint64(len(frgj.quarantineState.Tombstones)), + CheckpointMinimumSequence: frgj.checkpointPolicy.MinimumSequence, + CheckpointPredecessorHash: frgj.checkpointPolicy.PredecessorHash, + CheckpointSequence: frgj.checkpointState.Sequence, + CheckpointCertificateHash: frgj.checkpointState.CertificateHash, + CheckpointHistoryRoot: frgj.checkpointState.HistoryRoot, LocalSessionCount: localSessionCount, Complete: true, }, nil @@ -1879,7 +3881,14 @@ func (frgj *frostRetainedGroupJournal) reconcile( func validateCompleteFrostRetainedGroupHistory( history *FrostRetainedGroupHistory, + liftPolicy frostRetainedGroupQuarantineLiftPolicy, ) error { + if history == nil { + return fmt.Errorf("complete FROST retained-group history is nil") + } + if len(history.Mutations) > frostRetainedGroupMaximumMutations { + return fmt.Errorf("complete FROST retained-group history exceeds the mutation limit") + } var previous FrostRetainedGroupEventPoint for index, mutation := range history.Mutations { if !mutation.Point.valid() || mutation.Point.BlockNumber <= history.From.BlockNumber || @@ -1904,15 +3913,34 @@ func validateCompleteFrostRetainedGroupHistory( ); err != nil { return fmt.Errorf("complete FROST retained-group history is semantically invalid: [%w]", err) } + emptyActiveRoot, err := frostRetainedGroupQuarantineActiveRoot( + liftPolicy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + return fmt.Errorf("cannot initialize complete FROST quarantine history: [%w]", err) + } + emptyTombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + liftPolicy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + return fmt.Errorf("cannot initialize complete FROST quarantine history: [%w]", err) + } quarantineProbe := frostRetainedGroupQuarantineJournalState{ - Schema: frostRetainedGroupQuarantineStateSchema, - CurrentPoint: history.From, - Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), - Quarantines: []frostRetainedGroupQuarantineState{}, + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: liftPolicy.ProtocolBindingHash, + CurrentPoint: history.From, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + ActiveRoot: emptyActiveRoot, + TombstoneRoot: emptyTombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, } if err := applyFrostRetainedGroupQuarantineMutations( &quarantineProbe, frostRetainedGroupQuarantineMutations(history.Mutations), + liftPolicy, ); err != nil { return fmt.Errorf("complete FROST quarantine history is semantically invalid: [%w]", err) } @@ -1938,6 +3966,10 @@ func cloneFrostRetainedGroupQuarantineState( []frostRetainedGroupQuarantineState{}, state.Quarantines..., ) + result.Tombstones = append( + []frostRetainedGroupQuarantineTombstone{}, + state.Tombstones..., + ) return result } diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index bedab364a4..9827085680 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -4,8 +4,14 @@ import ( "bytes" "context" "crypto/ecdsa" + "crypto/ed25519" "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -18,16 +24,22 @@ import ( ) type journalHistorySource struct { - identity FrostRetainedGroupHistoryIdentity - checkpoint FrostPreSignFinality - head FrostPreSignFinality - descriptor [32]byte - mutations []FrostRetainedGroupMutation - complete bool - emptyAtFrom bool - points map[uint64][32]byte - operators map[chain.Address]chain.OperatorID - verifyErr error + identity FrostRetainedGroupHistoryIdentity + checkpoint FrostPreSignFinality + head FrostPreSignFinality + finalizedHeadErr error + descriptor [32]byte + mutations []FrostRetainedGroupMutation + complete bool + emptyAtFrom bool + points map[uint64][32]byte + operators map[chain.Address]chain.OperatorID + verifyErr error + checkpointIssuer func( + FrostRetainedGroupCheckpointCursor, + FrostPreSignFinality, + []FrostRetainedGroupMutation, + ) ([]FrostRetainedGroupCheckpointCertificate, error) } func (jhs *journalHistorySource) Identity( @@ -39,6 +51,9 @@ func (jhs *journalHistorySource) Identity( func (jhs *journalHistorySource) FinalizedHead( context.Context, ) (FrostPreSignFinality, error) { + if jhs.finalizedHeadErr != nil { + return FrostPreSignFinality{}, jhs.finalizedHeadErr + } return jhs.head, nil } @@ -59,6 +74,7 @@ func (jhs *journalHistorySource) ReadCompleteHistory( _ context.Context, from FrostPreSignFinality, to FrostPreSignFinality, + checkpointAfter FrostRetainedGroupCheckpointCursor, ) (*FrostRetainedGroupHistory, error) { mutations := make([]FrostRetainedGroupMutation, 0) for _, mutation := range jhs.mutations { @@ -66,13 +82,57 @@ func (jhs *journalHistorySource) ReadCompleteHistory( mutations = append(mutations, mutation) } } + historyRoot, err := frostRetainedGroupTestHistoryRoot( + [32]byte{0x44}, + from, + to, + mutations, + ) + if err != nil { + return nil, err + } + checkpoints, err := jhs.checkpointIssuer( + checkpointAfter, + to, + mutations, + ) + if err != nil { + return nil, err + } + checkpointComplete := true + if len(checkpoints) > frostRetainedGroupMaximumCheckpointsPerPage { + checkpoints = checkpoints[:frostRetainedGroupMaximumCheckpointsPerPage] + checkpointComplete = false + } + hashes := make([][32]byte, len(checkpoints)) + for index, checkpoint := range checkpoints { + hashes[index], err = + frostRetainedGroupCheckpointCertificateHash(checkpoint) + if err != nil { + return nil, err + } + } + tipHash := checkpointAfter.CertificateHash + if len(hashes) > 0 { + tipHash = hashes[len(hashes)-1] + } return &FrostRetainedGroupHistory{ - From: from, - To: to, - Mutations: cloneFrostRetainedGroupMutations(mutations), - Complete: jhs.complete, - EmptyAtFrom: jhs.emptyAtFrom, - DescriptorSetHash: jhs.descriptor, + From: from, + To: to, + Mutations: cloneFrostRetainedGroupMutations(mutations), + HistoryRoot: historyRoot, + CheckpointAfter: checkpointAfter, + Checkpoints: checkpoints, + CheckpointChainRoot: frostRetainedGroupCheckpointChainRoot( + [32]byte{0x44}, + checkpointAfter, + hashes, + ), + CheckpointTipHash: tipHash, + CheckpointComplete: checkpointComplete, + Complete: jhs.complete, + EmptyAtFrom: jhs.emptyAtFrom, + DescriptorSetHash: jhs.descriptor, }, nil } @@ -89,20 +149,26 @@ func (jhs *journalHistorySource) ResolveOperatorID( } type journalTestFixture struct { - manifest FrostRetainedGroupCanonicalJournalManifest - quarantine FrostRetainedGroupQuarantineJournalManifest - manifestHash [32]byte - checkpoint FrostPreSignFinality - target FrostPreSignFinality - later FrostPreSignFinality - walletID [32]byte - walletPKH [20]byte - operatorIDs []uint32 - operatorAddrs []chain.Address - localOperator chain.Address - registry *walletRegistry - source *journalHistorySource - admission FrostRetainedGroupMutation + manifest FrostRetainedGroupCanonicalJournalManifest + quarantine FrostRetainedGroupQuarantineJournalManifest + runtime FrostPreSignActivationRuntimeManifest + manifestHash [32]byte + bindingHash [32]byte + liftPrivateKeys []ed25519.PrivateKey + liftPublicKeySPKIs []string + checkpoint FrostPreSignFinality + target FrostPreSignFinality + later FrostPreSignFinality + walletID [32]byte + walletPKH [20]byte + operatorIDs []uint32 + operatorAddrs []chain.Address + localOperator chain.Address + registry *walletRegistry + source *journalHistorySource + admission FrostRetainedGroupMutation + checkpointPrivateKeys []ed25519.PrivateKey + checkpointPublicKeySPKIs []string } func newJournalTestFixture(t *testing.T) *journalTestFixture { @@ -151,29 +217,78 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { }, }, } + sourceIdentity := testFrostRetainedGroupCompleteIdentity() manifest := FrostRetainedGroupCanonicalJournalManifest{ StoreID: "journal-store-uuid", StoreFingerprint: [32]byte{0x31}, ClusterFingerprint: [32]byte{0x32}, Checkpoint: checkpoint, DescriptorSetHash: [32]byte{0x33}, - SourceTrustDomainID: "independent-journal-source", - SourceEndpointFingerprint: [32]byte{0x34}, - SourceOperatorFingerprint: [32]byte{0x35}, + SourceTrustDomainID: sourceIdentity.TrustDomainID, + SourceEndpointFingerprint: sourceIdentity.EndpointFingerprint, + SourceOperatorFingerprint: sourceIdentity.OperatorFingerprint, + SourceIdentity: sourceIdentity, MinimumGeneration: 1, } + checkpointAuthorities := make([]FrostRetainedGroupAuthority, 3) + checkpointPrivateKeys := make([]ed25519.PrivateKey, 3) + checkpointPublicKeySPKIs := make([]string, 3) + for index := range checkpointAuthorities { + authority, privateKey, publicKeySPKI := journalTestAuthority( + t, + fmt.Sprintf("checkpoint-%d", index+1), + byte(0x60+index), + ) + checkpointAuthorities[index] = authority + checkpointPrivateKeys[index] = privateKey + checkpointPublicKeySPKIs[index] = publicKeySPKI + } + liftAuthorities := make([]FrostRetainedGroupAuthority, 3) + liftPrivateKeys := make([]ed25519.PrivateKey, 3) + liftPublicKeySPKIs := make([]string, 3) + for index := range liftAuthorities { + authority, privateKey, publicKeySPKI := journalTestAuthority( + t, + fmt.Sprintf("lift-%d", index+1), + byte(0x70+index), + ) + liftAuthorities[index] = authority + liftPrivateKeys[index] = privateKey + liftPublicKeySPKIs[index] = publicKeySPKI + } quarantine := FrostRetainedGroupQuarantineJournalManifest{ - ProtocolID: [32]byte{0x41}, - StoreID: "quarantine-store-uuid", - StoreFingerprint: [32]byte{0x42}, - ClusterFingerprint: [32]byte{0x43}, + ProtocolID: [32]byte{0x41}, + LiftProtocolID: [32]byte{0x45}, + TombstoneProtocolID: [32]byte{0x46}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, + LiftAuthorityThreshold: 2, + LiftAuthorities: liftAuthorities, + StoreID: "quarantine-store-uuid", + StoreFingerprint: [32]byte{0x42}, + ClusterFingerprint: [32]byte{0x43}, + } + manifestHash := [32]byte{0x42} + bindingHash := [32]byte{0x44} + domainChainID := [32]byte{} + domainChainID[31] = 1 + runtime := FrostPreSignActivationRuntimeManifest{ + ManifestHash: manifestHash, + ActivationAuthorityKeyHash: [32]byte{0x47}, + VerifierOperatorFingerprint: [32]byte{0x48}, + HandshakeOperatorFingerprint: [32]byte{0x4d}, + DomainChainID: domainChainID, + GenesisBlockHash: [32]byte{0x49}, + ProfileHash: [32]byte{0x4a}, + ImplementationSetHash: [32]byte{0x4b}, + AttestationSignerKeyHash: [32]byte{0x4c}, + CanonicalJournal: manifest, + QuarantineJournal: quarantine, } source := &journalHistorySource{ - identity: FrostRetainedGroupHistoryIdentity{ - TrustDomainID: manifest.SourceTrustDomainID, - EndpointFingerprint: manifest.SourceEndpointFingerprint, - OperatorFingerprint: manifest.SourceOperatorFingerprint, - }, + identity: sourceIdentity, checkpoint: checkpoint, head: FrostPreSignFinality{BlockNumber: 100, BlockHash: [32]byte{0x64}}, descriptor: manifest.DescriptorSetHash, @@ -200,26 +315,272 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { WalletPublicKeyHash: walletPKH, OperatorIDs: append([]uint32{}, operatorIDs...), RetainedGroupHash: [32]byte{0x93}, + DkgResultHash: [32]byte{0x94}, + DkgSubmissionPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa1}, TransactionIndex: 0, LogIndex: 1}, + DkgApprovalPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 3}, CreationPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 4}, BridgeRegistrationPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 5}, } source.mutations = []FrostRetainedGroupMutation{admission} + checkpointPolicy, err := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtime, + ) + if err != nil { + t.Fatal(err) + } + source.checkpointIssuer = newFrostRetainedGroupTestCheckpointIssuer( + t, + checkpointPolicy, + checkpoint, + checkpointPrivateKeys, + checkpointPublicKeySPKIs, + ) return &journalTestFixture{ - manifest: manifest, - quarantine: quarantine, - manifestHash: [32]byte{0x42}, - checkpoint: checkpoint, - target: target, - later: later, - walletID: walletID, - walletPKH: walletPKH, - operatorIDs: operatorIDs, - operatorAddrs: operatorAddrs, - localOperator: localOperator, - registry: registry, - source: source, - admission: admission, + manifest: manifest, + quarantine: quarantine, + runtime: runtime, + manifestHash: manifestHash, + bindingHash: bindingHash, + liftPrivateKeys: liftPrivateKeys, + liftPublicKeySPKIs: liftPublicKeySPKIs, + checkpoint: checkpoint, + target: target, + later: later, + walletID: walletID, + walletPKH: walletPKH, + operatorIDs: operatorIDs, + operatorAddrs: operatorAddrs, + localOperator: localOperator, + registry: registry, + source: source, + admission: admission, + checkpointPrivateKeys: checkpointPrivateKeys, + checkpointPublicKeySPKIs: checkpointPublicKeySPKIs, + } +} + +func frostRetainedGroupTestHistoryRoot( + bindingHash [32]byte, + from FrostPreSignFinality, + to FrostPreSignFinality, + mutations []FrostRetainedGroupMutation, +) ([32]byte, error) { + query := frostRetainedGroupHistoryQuery{ + Schema: frostRetainedGroupHistoryRequestSchema, + BindingHash: frostActivationHex32(bindingHash), + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(to), + } + queryHash, err := frostRetainedGroupDomainHash( + frostRetainedGroupHistoryQueryDomain, + query, + ) + if err != nil { + return [32]byte{}, err + } + wireMutations := make( + []frostRetainedGroupWireMutation, + len(mutations), + ) + for index, mutation := range mutations { + wireMutations[index] = frostRetainedGroupMutationToWire(mutation) + } + return frostRetainedGroupHistoryRoot( + bindingHash, + queryHash, + wireMutations, + ) +} + +func newFrostRetainedGroupTestCheckpointIssuer( + t *testing.T, + policy frostRetainedGroupCheckpointPolicy, + from FrostPreSignFinality, + privateKeys []ed25519.PrivateKey, + publicKeySPKIs []string, +) func( + FrostRetainedGroupCheckpointCursor, + FrostPreSignFinality, + []FrostRetainedGroupMutation, +) ([]FrostRetainedGroupCheckpointCertificate, error) { + t.Helper() + certificates := make( + map[uint64]FrostRetainedGroupCheckpointCertificate, + ) + hashes := make(map[uint64][32]byte) + latest := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + var latestPoint FrostPreSignFinality + return func( + after FrostRetainedGroupCheckpointCursor, + to FrostPreSignFinality, + mutations []FrostRetainedGroupMutation, + ) ([]FrostRetainedGroupCheckpointCertificate, error) { + if after.Sequence < policy.MinimumSequence-1 || + (after.Sequence == policy.MinimumSequence-1 && + after.CertificateHash != policy.PredecessorHash) { + return nil, fmt.Errorf("test checkpoint cursor is invalid") + } + if after.Sequence >= policy.MinimumSequence { + hash, exists := hashes[after.Sequence] + if !exists || hash != after.CertificateHash { + return nil, fmt.Errorf("test checkpoint cursor is not an ancestor") + } + } + if latest.Sequence >= policy.MinimumSequence && + latestPoint == to { + suffix := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + latest.Sequence-after.Sequence, + ) + for sequence := after.Sequence + 1; sequence <= latest.Sequence; sequence++ { + suffix = append(suffix, certificates[sequence]) + } + return suffix, nil + } + if latest.Sequence >= policy.MinimumSequence && + to.BlockNumber <= latestPoint.BlockNumber { + return nil, fmt.Errorf( + "test checkpoint point does not strictly advance", + ) + } + semantic, err := frostRetainedGroupCertifiedStateFromHistory( + policy, + from, + to, + mutations, + ) + if err != nil { + historyRoot, historyRootErr := + frostRetainedGroupTestHistoryRoot( + policy.ProtocolBindingHash, + from, + to, + mutations, + ) + if historyRootErr != nil { + return nil, historyRootErr + } + // Malformed-history tests still need a strictly valid wire + // certificate so the source reaches its earlier semantic-history + // rejection. These sentinel roots can never pass checkpoint + // semantic validation. + semantic = FrostRetainedGroupCheckpointBody{ + Point: to, + HistoryRoot: historyRoot, + CanonicalGeneration: policy.CanonicalMinimum, + CanonicalInventoryRoot: [32]byte{0xf1}, + QuarantineGeneration: policy.QuarantineMinimum, + QuarantineEventRoot: [32]byte{0xf2}, + QuarantineActiveRoot: [32]byte{0xf3}, + QuarantineTombstoneRoot: [32]byte{0xf4}, + } + } + body := FrostRetainedGroupCheckpointBody{ + Schema: frostRetainedGroupCheckpointBodySchema, + ProtocolBindingHash: policy.ProtocolBindingHash, + ManifestHash: policy.ManifestHash, + ProfileHash: policy.ProfileHash, + ImplementationSetHash: policy.ImplementationSetHash, + ChainID: policy.ChainID, + DomainChainID: policy.DomainChainID, + GenesisBlockHash: policy.GenesisBlockHash, + AuthoritySetHash: policy.AuthoritySetHash, + Sequence: latest.Sequence + 1, + PreviousCertificateHash: latest.CertificateHash, + Point: semantic.Point, + HistoryRoot: semantic.HistoryRoot, + CanonicalGeneration: semantic.CanonicalGeneration, + CanonicalInventoryRoot: semantic.CanonicalInventoryRoot, + QuarantineGeneration: semantic.QuarantineGeneration, + QuarantineEventRoot: semantic.QuarantineEventRoot, + QuarantineActiveRoot: semantic.QuarantineActiveRoot, + QuarantineTombstoneRoot: semantic.QuarantineTombstoneRoot, + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(body) + if err != nil { + return nil, err + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupCheckpointSignature, + policy.AuthorityThreshold, + ) + for index := range signatures { + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: policy.Authorities[index].AuthorityID, + SignerPublicKeySPKI: publicKeySPKIs[index], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign(privateKeys[index], signatureHash[:]), + ), + } + } + certificate := FrostRetainedGroupCheckpointCertificate{ + Schema: frostRetainedGroupCheckpointCertificateSchema, + Body: body, + BodyHash: bodyHash, + Signatures: signatures, + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificate) + if err != nil { + return nil, err + } + certificates[body.Sequence] = certificate + hashes[body.Sequence] = certificateHash + latest = FrostRetainedGroupCheckpointCursor{ + Sequence: body.Sequence, + CertificateHash: certificateHash, + } + latestPoint = to + suffix := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + latest.Sequence-after.Sequence, + ) + for sequence := after.Sequence + 1; sequence <= latest.Sequence; sequence++ { + suffix = append(suffix, certificates[sequence]) + } + return suffix, nil + } +} + +func (fixture *journalTestFixture) resignCheckpointCertificate( + t *testing.T, + certificate *FrostRetainedGroupCheckpointCertificate, + signerIndices []int, +) { + t.Helper() + bodyHash, err := frostRetainedGroupCheckpointBodyHash(certificate.Body) + if err != nil { + t.Fatal(err) + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupCheckpointSignature, + len(signerIndices), + ) + for index, signerIndex := range signerIndices { + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: fixture.quarantine. + CheckpointAuthorities[signerIndex].AuthorityID, + SignerPublicKeySPKI: fixture. + checkpointPublicKeySPKIs[signerIndex], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign( + fixture.checkpointPrivateKeys[signerIndex], + signatureHash[:], + ), + ), + } } + certificate.BodyHash = bodyHash + certificate.Signatures = signatures } func TestFrostLocalSessionSnapshotBindsExactSignerMaterial(t *testing.T) { @@ -277,6 +638,157 @@ func TestFrostLocalSessionSnapshotBindsExactSignerMaterial(t *testing.T) { } } +func journalTestAuthority( + t *testing.T, + authorityID string, + seedByte byte, +) (FrostRetainedGroupAuthority, ed25519.PrivateKey, string) { + t.Helper() + seed := make([]byte, ed25519.SeedSize) + seed[0] = seedByte + privateKey := ed25519.NewKeyFromSeed(seed) + publicKeyDER, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + return FrostRetainedGroupAuthority{ + AuthorityID: authorityID, + PublicKeySPKIHash: sha256.Sum256(publicKeyDER), + }, + privateKey, + base64.StdEncoding.EncodeToString(publicKeyDER) +} + +func (fixture *journalTestFixture) liftMutation( + t *testing.T, + journal *frostRetainedGroupJournal, + quarantine FrostRetainedGroupMutation, + point FrostRetainedGroupEventPoint, +) FrostRetainedGroupMutation { + t.Helper() + var raisedRecord FrostRetainedGroupQuarantineRaisedRecord + for _, existing := range journal.quarantineState.Quarantines { + if existing.RaisedRecord.QuarantineID == quarantine.QuarantineID { + raisedRecord = existing.RaisedRecord + break + } + } + if raisedRecord.QuarantineID == [32]byte{} { + t.Fatal("active quarantine is absent from the durable journal") + } + resolutionFinality := FrostPreSignFinality{ + BlockNumber: 14, + BlockHash: [32]byte{0x0e}, + } + fixture.source.points[resolutionFinality.BlockNumber] = + resolutionFinality.BlockHash + body := FrostRetainedGroupQuarantineLiftBody{ + Schema: frostRetainedGroupLiftBodySchema, + ProtocolBindingHash: journal.liftPolicy.ProtocolBindingHash, + ManifestHash: journal.liftPolicy.ManifestHash, + ProfileHash: journal.liftPolicy.ProfileHash, + ImplementationSetHash: journal.liftPolicy.ImplementationSetHash, + ChainID: journal.liftPolicy.ChainID, + DomainChainID: journal.liftPolicy.DomainChainID, + GenesisBlockHash: journal.liftPolicy.GenesisBlockHash, + QuarantineProtocolID: journal.liftPolicy.QuarantineProtocolID, + LiftProtocolID: journal.liftPolicy.LiftProtocolID, + TombstoneProtocolID: journal.liftPolicy.TombstoneProtocolID, + AuthoritySetHash: journal.liftPolicy.AuthoritySetHash, + QuarantineID: quarantine.QuarantineID, + WalletID: quarantine.WalletID, + OriginalRaisedRecord: raisedRecord, + PriorGeneration: journal.quarantineState.Generation, + PriorEventRoot: journal.quarantineState.Root, + PriorActiveRoot: journal.quarantineState.ActiveRoot, + PriorTombstoneRoot: journal.quarantineState.TombstoneRoot, + LiftPoint: point, + ResolutionEvidenceHash: [32]byte{0x53}, + ResolutionFinality: resolutionFinality, + NotBeforeBlock: 14, + ExpiresAtBlock: 19, + } + lift := FrostRetainedGroupMutation{ + Point: point, + Kind: FrostRetainedGroupQuarantineLiftMutation, + WalletID: quarantine.WalletID, + QuarantineID: quarantine.QuarantineID, + LiftCertificate: &FrostRetainedGroupQuarantineLiftCertificate{ + Schema: frostRetainedGroupLiftCertificateSchema, + Body: body, + }, + } + authorityIndexes := make([]int, journal.liftPolicy.AuthorityThreshold) + for index := range authorityIndexes { + authorityIndexes[index] = index + } + fixture.resignLiftMutation(t, &lift, authorityIndexes) + return lift +} + +func (fixture *journalTestFixture) resignLiftMutation( + t *testing.T, + mutation *FrostRetainedGroupMutation, + authorityIndexes []int, +) { + t.Helper() + if mutation == nil || mutation.LiftCertificate == nil { + t.Fatal("lift mutation certificate is nil") + } + bodyHash, err := frostRetainedGroupLiftBodyHash( + mutation.LiftCertificate.Body, + ) + if err != nil { + t.Fatal(err) + } + signatureHash := frostRetainedGroupLiftSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupQuarantineLiftSignature, + len(authorityIndexes), + ) + for index, authorityIndex := range authorityIndexes { + if authorityIndex < 0 || + authorityIndex >= len(fixture.runtime.QuarantineJournal.LiftAuthorities) || + authorityIndex >= len(fixture.liftPrivateKeys) || + authorityIndex >= len(fixture.liftPublicKeySPKIs) { + t.Fatalf("invalid lift authority index [%d]", authorityIndex) + } + authority := fixture.runtime.QuarantineJournal. + LiftAuthorities[authorityIndex] + signatures[index] = FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: authority.AuthorityID, + SignerPublicKeySPKI: fixture.liftPublicKeySPKIs[authorityIndex], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign( + fixture.liftPrivateKeys[authorityIndex], + signatureHash[:], + ), + ), + } + } + mutation.LiftCertificate.Schema = frostRetainedGroupLiftCertificateSchema + mutation.LiftCertificate.BodyHash = bodyHash + mutation.LiftCertificate.Signatures = signatures + refreshJournalTestLiftCertificateHash(t, mutation) +} + +func refreshJournalTestLiftCertificateHash( + t *testing.T, + mutation *FrostRetainedGroupMutation, +) { + t.Helper() + if mutation == nil || mutation.LiftCertificate == nil { + t.Fatal("lift mutation certificate is nil") + } + certificateHash, err := frostRetainedGroupLiftCertificateHash( + *mutation.LiftCertificate, + ) + if err != nil { + t.Fatal(err) + } + mutation.LiftCertificateHash = certificateHash +} + func (fixture *journalTestFixture) openJournal( t *testing.T, directory string, @@ -284,9 +796,8 @@ func (fixture *journalTestFixture) openJournal( t.Helper() journal, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, - fixture.manifest, - fixture.quarantine, + fixture.bindingHash, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -297,6 +808,73 @@ func (fixture *journalTestFixture) openJournal( return journal } +func (fixture *journalTestFixture) openJournalError( + directory string, +) error { + journal, err := newFrostRetainedGroupJournal( + directory, + fixture.bindingHash, + fixture.runtime, + fixture.source, + fixture.registry, + fixture.localOperator, + ) + if journal != nil { + _ = journal.close() + } + return err +} + +func (fixture *journalTestFixture) openActiveQuarantine( + t *testing.T, + directory string, +) (*frostRetainedGroupJournal, FrostRetainedGroupMutation) { + t.Helper() + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupRecoveryRequiredMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x51}, + EvidenceHash: [32]byte{0x52}, + Reason: "manual recovery is required", + } + fixture.source.mutations = append(fixture.source.mutations, quarantine) + journal := fixture.openJournal(t, directory) + snapshot, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + _ = journal.close() + t.Fatal(err) + } + if snapshot.QuarantineCount != 1 || + snapshot.QuarantineTombstoneCount != 0 { + _ = journal.close() + t.Fatalf("unexpected active quarantine snapshot: %+v", snapshot) + } + return journal, quarantine +} + +func validateJournalTestLift( + journal *frostRetainedGroupJournal, + lift FrostRetainedGroupMutation, +) error { + if journal == nil || len(journal.quarantineState.Quarantines) != 1 { + return fmt.Errorf("journal does not contain exactly one quarantine") + } + _, err := validateFrostRetainedGroupLiftCertificate( + journal.liftPolicy, + journal.quarantineState, + lift, + journal.quarantineState.Quarantines[0], + ) + return err +} + func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( t *testing.T, ) { @@ -314,7 +892,7 @@ func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( t.Fatalf("unexpected journal snapshot: %+v", snapshot) } - fixture.source.mutations[0].OperatorIDs[0] = 51 + fixture.source.mutations[0].OperatorIDs[0] = 52 if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || !strings.Contains(err.Error(), "rewrote, omitted, or reordered") { t.Fatalf("expected canonical prefix rewrite failure, got [%v]", err) @@ -333,46 +911,1373 @@ func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( } } -func TestFrostRetainedGroupJournal_IntegratesCommittedOrphanBatchExactlyOnce( +func TestFrostRetainedGroupJournal_RejectsResignedGenerationRollback( t *testing.T, ) { fixture := newJournalTestFixture(t) + movingFunds := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 3, + BlockHash: [32]byte{0x03}, + TransactionHash: [32]byte{0xb3}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupMovingFundsMutation, + WalletID: fixture.walletID, + WalletPublicKeyHash: fixture.walletPKH, + } + fixture.source.mutations = append( + fixture.source.mutations, + movingFunds, + ) directory := filepath.Join(t.TempDir(), "journal") journal := fixture.openJournal(t, directory) - journal.persistFailureHook = func(stage string) error { - if stage != "after-batch-before-state" { - t.Fatalf("unexpected failure stage [%s]", stage) + defer journal.close() + first, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if first.SnapshotGeneration != 2 || + journal.checkpointState.CanonicalGeneration != 2 { + t.Fatalf("unexpected certified generation: %+v", first) + } + + // The exporter and checkpoint quorum now re-sign a history that omits a + // previously certified canonical transition. The new certificate is + // internally valid and exactly matches the rewritten history, but its + // generation rolls back relative to the durable certified predecessor. + fixture.source.mutations = []FrostRetainedGroupMutation{ + fixture.admission, + } + _, err = journal.reconcile(context.Background(), fixture.later) + if err == nil || !strings.Contains( + err.Error(), + "does not monotonically advance the durable head", + ) { + t.Fatalf("expected re-signed generation rollback rejection, got [%v]", err) + } + if journal.checkpointState.Sequence != 1 || + journal.checkpointState.CanonicalGeneration != 2 { + t.Fatalf( + "re-signed generation rollback changed durable checkpoint: %+v", + journal.checkpointState, + ) + } +} + +func TestFrostRetainedGroupJournal_CheckpointCertificateRejectsBypasses( + t *testing.T, +) { + newCertificate := func( + t *testing.T, + ) ( + *journalTestFixture, + frostRetainedGroupCheckpointPolicy, + FrostRetainedGroupCheckpointCursor, + FrostRetainedGroupCheckpointCertificate, + ) { + t.Helper() + fixture := newJournalTestFixture(t) + policy, err := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) } - return fmt.Errorf("simulated crash") + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } + return fixture, policy, after, certificates[0] } - if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || - !strings.Contains(err.Error(), "simulated crash") { - t.Fatalf("expected simulated crash, got [%v]", err) + + testCases := map[string]func( + *testing.T, + *journalTestFixture, + *FrostRetainedGroupCheckpointCertificate, + ){ + "insufficient quorum": func( + _ *testing.T, + _ *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures = certificate.Signatures[:1] + }, + "wrong pinned SPKI": func( + _ *testing.T, + fixture *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures[0].SignerPublicKeySPKI = + fixture.checkpointPublicKeySPKIs[2] + }, + "duplicate authority": func( + _ *testing.T, + _ *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures[1] = certificate.Signatures[0] + }, + "unsorted authorities": func( + _ *testing.T, + _ *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures[0], certificate.Signatures[1] = + certificate.Signatures[1], certificate.Signatures[0] + }, + "re-signed sequence gap": func( + t *testing.T, + fixture *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Body.Sequence++ + fixture.resignCheckpointCertificate( + t, + certificate, + []int{0, 1}, + ) + }, + "re-signed predecessor fork": func( + t *testing.T, + fixture *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Body.PreviousCertificateHash[0] ^= 0xff + fixture.resignCheckpointCertificate( + t, + certificate, + []int{0, 1}, + ) + }, } - if err := journal.close(); err != nil { + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + fixture, policy, after, certificate := newCertificate(t) + mutate(t, fixture, &certificate) + if _, err := validateFrostRetainedGroupCheckpointSuffix( + policy, + after, + []FrostRetainedGroupCheckpointCertificate{certificate}, + ); err == nil { + t.Fatalf("checkpoint bypass [%s] was accepted", name) + } + }) + } + + t.Run("same-point successor", func(t *testing.T) { + fixture, policy, after, first := newCertificate(t) + firstHash, err := + frostRetainedGroupCheckpointCertificateHash(first) + if err != nil { + t.Fatal(err) + } + second := first + second.Body.Sequence++ + second.Body.PreviousCertificateHash = firstHash + fixture.resignCheckpointCertificate(t, &second, []int{0, 1}) + if _, err := validateFrostRetainedGroupCheckpointSuffix( + policy, + after, + []FrostRetainedGroupCheckpointCertificate{first, second}, + ); err == nil || !strings.Contains(err.Error(), "strictly monotonic") { + t.Fatalf("same-point checkpoint successor was accepted: [%v]", err) + } + }) +} + +func TestFrostRetainedGroupJournal_CheckpointIdentityIgnoresQuorumEncoding( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { t.Fatal(err) } + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } - restarted := fixture.openJournal(t, directory) - defer restarted.close() - snapshot, err := restarted.reconcile(context.Background(), fixture.target) + twoOfThreeAB := certificates[0] + twoOfThreeAC := certificates[0] + fixture.resignCheckpointCertificate( + t, + &twoOfThreeAC, + []int{0, 2}, + ) + threeOfThree := certificates[0] + fixture.resignCheckpointCertificate( + t, + &threeOfThree, + []int{0, 1, 2}, + ) + + var expectedHash [32]byte + for index, certificate := range []FrostRetainedGroupCheckpointCertificate{ + twoOfThreeAB, + twoOfThreeAC, + threeOfThree, + } { + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + t.Fatalf( + "valid quorum encoding [%d] was rejected: [%v]", + index, + err, + ) + } + if index == 0 { + expectedHash = certificateHash + } else if certificateHash != expectedHash { + t.Fatalf( + "checkpoint identity depends on quorum encoding [%d]: [%x != %x]", + index, + certificateHash, + expectedHash, + ) + } + } + twoOfThreeABWire, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(twoOfThreeAB), + ) if err != nil { t.Fatal(err) } - if snapshot.SnapshotGeneration != 1 || restarted.state.BatchSequence != 1 || - len(restarted.mutations) != 1 { - t.Fatalf("orphan batch was not integrated exactly once: %+v", restarted.state) + twoOfThreeACWire, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(twoOfThreeAC), + ) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(twoOfThreeABWire, twoOfThreeACWire) { + t.Fatal("alternate quorum encodings unexpectedly have identical wire form") } } -func TestFrostRetainedGroupJournal_QuarantineAndAuthenticatedLiftAreIndependent( +func TestFrostRetainedGroupJournal_CheckpointRejectsNonPrimeOrderAuthorityKeys( t *testing.T, ) { fixture := newJournalTestFixture(t) - quarantine := FrostRetainedGroupMutation{ - Point: FrostRetainedGroupEventPoint{ - BlockNumber: 5, - BlockHash: [32]byte{0x05}, + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } + + identityKey := make(ed25519.PublicKey, ed25519.PublicKeySize) + identityKey[0] = 1 + identityKeyDER, err := x509.MarshalPKIXPublicKey(identityKey) + if err != nil { + t.Fatal(err) + } + policy.Authorities = append( + []FrostRetainedGroupAuthority{}, + policy.Authorities..., + ) + policy.Authorities[0].PublicKeySPKIHash = + sha256.Sum256(identityKeyDER) + policy.AuthoritySetHash, err = + frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + policy.AuthorityThreshold, + policy.Authorities, + ) + if err != nil { + t.Fatal(err) + } + certificate := certificates[0] + certificate.Body.AuthoritySetHash = policy.AuthoritySetHash + fixture.resignCheckpointCertificate( + t, + &certificate, + []int{0, 1}, + ) + signatureHash := frostRetainedGroupCheckpointSignatureHash( + certificate.BodyHash, + ) + trivialSignature := make([]byte, ed25519.SignatureSize) + trivialSignature[0] = 1 // R is the identity; S is zero. + if !ed25519.Verify( + identityKey, + signatureHash[:], + trivialSignature, + ) { + t.Fatal( + "test runtime no longer accepts the identity-key Ed25519 forgery", + ) + } + certificate.Signatures[0] = + FrostRetainedGroupCheckpointSignature{ + AuthorityID: policy.Authorities[0].AuthorityID, + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + identityKeyDER, + ), + Signature: base64.StdEncoding.EncodeToString( + trivialSignature, + ), + } + if _, err := validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ); err == nil || !strings.Contains( + err.Error(), + "nonidentity prime-order", + ) { + t.Fatalf( + "identity checkpoint authority key was not rejected: [%v]", + err, + ) + } + + orderTwoKey := make(ed25519.PublicKey, ed25519.PublicKeySize) + orderTwoKey[0] = 0xec + for index := 1; index < len(orderTwoKey)-1; index++ { + orderTwoKey[index] = 0xff + } + orderTwoKey[len(orderTwoKey)-1] = 0x7f + if err := validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + orderTwoKey, + ); err == nil || !strings.Contains(err.Error(), "subgroup") { + t.Fatalf( + "nonidentity torsion Ed25519 key was not rejected: [%v]", + err, + ) + } +} + +func TestFrostRetainedGroupJournal_CheckpointFrozenHashVector( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue frozen checkpoint vector: [%v]", err) + } + bodyHash, err := + frostRetainedGroupCheckpointBodyHash(certificates[0].Body) + if err != nil { + t.Fatal(err) + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificates[0]) + if err != nil { + t.Fatal(err) + } + chainRoot := frostRetainedGroupCheckpointChainRoot( + fixture.bindingHash, + after, + [][32]byte{certificateHash}, + ) + const expectedBodyHash = "ba0fdaecfc27fac1de867bd56f88fe66198952b3a99ef21f639bc62f331ce1fd" + const expectedCertificateHash = "0f7a902e61f4d2e47ab936440b865f693c403c263d17aeb9298a515830557d4a" + const expectedChainRoot = "a4bb6bd09d47673ef2476afcf6318c7508430623a20011feae3338f3c2302ec6" + if fmt.Sprintf("%x", bodyHash) != expectedBodyHash || + fmt.Sprintf("%x", certificateHash) != expectedCertificateHash || + fmt.Sprintf("%x", chainRoot) != expectedChainRoot { + t.Fatalf( + "checkpoint hash vector changed\nbody: %x\ncertificate: %x\nchain: %x", + bodyHash, + certificateHash, + chainRoot, + ) + } +} + +func TestFrostRetainedGroupJournal_RecoversCheckpointChainBeyondOnePage( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + const previousAggregateRecoveryLimit = 4096 + count := previousAggregateRecoveryLimit + 2 + cursor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, + CertificateHash: fixture.quarantine.CheckpointPredecessorHash, + } + var proofFloor FrostRetainedGroupCheckpointCursor + var target FrostPreSignFinality + for index := 0; index < count; index++ { + blockNumber := fixture.checkpoint.BlockNumber + uint64(index) + 1 + blockHash := [32]byte{} + binary.BigEndian.PutUint64(blockHash[24:], blockNumber) + target = FrostPreSignFinality{ + BlockNumber: blockNumber, + BlockHash: blockHash, + } + if blockNumber == fixture.admission.Point.BlockNumber { + target.BlockHash = fixture.admission.Point.BlockHash + } + fixture.source.points[target.BlockNumber] = target.BlockHash + certificates, err := fixture.source.checkpointIssuer( + cursor, + target, + []FrostRetainedGroupMutation{fixture.admission}, + ) + if err != nil { + t.Fatal(err) + } + if len(certificates) != 1 { + t.Fatalf( + "expected one newly issued certificate, got [%d]", + len(certificates), + ) + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificates[0]) + if err != nil { + t.Fatal(err) + } + cursor = FrostRetainedGroupCheckpointCursor{ + Sequence: certificates[0].Body.Sequence, + CertificateHash: certificateHash, + } + if index == count-1-frostRetainedGroupMaximumHandshakeAncestry { + proofFloor = cursor + } + } + fixture.source.head = FrostPreSignFinality{ + BlockNumber: target.BlockNumber + 1, + BlockHash: [32]byte{0xfa}, + } + fixture.source.points[fixture.source.head.BlockNumber] = + fixture.source.head.BlockHash + + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + snapshot, err := journal.reconcile(context.Background(), target) + if !errors.Is(err, errFrostRetainedGroupCheckpointRecoveryProgress) || + snapshot != nil { + _ = journal.close() + t.Fatalf( + "first bounded recovery page did not report durable progress: [%v]", + err, + ) + } + if journal.checkpointState.Sequence != + uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + _ = journal.close() + t.Fatalf( + "first bounded recovery page stopped at sequence [%d]", + journal.checkpointState.Sequence, + ) + } + + postPublicationTimeoutInjected := false + journal.checkpointPersistFailureHook = func(stage string) error { + if stage == "after-checkpoint-state-before-memory" && + !postPublicationTimeoutInjected { + postPublicationTimeoutInjected = true + fixture.source.finalizedHeadErr = context.DeadlineExceeded + } + return nil + } + snapshot, err = journal.reconcile(context.Background(), target) + if !errors.Is(err, errFrostRetainedGroupCheckpointRecoveryProgress) || + !errors.Is(err, context.DeadlineExceeded) || + snapshot != nil { + _ = journal.close() + t.Fatalf( + "post-publication timeout did not preserve durable progress: [%v]", + err, + ) + } + if journal.checkpointState.Sequence != + 2*uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + _ = journal.close() + t.Fatal("post-publication timeout lost the durable checkpoint cursor") + } + journal.checkpointPersistFailureHook = nil + fixture.source.finalizedHeadErr = nil + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if restarted.checkpointState.Sequence != + 2*uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + t.Fatalf( + "restart lost the durable checkpoint cursor: [%d]", + restarted.checkpointState.Sequence, + ) + } + previousSequence := restarted.checkpointState.Sequence + progressCount := 0 + for { + snapshot, err = restarted.reconcile(context.Background(), target) + if err == nil { + break + } + if !errors.Is( + err, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) || snapshot != nil { + t.Fatalf( + "bounded recovery returned a non-progress result: [%v]", + err, + ) + } + if restarted.checkpointState.Sequence != + previousSequence+ + uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + t.Fatalf( + "bounded recovery did not advance exactly one page: [%d] -> [%d]", + previousSequence, + restarted.checkpointState.Sequence, + ) + } + previousSequence = restarted.checkpointState.Sequence + progressCount++ + } + expectedProgressCount := + previousAggregateRecoveryLimit/ + frostRetainedGroupMaximumCheckpointsPerPage - + 2 + if progressCount != expectedProgressCount { + t.Fatalf( + "unexpected bounded recovery progress count: [%d]", + progressCount, + ) + } + if snapshot.CheckpointSequence != uint64(count) || + restarted.checkpointState.Sequence != uint64(count) || + snapshot.CheckpointCertificateHash != cursor.CertificateHash { + t.Fatalf( + "multi-page recovery stopped at the wrong checkpoint: %+v", + snapshot, + ) + } + ancestry, err := restarted.checkpointAncestryFrom(proofFloor) + if err != nil { + t.Fatal(err) + } + if len(ancestry) != frostRetainedGroupMaximumHandshakeAncestry+1 { + t.Fatalf( + "long-lived ancestry proof was truncated at [%d] certificates", + len(ancestry), + ) + } + if err := VerifyFrostRetainedGroupCheckpointProof( + fixture.bindingHash, + fixture.runtime, + proofFloor, + FrostRetainedGroupCheckpointCommitment{ + DurableHead: cursor, + Point: restarted.checkpointState.Point, + HistoryRoot: restarted.checkpointState.HistoryRoot, + CanonicalGeneration: restarted.checkpointState.CanonicalGeneration, + CanonicalInventoryRoot: restarted.checkpointState.CanonicalInventoryRoot, + QuarantineGeneration: restarted.checkpointState.QuarantineGeneration, + QuarantineEventRoot: restarted.checkpointState.QuarantineEventRoot, + QuarantineActiveRoot: restarted.checkpointState.QuarantineActiveRoot, + QuarantineTombstoneRoot: restarted.checkpointState.QuarantineTombstoneRoot, + }, + ancestry, + ); err != nil { + t.Fatalf("long-lived ancestry proof is invalid: [%v]", err) + } +} + +func TestFrostRetainedGroupJournal_CheckpointPersistenceRetriesInProcess( + t *testing.T, +) { + t.Run("adopts an alternate valid quorum encoding", func(t *testing.T) { + fixture := newJournalTestFixture(t) + cursor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, + CertificateHash: fixture.quarantine. + CheckpointPredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + cursor, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } + sourceCertificate := certificates[0] + storedCertificate := sourceCertificate + fixture.resignCheckpointCertificate( + t, + &storedCertificate, + []int{0, 2}, + ) + sourceHash, err := + frostRetainedGroupCheckpointCertificateHash(sourceCertificate) + if err != nil { + t.Fatal(err) + } + storedHash, err := + frostRetainedGroupCheckpointCertificateHash(storedCertificate) + if err != nil { + t.Fatal(err) + } + if sourceHash != storedHash { + t.Fatal("alternate quorum encodings have different checkpoint identities") + } + + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + if err := persistFrostRetainedGroupEnvelopeAt( + journal.checkpointDirectory, + frostRetainedGroupCheckpointFileName( + storedCertificate.Body.Sequence, + storedHash, + ), + frostRetainedGroupCheckpointCertificateToWire( + storedCertificate, + ), + false, + ); err != nil { + _ = journal.close() + t.Fatal(err) + } + snapshot, err := journal.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + _ = journal.close() + t.Fatal(err) + } + adopted := journal.checkpointCertificates[storedCertificate.Body.Sequence] + if snapshot.CheckpointCertificateHash != storedHash || + len(adopted.Signatures) != len(storedCertificate.Signatures) || + adopted.Signatures[1].AuthorityID != + storedCertificate.Signatures[1].AuthorityID { + _ = journal.close() + t.Fatalf( + "journal did not adopt the durable quorum encoding: %+v", + adopted, + ) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + restartedSnapshot, err := restarted.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + if restartedSnapshot.CheckpointCertificateHash != storedHash || + restarted.checkpointState.Sequence != + storedCertificate.Body.Sequence { + t.Fatalf( + "alternate quorum encoding did not converge after restart: %+v", + restartedSnapshot, + ) + } + }) + + t.Run("adopts a certificate orphan before the next certificate", func(t *testing.T) { + fixture := newJournalTestFixture(t) + cursor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, + CertificateHash: fixture.quarantine. + CheckpointPredecessorHash, + } + first, err := fixture.source.checkpointIssuer( + cursor, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(first) != 1 { + t.Fatalf("cannot issue first test checkpoint: [%v]", err) + } + firstHash, err := + frostRetainedGroupCheckpointCertificateHash(first[0]) + if err != nil { + t.Fatal(err) + } + cursor = FrostRetainedGroupCheckpointCursor{ + Sequence: first[0].Body.Sequence, + CertificateHash: firstHash, + } + second, err := fixture.source.checkpointIssuer( + cursor, + fixture.later, + fixture.source.mutations, + ) + if err != nil || len(second) != 1 { + t.Fatalf("cannot issue second test checkpoint: [%v]", err) + } + + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + certificateBoundaries := 0 + journal.checkpointPersistFailureHook = func(stage string) error { + if stage == "after-checkpoint-certificate-before-next" { + certificateBoundaries++ + if certificateBoundaries == 1 { + return fmt.Errorf("simulated certificate-boundary crash") + } + } + return nil + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil || !strings.Contains( + err.Error(), + "certificate-boundary", + ) { + t.Fatalf("expected certificate-boundary failure, got [%v]", err) + } + if journal.checkpointState.Sequence != + fixture.quarantine.CheckpointMinimumSequence-1 || + len(journal.checkpointCertificates) != 0 { + t.Fatal("certificate orphan was published to in-memory state") + } + journal.checkpointPersistFailureHook = nil + snapshot, err := journal.reconcile( + context.Background(), + fixture.later, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.CheckpointSequence != 2 || + journal.checkpointState.Sequence != 2 || + len(journal.checkpointCertificates) != 2 { + t.Fatalf( + "same-process certificate-orphan retry did not converge: %+v", + snapshot, + ) + } + }) + + for _, stage := range []string{ + "after-checkpoint-certificates-before-state", + "after-checkpoint-state-before-memory", + } { + t.Run(stage, func(t *testing.T) { + fixture := newJournalTestFixture(t) + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + failed := false + journal.checkpointPersistFailureHook = func(actual string) error { + if actual == stage && !failed { + failed = true + return fmt.Errorf("simulated checkpoint publication crash") + } + return nil + } + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err == nil || !strings.Contains( + err.Error(), + "publication crash", + ) { + t.Fatalf("expected checkpoint publication failure, got [%v]", err) + } + if journal.checkpointState.Sequence != + fixture.quarantine.CheckpointMinimumSequence-1 || + len(journal.checkpointCertificates) != 0 { + t.Fatal("failed checkpoint publication changed in-memory head") + } + journal.checkpointPersistFailureHook = nil + snapshot, err := journal.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.CheckpointSequence != 1 || + journal.checkpointState.Sequence != 1 || + len(journal.checkpointCertificates) != 1 { + t.Fatalf( + "same-process state-boundary retry did not converge: %+v", + snapshot, + ) + } + }) + } +} + +func TestFrostRetainedGroupJournal_CheckpointCrashBoundariesRecover( + t *testing.T, +) { + for _, stage := range []string{ + "after-canonical-before-quarantine", + "after-semantic-journals-before-checkpoints", + "after-checkpoint-certificates-before-state", + } { + t.Run(stage, func(t *testing.T) { + fixture := newJournalTestFixture(t) + fixture.source.mutations = append( + fixture.source.mutations, + FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupRecoveryRequiredMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x51}, + EvidenceHash: [32]byte{0x52}, + Reason: "manual recovery is required", + }, + ) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + failed := false + journal.checkpointPersistFailureHook = func(actual string) error { + if actual == stage && !failed { + failed = true + return fmt.Errorf("simulated cross-journal crash") + } + return nil + } + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err == nil || !strings.Contains( + err.Error(), + "cross-journal crash", + ) { + t.Fatalf("expected cross-journal crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + snapshot, err := restarted.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.SnapshotGeneration != 1 || + snapshot.QuarantineGeneration != 1 || + snapshot.CheckpointSequence != 1 || + len(restarted.mutations) != 1 || + len(restarted.quarantineMutations) != 1 || + len(restarted.checkpointCertificates) != 1 { + t.Fatalf( + "cross-journal crash recovery did not converge exactly once: %+v", + snapshot, + ) + } + }) + } +} + +func TestFrostRetainedGroupJournal_RejectsStaleHandshakeFloorBeforeAllocation( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + floor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence, + CertificateHash: [32]byte{0x7a}, + } + journal.checkpointHashes[floor.Sequence] = floor.CertificateHash + journal.checkpointState.Sequence = + floor.Sequence + frostRetainedGroupMaximumHandshakeAncestry + 1 + _, err := journal.checkpointAncestryFrom(floor) + if err == nil || !strings.Contains(err.Error(), "floor is too stale") { + t.Fatalf("expected stale external floor rejection, got [%v]", err) + } +} + +func TestFrostRetainedGroupJournal_RejectsOversizedHandshakeProofBeforeMaterialization( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err != nil { + t.Fatal(err) + } + floor := FrostRetainedGroupCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: journal.checkpointState.CertificateHash, + } + certificate := journal.checkpointCertificates[floor.Sequence] + baseSignatures := append( + []FrostRetainedGroupCheckpointSignature{}, + certificate.Signatures..., + ) + certificate.Signatures = make( + []FrostRetainedGroupCheckpointSignature, + 0, + len(baseSignatures)*64, + ) + for index := 0; index < 64; index++ { + certificate.Signatures = append( + certificate.Signatures, + baseSignatures..., + ) + } + for offset := uint64(0); offset <= + frostRetainedGroupMaximumHandshakeAncestry; offset++ { + sequence := floor.Sequence + offset + journal.checkpointCertificates[sequence] = certificate + journal.checkpointHashes[sequence] = [32]byte{0x7a} + } + journal.checkpointHashes[floor.Sequence] = floor.CertificateHash + journal.checkpointState.Sequence = + floor.Sequence + frostRetainedGroupMaximumHandshakeAncestry + + if _, err := journal.checkpointAncestryFrom( + floor, + ); err == nil || !strings.Contains( + err.Error(), + "canonical byte limit", + ) { + t.Fatalf( + "oversized checkpoint proof was not rejected before aggregate materialization: [%v]", + err, + ) + } +} + +func TestFrostRetainedGroupJournal_IntegratesCommittedOrphanBatchExactlyOnce( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + journal.persistFailureHook = func(stage string) error { + if stage != "after-batch-before-state" { + t.Fatalf("unexpected failure stage [%s]", stage) + } + return fmt.Errorf("simulated crash") + } + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "simulated crash") { + t.Fatalf("expected simulated crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + snapshot, err := restarted.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if snapshot.SnapshotGeneration != 1 || restarted.state.BatchSequence != 1 || + len(restarted.mutations) != 1 { + t.Fatalf("orphan batch was not integrated exactly once: %+v", restarted.state) + } +} + +func TestFrostRetainedGroupJournal_RejectsAuthenticatedPriorSchemaFixtures( + t *testing.T, +) { + type legacyFixture struct { + schemas [2]string + mutate func(*testing.T, string, string) + } + testCases := map[string]legacyFixture{ + "canonical metadata": { + schemas: [2]string{ + frostRetainedGroupJournalMetadataSchemaV1, + frostRetainedGroupJournalMetadataSchemaV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + metadata := frostRetainedGroupJournalMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + ); err != nil { + t.Fatal(err) + } + metadata.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + true, + ); err != nil { + t.Fatal(err) + } + }, + }, + "canonical state": { + schemas: [2]string{ + frostRetainedGroupJournalStateSchemaV1, + frostRetainedGroupJournalStateSchemaV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + state := frostRetainedGroupJournalState{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + ); err != nil { + t.Fatal(err) + } + state.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + true, + ); err != nil { + t.Fatal(err) + } + }, + }, + "canonical batch": { + schemas: [2]string{ + frostRetainedGroupJournalBatchSchemaV1, + frostRetainedGroupJournalBatchSchemaV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + name := frostRetainedGroupBatchFileName(1) + batch := frostRetainedGroupJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { + t.Fatal(err) + } + batch.Schema = schema + if schema == frostRetainedGroupJournalBatchSchemaV1 { + for index := range batch.Mutations { + batch.Mutations[index].DkgResultHash = [32]byte{} + batch.Mutations[index].DkgSubmissionPoint = FrostRetainedGroupEventPoint{} + batch.Mutations[index].DkgApprovalPoint = FrostRetainedGroupEventPoint{} + } + } + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(payload) + if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { + t.Fatal(err) + } + }, + }, + "quarantine metadata": { + schemas: [2]string{ + frostRetainedGroupQuarantineMetadataV1, + frostRetainedGroupQuarantineMetadataV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + metadata := frostRetainedGroupQuarantineMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + ); err != nil { + t.Fatal(err) + } + metadata.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + true, + ); err != nil { + t.Fatal(err) + } + }, + }, + "quarantine state": { + schemas: [2]string{ + frostRetainedGroupQuarantineStateV1, + frostRetainedGroupQuarantineStateV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + state := frostRetainedGroupQuarantineJournalState{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + ); err != nil { + t.Fatal(err) + } + state.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + true, + ); err != nil { + t.Fatal(err) + } + }, + }, + "quarantine batch": { + schemas: [2]string{ + frostRetainedGroupQuarantineBatchV1, + frostRetainedGroupQuarantineBatchV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + name := frostRetainedGroupBatchFileName(1) + batch := frostRetainedGroupWireQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { + t.Fatal(err) + } + batch.Schema = schema + batch.Checksum = frostActivationHex32([32]byte{}) + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = frostActivationHex32(sha256.Sum256(payload)) + if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { + t.Fatal(err) + } + }, + }, + } + + for componentName, testCase := range testCases { + for versionIndex, schema := range testCase.schemas { + t.Run(fmt.Sprintf("%s/v%d", componentName, versionIndex+1), func(t *testing.T) { + fixture := newJournalTestFixture(t) + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupQuarantineMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x61}, + EvidenceHash: [32]byte{0x62}, + Reason: "authenticated prior-schema fixture", + } + fixture.source.mutations = append(fixture.source.mutations, quarantine) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + if _, err := journal.reconcile(context.Background(), fixture.target); err != nil { + t.Fatal(err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + testCase.mutate(t, directory, schema) + err := fixture.openJournalError(directory) + if err == nil || + !strings.Contains(err.Error(), "prior FROST retained-group") || + !strings.Contains(err.Error(), "not safely migratable") || + !strings.Contains(err.Error(), "new empty v3") { + t.Fatalf( + "expected explicit manifest-pinned prior-schema rejection, got [%v]", + err, + ) + } + }) + } + } +} + +func TestFrostRetainedGroupJournal_RejectsCrossBindingBatchReplay( + t *testing.T, +) { + testCases := map[string]struct { + addQuarantine bool + directory string + }{ + "canonical": { + directory: frostRetainedGroupCanonicalDirectory, + }, + "quarantine": { + addQuarantine: true, + directory: frostRetainedGroupQuarantineDirectory, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newJournalTestFixture(t) + if testCase.addQuarantine { + fixture.source.mutations = append( + fixture.source.mutations, + FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupQuarantineMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x61}, + EvidenceHash: [32]byte{0x62}, + Reason: "binding replay test", + }, + ) + } + + rootDirectory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, rootDirectory) + if _, err := journal.reconcile(context.Background(), fixture.target); err != nil { + t.Fatal(err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + directory := filepath.Join(rootDirectory, testCase.directory) + batchName := frostRetainedGroupBatchFileName(1) + if testCase.addQuarantine { + batch := frostRetainedGroupWireQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + ); err != nil { + t.Fatal(err) + } + batch.BindingHash = frostActivationHex32([32]byte{0xff}) + batch.Checksum = frostActivationHex32([32]byte{}) + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = frostActivationHex32(sha256.Sum256(payload)) + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + true, + ); err != nil { + t.Fatal(err) + } + } else { + batch := frostRetainedGroupJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + ); err != nil { + t.Fatal(err) + } + batch.BindingHash = [32]byte{0xff} + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(payload) + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + true, + ); err != nil { + t.Fatal(err) + } + } + + err := fixture.openJournalError(rootDirectory) + if err == nil || !strings.Contains(err.Error(), "batch header is invalid") { + t.Fatalf("expected cross-binding batch replay rejection, got [%v]", err) + } + }) + } +} + +func TestFrostRetainedGroupJournal_QuarantineAndAuthenticatedLiftAreIndependent( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, TransactionHash: [32]byte{0xa5}, TransactionIndex: 1, LogIndex: 1, @@ -390,49 +2295,543 @@ func TestFrostRetainedGroupJournal_QuarantineAndAuthenticatedLiftAreIndependent( if err != nil { t.Fatal(err) } - if first.SnapshotGeneration != 1 || first.QuarantineGeneration != 1 || - first.QuarantineCount != 1 { - t.Fatalf("unexpected quarantined snapshot: %+v", first) + if first.SnapshotGeneration != 1 || first.QuarantineGeneration != 1 || + first.QuarantineCount != 1 { + t.Fatalf("unexpected quarantined snapshot: %+v", first) + } + if journal.directory == journal.quarantineDirectory { + t.Fatal("canonical and quarantine journals share a physical store") + } + if len(journal.mutations) != 1 || + len(journal.quarantineMutations) != 1 || + isFrostRetainedGroupQuarantineMutation(journal.mutations[0].Kind) || + !isFrostRetainedGroupQuarantineMutation(journal.quarantineMutations[0].Kind) { + t.Fatal("canonical and quarantine mutations were not durably partitioned") + } + for _, metadataPath := range []string{ + filepath.Join(journal.directory, frostRetainedGroupJournalMetadataFile), + filepath.Join(journal.quarantineDirectory, frostRetainedGroupJournalMetadataFile), + } { + if info, err := os.Lstat(metadataPath); err != nil || + !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + t.Fatalf("independent journal metadata is not durable and private: [%s] [%v]", metadataPath, err) + } + } + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + fixture.source.mutations = append(fixture.source.mutations, lift) + second, err := journal.reconcile(context.Background(), fixture.later) + if err != nil { + t.Fatal(err) + } + if second.SnapshotGeneration != 1 || second.QuarantineGeneration != 2 || + second.QuarantineCount != 0 || second.QuarantineTombstoneCount != 1 || + second.QuarantineRoot == first.QuarantineRoot || + second.QuarantineActiveRoot == first.QuarantineActiveRoot || + second.QuarantineTombstoneRoot == first.QuarantineTombstoneRoot { + t.Fatalf("unexpected lifted snapshot: %+v", second) + } + if len(journal.quarantineState.Quarantines) != 1 || + journal.quarantineState.Quarantines[0].Status != frostRetainedGroupQuarantineLifted || + len(journal.quarantineState.Tombstones) != 1 { + t.Fatal("lift did not retain its immutable record and permanent tombstone") + } +} + +func TestFrostRetainedGroupJournal_LiftCertificateRejectsBypasses( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + if err := validateJournalTestLift(journal, lift); err != nil { + t.Fatalf("valid lift certificate was rejected: [%v]", err) + } + + substitutions := map[string]struct { + mutate func(*FrostRetainedGroupMutation) + resign bool + }{ + "unsigned body substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ManifestHash[0] ^= 0xff + }, + }, + "signed manifest substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ManifestHash[0] ^= 0xff + }, + resign: true, + }, + "signed quarantine ID substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.QuarantineID[0] ^= 0xff + }, + resign: true, + }, + "signed wallet substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.WalletID[0] ^= 0xff + }, + resign: true, + }, + "signed raised evidence substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.OriginalRaisedRecord. + EvidenceHash[0] ^= 0xff + }, + resign: true, + }, + "signed raised reason substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.OriginalRaisedRecord.Reason += + " altered" + }, + resign: true, + }, + "signed recovery flag substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.OriginalRaisedRecord. + RecoveryRequired = false + }, + resign: true, + }, + "signed prior generation substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorGeneration++ + }, + resign: true, + }, + "signed prior event root substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorEventRoot[0] ^= 0xff + }, + resign: true, + }, + "signed prior active root substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorActiveRoot[0] ^= 0xff + }, + resign: true, + }, + "signed prior tombstone root substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorTombstoneRoot[0] ^= 0xff + }, + resign: true, + }, + "signed lift point substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.LiftPoint. + TransactionHash[0] ^= 0xff + }, + resign: true, + }, + "future resolution finality": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ResolutionFinality = + FrostPreSignFinality{ + BlockNumber: 16, + BlockHash: [32]byte{0x10}, + } + }, + resign: true, + }, + "resolution finality before quarantine": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ResolutionFinality = + FrostPreSignFinality{ + BlockNumber: 4, + BlockHash: [32]byte{0x04}, + } + }, + resign: true, + }, + "same-height conflicting resolution hash": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ResolutionFinality = + FrostPreSignFinality{ + BlockNumber: 15, + BlockHash: [32]byte{0xee}, + } + }, + resign: true, + }, + "unsafe canonical JSON integer": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ExpiresAtBlock = + frostRetainedGroupMaximumCanonicalJSONInteger + 1 + }, + }, + "certificate reference substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificateHash[0] ^= 0xff + }, + }, + } + for name, testCase := range substitutions { + t.Run(name, func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + testCase.mutate(&candidate) + if testCase.resign { + fixture.resignLiftMutation(t, &candidate, []int{0, 1}) + } + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("substituted lift certificate was accepted") + } + }) + } + + irrelevantFields := map[string]func(*FrostRetainedGroupMutation){ + "wallet public key hash": func(candidate *FrostRetainedGroupMutation) { + candidate.WalletPublicKeyHash = [20]byte{0x99} + }, + "whitespace reason": func(candidate *FrostRetainedGroupMutation) { + candidate.Reason = " " + }, + } + for name, mutate := range irrelevantFields { + t.Run("irrelevant "+name, func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + mutate(&candidate) + state := cloneFrostRetainedGroupQuarantineState( + journal.quarantineState, + ) + if err := applyFrostRetainedGroupQuarantineMutations( + &state, + []FrostRetainedGroupMutation{candidate}, + journal.liftPolicy, + ); err == nil { + t.Fatal("lift with an unsigned irrelevant field was accepted") + } + }) + } + + t.Run("insufficient quorum", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0}) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("one-of-three lift quorum was accepted") + } + }) + t.Run("unsorted signatures", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{1, 0}) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("unsorted lift signatures were accepted") + } + }) + t.Run("duplicate signatures", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0, 0}) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("duplicate lift signatures were accepted") + } + }) + t.Run("unknown extra signature", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0, 1}) + _, unknownPrivateKey, unknownSPKI := journalTestAuthority( + t, + "zz-unknown", + 0x7f, + ) + signatureHash := frostRetainedGroupLiftSignatureHash( + candidate.LiftCertificate.BodyHash, + ) + candidate.LiftCertificate.Signatures = append( + candidate.LiftCertificate.Signatures, + FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: "zz-unknown", + SignerPublicKeySPKI: unknownSPKI, + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign(unknownPrivateKey, signatureHash[:]), + ), + }, + ) + refreshJournalTestLiftCertificateHash(t, &candidate) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("unknown extra lift signature was accepted") + } + }) + t.Run("invalid extra signature", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0, 1, 2}) + signature, err := base64.StdEncoding.Strict().DecodeString( + candidate.LiftCertificate.Signatures[2].Signature, + ) + if err != nil { + t.Fatal(err) + } + signature[0] ^= 0xff + candidate.LiftCertificate.Signatures[2].Signature = + base64.StdEncoding.EncodeToString(signature) + refreshJournalTestLiftCertificateHash(t, &candidate) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("invalid extra lift signature was accepted") + } + }) + t.Run("noncanonical base64", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + candidate.LiftCertificate.Signatures[0].SignerPublicKeySPKI = + "\n" + candidate.LiftCertificate.Signatures[0].SignerPublicKeySPKI + refreshJournalTestLiftCertificateHash(t, &candidate) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("noncanonical base64 lift credential was accepted") + } + }) +} + +func TestFrostRetainedGroupJournal_LiftRejectsNonPrimeOrderAuthorityKeys( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + + identityKey := make(ed25519.PublicKey, ed25519.PublicKeySize) + identityKey[0] = 1 + identityKeyDER, err := x509.MarshalPKIXPublicKey(identityKey) + if err != nil { + t.Fatal(err) + } + policy := journal.liftPolicy + policy.Authorities = append( + []FrostRetainedGroupAuthority{}, + policy.Authorities..., + ) + policy.Authorities[0].PublicKeySPKIHash = + sha256.Sum256(identityKeyDER) + policy.AuthoritySetHash, err = frostRetainedGroupLiftAuthoritySetHash( + policy.AuthorityThreshold, + policy.Authorities, + ) + if err != nil { + t.Fatal(err) } - if journal.directory == journal.quarantineDirectory { - t.Fatal("canonical and quarantine journals share a physical store") + lift.LiftCertificate.Body.AuthoritySetHash = policy.AuthoritySetHash + fixture.resignLiftMutation(t, &lift, []int{0, 1}) + signatureHash := frostRetainedGroupLiftSignatureHash( + lift.LiftCertificate.BodyHash, + ) + trivialSignature := make([]byte, ed25519.SignatureSize) + trivialSignature[0] = 1 // R is the identity; S is zero. + if !ed25519.Verify( + identityKey, + signatureHash[:], + trivialSignature, + ) { + t.Fatal("test runtime no longer accepts the identity-key Ed25519 forgery") + } + lift.LiftCertificate.Signatures[0] = + FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: policy.Authorities[0].AuthorityID, + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + identityKeyDER, + ), + Signature: base64.StdEncoding.EncodeToString( + trivialSignature, + ), + } + refreshJournalTestLiftCertificateHash(t, &lift) + + if _, err := validateFrostRetainedGroupLiftCertificateShape( + policy, + lift.LiftCertificate, + ); err == nil || !strings.Contains( + err.Error(), + "nonidentity prime-order", + ) { + t.Fatalf("identity lift authority key was not rejected: [%v]", err) } - if len(journal.mutations) != 1 || - len(journal.quarantineMutations) != 1 || - isFrostRetainedGroupQuarantineMutation(journal.mutations[0].Kind) || - !isFrostRetainedGroupQuarantineMutation(journal.quarantineMutations[0].Kind) { - t.Fatal("canonical and quarantine mutations were not durably partitioned") +} + +func TestFrostRetainedGroupJournal_LiftAuthorityStrictMajority( + t *testing.T, +) { + addFourthAuthority := func( + t *testing.T, + fixture *journalTestFixture, + threshold uint64, + ) { + authority, privateKey, publicKeySPKI := journalTestAuthority( + t, + "lift-4", + 0x73, + ) + authorities := append( + []FrostRetainedGroupAuthority{}, + fixture.runtime.QuarantineJournal.LiftAuthorities..., + ) + authorities = append(authorities, authority) + fixture.runtime.QuarantineJournal.LiftAuthorityThreshold = threshold + fixture.runtime.QuarantineJournal.LiftAuthorities = authorities + fixture.quarantine = fixture.runtime.QuarantineJournal + fixture.liftPrivateKeys = append( + fixture.liftPrivateKeys, + privateKey, + ) + fixture.liftPublicKeySPKIs = append( + fixture.liftPublicKeySPKIs, + publicKeySPKI, + ) } - for _, metadataPath := range []string{ - filepath.Join(journal.directory, frostRetainedGroupJournalMetadataFile), - filepath.Join(journal.quarantineDirectory, frostRetainedGroupJournalMetadataFile), - } { - if info, err := os.Lstat(metadataPath); err != nil || - !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { - t.Fatalf("independent journal metadata is not durable and private: [%s] [%v]", metadataPath, err) + t.Run("2-of-4 rejected", func(t *testing.T) { + fixture := newJournalTestFixture(t) + addFourthAuthority(t, fixture, 2) + err := fixture.openJournalError( + filepath.Join(t.TempDir(), "journal"), + ) + if err == nil || !strings.Contains(err.Error(), "strict majority") { + t.Fatalf("expected 2-of-4 policy rejection, got [%v]", err) } - } - lift := FrostRetainedGroupMutation{ - Point: FrostRetainedGroupEventPoint{ + }) + t.Run("3-of-4 accepted", func(t *testing.T) { + fixture := newJournalTestFixture(t) + addFourthAuthority(t, fixture, 3) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + if len(lift.LiftCertificate.Signatures) != 3 { + t.Fatal("3-of-4 policy did not produce three signatures") + } + if err := validateJournalTestLift(journal, lift); err != nil { + t.Fatalf("valid 3-of-4 lift was rejected: [%v]", err) + } + }) +} + +func TestFrostRetainedGroupJournal_LiftCertificateFrozenWireVector( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ BlockNumber: 15, BlockHash: [32]byte{0x0f}, TransactionHash: [32]byte{0xaf}, TransactionIndex: 2, LogIndex: 3, }, - Kind: FrostRetainedGroupQuarantineLiftMutation, - WalletID: fixture.walletID, - QuarantineID: quarantine.QuarantineID, - AuthenticationHash: [32]byte{0x53}, - } - fixture.source.mutations = append(fixture.source.mutations, lift) - second, err := journal.reconcile(context.Background(), fixture.later) + ) + wire := frostRetainedGroupLiftCertificateToWire(lift.LiftCertificate) + canonical, err := frostRetainedGroupCanonicalValue(wire) if err != nil { t.Fatal(err) } - if second.SnapshotGeneration != 1 || second.QuarantineGeneration != 2 || - second.QuarantineCount != 0 || second.QuarantineRoot == first.QuarantineRoot { - t.Fatalf("unexpected lifted snapshot: %+v", second) + if strings.Contains( + string(canonical), + `"protocolBindingHash":[`, + ) || !strings.Contains( + string(canonical), + `"protocolBindingHash":"0x`, + ) { + t.Fatal("lift certificate did not use the explicit hex32 wire contract") + } + vectors := map[string]struct { + actual [32]byte + expected string + }{ + "authority set": { + actual: journal.liftPolicy.AuthoritySetHash, + expected: "b08dcb52b095337400460f2df2a4b490c1d59e9995a0980489eb0d5540a2ee57", + }, + "body": { + actual: lift.LiftCertificate.BodyHash, + expected: "a39727457786e7ad6bf67f3ea3cd7777c766e54d761684324bfdfa19c8030686", + }, + "certificate": { + actual: lift.LiftCertificateHash, + expected: "7000fbb0730db155daa390ba9f8a0641ebc20ce7ee1ccee34b9ff987d7501e0d", + }, + } + for name, vector := range vectors { + if fmt.Sprintf("%x", vector.actual) != vector.expected { + t.Fatalf( + "%s wire vector changed: got [%x], expected [%s]", + name, + vector.actual, + vector.expected, + ) + } } } @@ -491,6 +2890,253 @@ func TestFrostRetainedGroupJournal_IntegratesQuarantineOrphanBatchExactlyOnce( } } +func TestFrostRetainedGroupJournal_LiftCrashRecoveryAndContentAddressing( + t *testing.T, +) { + liftPoint := FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + } + t.Run("certificate-only orphan is inert", func(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine( + t, + directory, + ) + lift := fixture.liftMutation(t, journal, quarantine, liftPoint) + fixture.source.mutations = append(fixture.source.mutations, lift) + journal.persistFailureHook = func(stage string) error { + switch stage { + case "after-batch-before-state": + return nil + case "after-quarantine-lift-certificate-before-batch": + return fmt.Errorf("simulated certificate-only crash") + default: + t.Fatalf("unexpected failure stage [%s]", stage) + return nil + } + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil || !strings.Contains(err.Error(), "certificate-only") { + t.Fatalf("expected certificate-only crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if len(restarted.liftCertificates) != 1 || + frostRetainedGroupActiveQuarantineCount( + restarted.quarantineState, + ) != 1 || + len(restarted.quarantineState.Tombstones) != 0 { + t.Fatal("certificate-only orphan changed quarantine state") + } + snapshot, err := restarted.reconcile( + context.Background(), + fixture.later, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.QuarantineCount != 0 || + snapshot.QuarantineTombstoneCount != 1 { + t.Fatalf("certificate-only recovery did not lift exactly once: %+v", snapshot) + } + }) + + t.Run("certificate and batch orphan integrate exactly once", func(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine( + t, + directory, + ) + lift := fixture.liftMutation(t, journal, quarantine, liftPoint) + fixture.source.mutations = append(fixture.source.mutations, lift) + journal.persistFailureHook = func(stage string) error { + switch stage { + case "after-batch-before-state", + "after-quarantine-lift-certificate-before-batch": + return nil + case "after-quarantine-batch-before-state": + return fmt.Errorf("simulated certificate-and-batch crash") + default: + t.Fatalf("unexpected failure stage [%s]", stage) + return nil + } + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil || !strings.Contains(err.Error(), "certificate-and-batch") { + t.Fatalf("expected certificate-and-batch crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if frostRetainedGroupActiveQuarantineCount( + restarted.quarantineState, + ) != 0 || + len(restarted.quarantineState.Tombstones) != 1 || + restarted.quarantineState.BatchSequence != 2 { + t.Fatalf( + "certificate-and-batch orphan was not integrated: %+v", + restarted.quarantineState, + ) + } + snapshot, err := restarted.reconcile( + context.Background(), + fixture.later, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.QuarantineCount != 0 || + snapshot.QuarantineTombstoneCount != 1 || + restarted.quarantineState.BatchSequence != 2 { + t.Fatalf("orphan lift was not integrated exactly once: %+v", snapshot) + } + }) + + t.Run("checkpoint rejects a conflicting valid certificate rewrite", func(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine( + t, + directory, + ) + firstLift := fixture.liftMutation(t, journal, quarantine, liftPoint) + fixture.source.mutations = append( + fixture.source.mutations, + firstLift, + ) + journal.persistFailureHook = func(stage string) error { + switch stage { + case "after-batch-before-state": + return nil + case "after-quarantine-lift-certificate-before-batch": + return fmt.Errorf("simulated first certificate crash") + default: + t.Fatalf("unexpected failure stage [%s]", stage) + return nil + } + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil { + t.Fatal("expected first certificate crash") + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + secondLift := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{firstLift}, + )[0] + fixture.resignLiftMutation(t, &secondLift, []int{0, 2}) + if secondLift.LiftCertificateHash == firstLift.LiftCertificateHash { + t.Fatal("different quorum certificates have the same full digest") + } + fixture.source.mutations[len(fixture.source.mutations)-1] = + secondLift + _, err := restarted.reconcile( + context.Background(), + fixture.later, + ) + if err == nil || !strings.Contains( + err.Error(), + "independently derived semantic state", + ) { + t.Fatalf( + "expected checkpoint-bound certificate rewrite rejection, got [%v]", + err, + ) + } + if len(restarted.quarantineState.Tombstones) != 0 { + t.Fatal("conflicting certificate rewrite changed durable state") + } + }) +} + +func TestFrostRetainedGroupJournal_TombstoneRejectsReplayAndReraise( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine(t, directory) + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + fixture.source.mutations = append(fixture.source.mutations, lift) + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err != nil { + t.Fatal(err) + } + + replayState := cloneFrostRetainedGroupQuarantineState( + journal.quarantineState, + ) + if err := applyFrostRetainedGroupQuarantineMutations( + &replayState, + []FrostRetainedGroupMutation{lift}, + journal.liftPolicy, + ); err == nil { + t.Fatal("tombstoned lift replay was accepted") + } + reraise := quarantine + reraise.Point = FrostRetainedGroupEventPoint{ + BlockNumber: 18, + BlockHash: [32]byte{0x12}, + TransactionHash: [32]byte{0xb2}, + TransactionIndex: 1, + LogIndex: 1, + } + reraise.EvidenceHash = [32]byte{0x54} + if err := applyFrostRetainedGroupQuarantineMutations( + &replayState, + []FrostRetainedGroupMutation{reraise}, + journal.liftPolicy, + ); err == nil { + t.Fatal("tombstoned quarantine ID was raised again") + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if len(restarted.quarantineState.Quarantines) != 1 || + restarted.quarantineState.Quarantines[0].Status != + frostRetainedGroupQuarantineLifted || + len(restarted.quarantineState.Tombstones) != 1 { + t.Fatal("lifted record or permanent tombstone was lost on restart") + } +} + func TestApplyFrostRetainedGroupMutations_EnforcesLifecycleAndRegistryClosure( t *testing.T, ) { @@ -528,6 +3174,155 @@ func TestApplyFrostRetainedGroupMutations_EnforcesLifecycleAndRegistryClosure( } } +func TestApplyFrostRetainedGroupMutations_AllowsRepeatedStakeWeightedSeats( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + duplicate := fixture.admission + duplicate.OperatorIDs = append([]uint32{}, fixture.admission.OperatorIDs...) + duplicate.OperatorIDs[1] = duplicate.OperatorIDs[0] + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: fixture.manifest.Checkpoint, + Wallets: []frostRetainedGroupWalletState{}, + } + if err := applyFrostRetainedGroupMutations( + &state, + []FrostRetainedGroupMutation{duplicate}, + ); err != nil { + t.Fatalf("expected repeated operator seats to be retained, got [%v]", err) + } + if len(state.Wallets) != 1 || + state.Wallets[0].OperatorIDs[0] != state.Wallets[0].OperatorIDs[1] { + t.Fatalf("repeated stake-weighted seats were not preserved: [%+v]", state.Wallets) + } +} + +func TestApplyFrostRetainedGroupMutations_EnforcesWalletLimit(t *testing.T) { + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{1}}, + Wallets: []frostRetainedGroupWalletState{}, + } + err := applyFrostRetainedGroupMutations( + &state, + journalTestBoundedAdmissions(frostRetainedGroupMaximumWallets+1), + ) + if err == nil || !strings.Contains(err.Error(), "wallet limit") { + t.Fatalf("expected retained-wallet limit rejection, got [%v]", err) + } +} + +func TestValidateCompleteFrostRetainedGroupHistory_EnforcesMutationLimit( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + policy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } + history := &FrostRetainedGroupHistory{ + From: FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{1}}, + To: FrostPreSignFinality{BlockNumber: 2, BlockHash: [32]byte{2}}, + Mutations: make( + []FrostRetainedGroupMutation, + frostRetainedGroupMaximumMutations+1, + ), + } + err = validateCompleteFrostRetainedGroupHistory(history, policy) + if err == nil || !strings.Contains(err.Error(), "mutation limit") { + t.Fatalf("expected aggregate mutation limit rejection, got [%v]", err) + } +} + +func BenchmarkApplyFrostRetainedGroupMutations_MaximumWalletSet( + b *testing.B, +) { + mutations := journalTestBoundedAdmissions( + frostRetainedGroupMaximumWallets, + ) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: FrostPreSignFinality{ + BlockNumber: 1, + BlockHash: [32]byte{1}, + }, + Wallets: []frostRetainedGroupWalletState{}, + } + if err := applyFrostRetainedGroupMutations( + &state, + mutations, + ); err != nil { + b.Fatal(err) + } + } +} + +func journalTestBoundedAdmissions( + count int, +) []FrostRetainedGroupMutation { + result := make([]FrostRetainedGroupMutation, count) + operatorIDs := make([]uint32, 51) + for index := range operatorIDs { + operatorIDs[index] = uint32((index % 17) + 1) + } + for index := range result { + identifier := uint64(index + 1) + walletID := [32]byte{0xa1} + binary.BigEndian.PutUint64(walletID[24:], identifier) + walletPublicKeyHash := [20]byte{0xa2} + binary.BigEndian.PutUint64(walletPublicKeyHash[12:], identifier) + blockNumber := uint64(index + 2) + blockHash := [32]byte{0xa3} + binary.BigEndian.PutUint64(blockHash[24:], blockNumber) + submissionTransaction := [32]byte{0xa4} + binary.BigEndian.PutUint64(submissionTransaction[24:], identifier) + admissionTransaction := [32]byte{0xa5} + binary.BigEndian.PutUint64(admissionTransaction[24:], identifier) + submission := FrostRetainedGroupEventPoint{ + BlockNumber: blockNumber, + BlockHash: blockHash, + TransactionHash: submissionTransaction, + TransactionIndex: 0, + LogIndex: 1, + } + approval := FrostRetainedGroupEventPoint{ + BlockNumber: blockNumber, + BlockHash: blockHash, + TransactionHash: admissionTransaction, + TransactionIndex: 1, + LogIndex: 1, + } + creation := approval + creation.LogIndex = 2 + registration := approval + registration.LogIndex = 3 + retainedGroupHash := [32]byte{0xa6} + binary.BigEndian.PutUint64(retainedGroupHash[24:], identifier) + dkgResultHash := [32]byte{0xa7} + binary.BigEndian.PutUint64(dkgResultHash[24:], identifier) + result[index] = FrostRetainedGroupMutation{ + Point: registration, + Kind: FrostRetainedGroupAdmissionMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + OperatorIDs: append([]uint32{}, operatorIDs...), + RetainedGroupHash: retainedGroupHash, + DkgResultHash: dkgResultHash, + DkgSubmissionPoint: submission, + DkgApprovalPoint: approval, + CreationPoint: creation, + BridgeRegistrationPoint: registration, + } + } + return result +} + func lifecycleMutation( fixture *journalTestFixture, block uint64, @@ -558,9 +3353,8 @@ func TestFrostRetainedGroupJournal_RejectsIdentityMismatchAndConcurrentOwner( defer journal.close() if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, - fixture.manifest, - fixture.quarantine, + fixture.bindingHash, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -576,9 +3370,8 @@ func TestFrostRetainedGroupJournal_RejectsIdentityMismatchAndConcurrentOwner( } if _, err := newFrostRetainedGroupJournal( otherDirectory, - fixture.manifestHash, - fixture.manifest, - fixture.quarantine, + fixture.bindingHash, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -598,9 +3391,8 @@ func TestFrostRetainedGroupJournal_RejectsSymlinkEntry(t *testing.T) { } if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, - fixture.manifest, - fixture.quarantine, + fixture.bindingHash, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -760,6 +3552,164 @@ func TestPersistFrostRetainedGroupEnvelopeAt_PreservesInternalFiles( } } +func TestFrostRetainedGroupJournal_RecoversInterruptedTemporaryFiles( + t *testing.T, +) { + type component struct { + directory string + stateFile string + metadataFile string + } + components := map[string]component{ + "canonical": { + directory: frostRetainedGroupCanonicalDirectory, + stateFile: frostRetainedGroupJournalStateFile, + metadataFile: frostRetainedGroupJournalMetadataFile, + }, + "quarantine": { + directory: frostRetainedGroupQuarantineDirectory, + stateFile: frostRetainedGroupJournalStateFile, + metadataFile: frostRetainedGroupJournalMetadataFile, + }, + "checkpoint": { + directory: frostRetainedGroupCheckpointDirectory, + stateFile: frostRetainedGroupCheckpointStateFile, + metadataFile: frostRetainedGroupCheckpointMetadataFile, + }, + } + stages := map[string]struct { + finalName func(component) string + prepare func(string, string) error + }{ + "partial before sync": { + finalName: func(component component) string { + return component.stateFile + }, + prepare: func(_ string, temporaryPath string) error { + return os.WriteFile(temporaryPath, []byte("{"), 0600) + }, + }, + "synced before publication": { + finalName: func(component component) string { + return component.stateFile + }, + prepare: func(finalPath string, temporaryPath string) error { + data, err := os.ReadFile(finalPath) + if err != nil { + return err + } + return os.WriteFile(temporaryPath, data, 0600) + }, + }, + "linked before temporary unlink": { + finalName: func(component component) string { + return component.metadataFile + }, + prepare: func(finalPath string, temporaryPath string) error { + return os.Link(finalPath, temporaryPath) + }, + }, + } + + for stageName, stage := range stages { + for componentName, component := range components { + t.Run(stageName+"/"+componentName, func(t *testing.T) { + fixture := newJournalTestFixture(t) + rootDirectory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, rootDirectory) + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err != nil { + t.Fatal(err) + } + expectedCanonicalGeneration := + journal.state.SnapshotGeneration + expectedQuarantineGeneration := + journal.quarantineState.Generation + expectedCheckpointSequence := + journal.checkpointState.Sequence + if err := journal.close(); err != nil { + t.Fatal(err) + } + + finalName := stage.finalName(component) + directory := filepath.Join( + rootDirectory, + component.directory, + ) + finalPath := filepath.Join(directory, finalName) + temporaryName := finalName + "-" + + strings.Repeat("ab", 16) + + frostRetainedGroupJournalTempSuffix + temporaryPath := filepath.Join(directory, temporaryName) + if err := stage.prepare( + finalPath, + temporaryPath, + ); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, rootDirectory) + defer restarted.close() + if restarted.state.SnapshotGeneration != + expectedCanonicalGeneration || + restarted.quarantineState.Generation != + expectedQuarantineGeneration || + restarted.checkpointState.Sequence != + expectedCheckpointSequence { + t.Fatalf( + "temporary-file recovery changed committed state: canonical=[%d] quarantine=[%d] checkpoint=[%d]", + restarted.state.SnapshotGeneration, + restarted.quarantineState.Generation, + restarted.checkpointState.Sequence, + ) + } + if _, err := os.Lstat(temporaryPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf( + "interrupted temporary file was not removed: [%v]", + err, + ) + } + if _, err := os.Lstat(finalPath); err != nil { + t.Fatalf( + "committed journal file was lost during recovery: [%v]", + err, + ) + } + }) + } + } +} + +func TestFrostRetainedGroupJournal_RejectsMalformedTemporaryFileName( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + rootDirectory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, rootDirectory) + if err := journal.close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join( + rootDirectory, + frostRetainedGroupCanonicalDirectory, + "unexpected.tmp", + ), + []byte("partial"), + 0600, + ); err != nil { + t.Fatal(err) + } + if err := fixture.openJournalError(rootDirectory); err == nil { + t.Fatal("malformed journal temporary file name was discarded") + } +} + func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T) { t.Run("quarantine batch checksum", func(t *testing.T) { fixture := newJournalTestFixture(t) @@ -810,9 +3760,8 @@ func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T } if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, - fixture.manifest, - fixture.quarantine, + fixture.bindingHash, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -834,9 +3783,8 @@ func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T } if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, - fixture.manifest, - fixture.quarantine, + fixture.bindingHash, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 4baf6350f2..214ae663f0 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -254,11 +254,28 @@ func newNode( "cannot enable production FROST pre-sign authorization without an activation manifest", ) } + verifierSource, ok := config.FrostRetainedGroupHistorySource.(FrostPreSignEthereumEvidenceVerifierSource) + if !ok { + return nil, fmt.Errorf( + "cannot enable production FROST pre-sign authorization without an independent Ethereum evidence verifier", + ) + } + ethereumEvidenceVerifier, err := + verifierSource.FrostPreSignEthereumEvidenceVerifier( + context.Background(), + ) + if err != nil { + return nil, fmt.Errorf( + "cannot obtain independent FROST Ethereum evidence verifier: [%w]", + err, + ) + } configuredProfile, err := configurator.ConfigureFrostPreSignAuthorization( context.Background(), config.FrostPreSignActivationManifestPath, config.FrostPreSignActivationEnvelopeSignerKeyHash, config.FrostPreSignLinkedLibraryDescriptorSetHash, + ethereumEvidenceVerifier, ) if err != nil { return nil, fmt.Errorf( @@ -717,11 +734,44 @@ func newNode( "cannot enable FROST activation handshake without an independent retained-group history source", ) } + evidenceBinder, ok := config.FrostRetainedGroupHistorySource.(FrostRetainedGroupActivationEvidenceBinder) + if !ok { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST activation handshake without a manifest-bound retained-group evidence source", + ) + } + if err := evidenceBinder.BindFrostRetainedGroupActivationEvidence( + verifiedActivationProfile, + runtimeManifest, + ); err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind retained-group evidence to the authenticated activation manifest: [%w]", + err, + ) + } + bindingSource, ok := + config.FrostRetainedGroupHistorySource.(FrostRetainedGroupProtocolBindingSource) + if !ok { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST activation handshake without a protocol-bound retained-group source", + ) + } + retainedGroupBindingHash, err := + bindingSource.FrostRetainedGroupProtocolBindingHash() + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot read retained-group protocol binding: [%w]", + err, + ) + } journal, err := newFrostRetainedGroupJournal( config.FrostRetainedGroupJournalDirectory, - runtimeManifest.ManifestHash, - runtimeManifest.CanonicalJournal, - runtimeManifest.QuarantineJournal, + retainedGroupBindingHash, + runtimeManifest, config.FrostRetainedGroupHistorySource, walletRegistry, operatorAddress, @@ -776,6 +826,7 @@ func newNode( pointVerifier, storeBinding, outbox, + journal, readiness, ) if err != nil { diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 3e7e8c1664..96e8334ebd 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -165,7 +165,12 @@ type Config struct { // FrostRetainedGroupHistorySource is an independently authenticated, // receipt-complete source. It must not share the primary Ethereum adapter's // trust domain, endpoint, operator, or history store. - FrostRetainedGroupHistorySource FrostRetainedGroupHistorySource + FrostRetainedGroupHistorySource FrostRetainedGroupHistorySource `mapstructure:"-"` + // FrostRetainedGroupHistory configures the production signed, paginated + // retained-group export and its independent finalized Ethereum verifier. + // The start command constructs FrostRetainedGroupHistorySource from this + // configuration before TBTC initialization whenever FROST activation is on. + FrostRetainedGroupHistory FrostRetainedGroupHistorySourceConfig // FrostNativeSignerAnchorURL is the exact authenticated checkpoint-service // endpoint. HTTPS uses normal PKIX validation plus the manifest-pinned leaf // SPKI. Plain HTTP is accepted only for a canonical numeric loopback host.