diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml index c17eb9b841..44fbd10a8c 100644 --- a/.github/workflows/nightly-fuzz.yml +++ b/.github/workflows/nightly-fuzz.yml @@ -117,6 +117,12 @@ jobs: - name: driver-config-key-field-round-trip pkg: ./token/driver func: FuzzConfigKeyFieldRoundTrip + - name: storage-integrity-stored-token-request + pkg: ./token/services/storage/integrity + func: FuzzCheckStoredTokenRequest + - name: storage-integrity-token-request-actions + pkg: ./token/services/storage/integrity + func: FuzzCheckTokenRequestActions steps: - name: Checkout code diff --git a/docs/README.md b/docs/README.md index 59fe04938b..8144a62c90 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Welcome to Panurus documentation. * [**HTLC Deadlines and Clock Synchronisation**](security/htlc_deadline_clock_assumptions.md): The clock-synchronisation assumption that the HTLC claim/reclaim deadline rests on, and the deadline margin it requires of a deployment. * [**Selector Resource Limits**](security/selector_resource_limits.md): How to throttle token selection by supplying a custom `Locker`. +* [**Store Integrity Verification**](security/store_integrity_verification.md): What each store verifies about the payloads it persists, what it requires of its caller, and what it deliberately does not check. ## Command-Line Tools diff --git a/docs/security/store_integrity_verification.md b/docs/security/store_integrity_verification.md new file mode 100644 index 0000000000..108253cb65 --- /dev/null +++ b/docs/security/store_integrity_verification.md @@ -0,0 +1,154 @@ +# Store Integrity Verification + +The store services persist the payloads that Panurus later treats as evidence: the token request a +transaction is re-validated against, the public parameters it is validated *under*, the identity a +signer is bound to, the acknowledgement that a party endorsed a transaction. This page states, per +asset class, **what a store verifies, what it requires of its caller, and what it deliberately does +not check** — so that a reader of a store method knows which of the three applies without reading +its implementation. + +The checks themselves live in one backend-agnostic package, +[`token/services/storage/integrity`](../../token/services/storage/integrity), and each store method +that applies one carries a `Verification:` clause in its Godoc naming it. Both are greppable on +purpose: `grep -rn "Verification:"` enumerates the contract, and `grep -rn integrity.Check` +enumerates the enforcement. + +## Scope + +This is a **structural** integrity boundary, not a second validator. The checks need only the bytes +themselves plus the key those bytes are stored under; none of them performs I/O, consults a ledger, +or evaluates a zero-knowledge proof. A token request is fully verified only by a `token.Validator` +against a ledger, and that happens where it already happened — see +[Endorsement responder security](../services/ttx_responder_security.md). + +What the boundary buys is that a payload which **could not have been produced by a correct caller** +is refused rather than stored, and a payload which **could not honestly be attributed to the row it +was found in** is reported rather than returned. The failures it catches are systemic ones: a +truncated or garbled blob, a row swap, a wrong-row read on a hash-addressed table, a write that +skipped the validating path, an empty value standing in for a real one. + +Out of scope: trust-chain and revocation validation, semantic validation of actions, and anything +requiring a network round-trip from inside a store call. + +## Asset classes + +| Asset | At insert | On retrieval | Caller must have done | +|---|---|---|---| +| Token request (`ttxdb`, `auditdb` — `TokenRequestWithMetadata`) | Non-empty `tx_id`, non-empty request, non-empty `pp_hash` | Deserializes, declares a supported protocol version, and its **anchor equals the `tx_id` it is filed under** | Serialized it from a live `token.Request` (the marshal round-trip is what makes the insert-side re-parse redundant) | +| Token request (`endorserdb` — bare actions and signatures) | Non-empty `tx_id`/request/`pp_hash`, deserializes at a supported version, **carries at least one action** | — (this format has no anchor; see below) | Validated it — `validator.UnmarshallAndVerifyWithMetadata`, and taken `pp_hash` from the *local* TMS, not from the peer | +| Public parameters (`tokendb`) | Hash computed by the store from the bytes it is storing | **Recomputed and compared against the hash the row is filed under** | — | +| Identity (`identitydb`, both backends) | Non-empty | Non-empty, and the stored identity **compared against the requested one** (`GetAuditInfo`, `GetTokenInfo`, and `GetSignerInfo` on SQL) | Matched it against its audit info, and — at `wallet.Service` — established that an owner verifier can be derived from it | +| Signer registration (`token.SignatureService`) | Identity non-empty **and** a verifier derivable from it in some role | — | — | +| Endorsement acknowledgement (`tx_ends`) | Non-empty endorser, non-empty signature | — (the signed message is not persisted; see below) | **Verified `sigma` against the exact payload it sent to that party** — `CollectEndorsementsView.distributeTxToParty` does | +| Movements, transaction records, token locks, application/public metadata, audit info blobs, `IdentityConfiguration` raw | — | — | Everything: these are stored as given | + +The last row is a deliberate posture, not an omission. These classes are high-volume, individually +low-value, and — decisively — have **no cheap self-consistency predicate**: there is no field in a +movement row that a structural check could disagree with. Adding a check with no predicate to +evaluate would cost write throughput and catch nothing. + +## Requirements + +1. **Callers of `endorserdb.AppendValidationRecord` MUST validate the token request first**, and + MUST take `pp_hash` from their own TMS rather than from the peer that sent the request. The store + checks that the payload is a deserializable request with at least one action; it cannot check + that the actions are legal. +2. **Callers of `AddTransactionEndorsementAck` MUST verify the signature** against the payload they + sent to that endorser, before storing. The store checks only that an endorser and a signature are + present. +3. **No caller may pass an empty identity** to the identity store. See the next section for why this + is a correctness requirement and not a tidiness one. +4. **The checks MUST remain unconditional.** No functional option, no setter, no configuration key, + no build tag may turn one off. This is enforced by + [`nobypass_test.go`](../../token/services/storage/integrity/nobypass_test.go), which reads the + source of every package applying a check and fails on an identifier named like a bypass, on a + variadic parameter added to a check, on mutable package-level state in the `integrity` package, + on a verification-related configuration key, and on a check whose error is discarded. + +## What is deliberately not checked, and why + +Three of these are structural limits rather than choices, and each one is a place where the obvious +check does not exist to be added. + +**`pp_hash` is not a digest of the request.** It is the hash of the *public parameters* the request +was created under. It therefore provides no integrity for the `request` column, and no +retrieval-time "does the stored hash match the stored bytes" check is possible for token requests. +What *is* available, and is what the retrieval-side check uses, is the request's own **anchor**: the +transaction id the request commits to, covered by the signatures inside it. Comparing the anchor +against the `tx_id` the row is keyed by is what ties the bytes to the row they were found in, and it +catches truncation, encoding drift, and row swaps. The bare actions-and-signatures format held by +`endorserdb` carries no anchor at all, so records in that format cannot be bound to their +transaction id by a structural check — only by the caller's validation. + +**The message an endorsement acknowledgement signs is not persisted.** `tx_ends` holds +`(endorser, sigma)`. The signed message is the *per-party filtered* transaction payload, different +for each endorser, and it is not stored anywhere. Retrieval-time re-verification of an ack therefore +has nothing to verify against, and adding it requires a schema change — a persisted digest of the +endorsed message — which is tracked separately. Until then, the posture is: verified at insert by +the only producer, contract-documented as not re-verifiable on read. The store does refuse an empty +endorser or an empty signature, which matters more than it looks: +`ttx.TransactionInfo` presents acks as a map keyed by endorser and its consumers do not inspect the +values, so a row with an empty signature reads as "this party signed". + +**A supplied verifier is not compared against the identity it is registered for.** `driver.Verifier` +exposes only `Verify(message, sigma)` — there is no canonical public key to compare, so establishing +agreement would require a new accessor implemented across every identity type (x509, idemix, +htlc, multisig, boolpolicy). What `token.SignatureService.RegisterSigner` enforces instead is the +substantive, non-tautological part: the identity is non-empty, and *some* verifier can be derived +from it by this driver. That rules out binding a signer to bytes no verifier can be built from — +an identity that can sign but whose signatures nothing can check. The comparison itself would be a +tautology for the in-tree callers that pass a verifier at all: the x509 and idemix key managers +derive it from the identity they are registering, and the `ttx` callers pass `nil`. + +**The KVS identity backend cannot compare signer identities.** It does not store the identity +alongside the signer info, so `GetSignerInfo` there refuses an empty identity but cannot verify that +the record found under an identity hash belongs to the requested identity. The SQL backend does both. +This asymmetry is recorded on the KVS method itself; the shared spec in +`token/services/storage/db/dbtest` holds both backends to everything they *can* both do. + +## Why this is sufficient + +The empty-identity guards are the least obvious and the most load-bearing, so they are worth stating +plainly. Identity rows and identity caches are keyed by `Identity.UniqueID()`, and for an empty +identity that function returns the literal string `` — **not** a hash. Every empty identity +therefore collapses onto one row and one cache entry. Without the guard, one caller's audit info, +token metadata, or signer info is readable by any other empty-identity lookup, and a signer +registered for one is returned for another. This is why the guard sits on ephemeral registrations +too, which write nothing to storage but populate the same caches. + +The hash-addressed read paths are the second load-bearing case. `GetAuditInfo`, `GetTokenInfo`, and +`GetSignerInfo` locate a row by `identity_hash`, and `PublicParamsByHash` by `raw_hash` — in each +case the caller names a value, the store looks up a digest of it, and before these checks nothing +compared the row it found against what the caller asked for. Comparing them turns a hash-addressed +read into an authenticated one at the cost of a byte comparison, or one SHA-256 for public +parameters. + +For the remaining classes, the argument is that the check is applied where the information exists. +Every insert path either just serialized the payload from an in-memory object or just validated it +in the caller's own scope; re-parsing there would cost time proportional to the payload and learn +nothing. Every retrieval path hands bytes to a caller that is about to treat them as authentic +evidence about a specific transaction, and pays one unmarshal to establish that they are. + +## If the requirements are not met + +The checks are **fail-closed**: a payload that fails one is not stored, and a row that fails one is +not returned. Retrieval-side failures are logged at ERROR before the error is returned, because they +indicate storage corruption or out-of-band modification rather than a caller mistake, and nothing +downstream will surface them a second time. + +A caller that skips its own obligations from the Requirements section above is *not* caught by these +checks — that is what makes them obligations. An unvalidated token request that deserializes and +carries an action passes the `endorserdb` check and is filed as validated; an acknowledgement with a +signature that verifies against nothing passes the `tx_ends` check and is filed as an endorsement. +Both would then be believed by anything reading those stores. The store-level checks narrow what a +skipped verification can look like; they do not substitute for it. + +## Related + +* [Endorsement responder security](../services/ttx_responder_security.md) — where token requests + arriving over the wire are actually validated. +* [Storage Service](../services/storage.md) — the schema these checks apply to. +* [Public Parameters Lifecycle](../public_parameters.md) — why parameters are addressed by hash. +* [Identity Service](../services/identity.md) — identity registration and the deserializer. +* [Storage DB Schema Upgradability](../upgradability.md#storage-db-schema-upgradability) — why a + persisted digest column is a migration question and not a code change. diff --git a/docs/services/identity.md b/docs/services/identity.md index 11493a2a8f..068496104b 100644 --- a/docs/services/identity.md +++ b/docs/services/identity.md @@ -254,6 +254,35 @@ any other resource, give it a `Close()` method and it will be released automatic > **Note:** tests that exercise an anonymous owner wallet should `t.Cleanup(w.Close)`, > otherwise each test leaves a provisioning goroutine behind for the rest of the run. +## Identity Registration Checks + +Every path that binds something to an identity — audit info, token metadata, signer info, a signer — +first requires the identity to be **non-empty**, and refuses it outright otherwise. This is not +input tidying. Identity rows and the provider's caches are keyed by `Identity.UniqueID()`, which maps +the empty identity to the literal string `` rather than to a hash, so every empty identity +would collapse onto one row and one cache entry: one caller's audit info would be readable by any +other empty-identity lookup, and a signer registered for one would be handed back for another. The +guard therefore also applies to ephemeral registrations, which write nothing to storage but populate +the same caches. + +Two further checks apply where the information to make them exists: + +* `wallet.Service.RegisterRecipientIdentity` matches the recipient identity against its audit info + (`Deserializer.MatchIdentity`) and requires that an **owner verifier can be derived from it**. An + identity no verifier can be built from is one whose tokens could never be spent, and both checks + route through the same typed-identity deserializer, so the second cannot reject an identity the + first accepts. +* `token.SignatureService.RegisterSigner` and `RegisterEphemeralSigner` require that **some** + verifier — owner, issuer, or auditor — is derivable from the identity a signer is being bound to. + They deliberately do not compare a supplied `Verifier` against the identity; `driver.Verifier` + exposes only `Verify`, so there is no canonical key to compare. + +On the read side, `GetAuditInfo`, `GetTokenInfo`, and (on the SQL backend) `GetSignerInfo` locate a +row by identity hash and then compare the **stored** identity against the requested one before +returning or caching anything, so a hash-addressed read cannot silently return another identity's +data. For the full posture, including the KVS backend's inability to make the last comparison, see +[**Store Integrity Verification**](../security/store_integrity_verification.md). + ## Identity Types The Identity Service leverages a wrapper called **TypedIdentity** to support various identity schemes uniformly. diff --git a/docs/services/storage.md b/docs/services/storage.md index ce88041c14..5f40a52a0f 100644 --- a/docs/services/storage.md +++ b/docs/services/storage.md @@ -220,6 +220,20 @@ The Storage Service follows a "Finality-Driven" update strategy. While transacti The `TokenDB` and `Movements` tables are typically updated only when the **Network Service** confirms that a transaction has reached finality on the ledger. This ensures that the local view of the "Token Landscape" always reflects the ground truth of the distributed ledger. +## Integrity Verification + +A store does not persist whatever it is handed. Before a high-value payload is written, and again +before it is handed back, the store applies a set of structural checks — that a token request is +anchored to the transaction id it is filed under, that public parameters hash to the hash they are +addressed by, that a hash-addressed identity row belongs to the identity that was asked for. The +checks are unconditional and fail-closed, and the ones a store *does not* perform are the caller's +documented obligation rather than an unstated gap. + +Each store method that applies a check names it in a `Verification:` clause in its Godoc, and the +checks themselves live in `token/services/storage/integrity`. For the full per-asset-class posture — +including what is deliberately not checked and why — see +[**Store Integrity Verification**](../security/store_integrity_verification.md). + ## Transaction Recovery Service The Storage Service includes a **Transaction Recovery Service** that provides the core recovery mechanism for handling pending transactions that may have lost their finality listeners due to node restarts, network interruptions, or other failures. diff --git a/docs/services/storage/endorserdb.md b/docs/services/storage/endorserdb.md index 4ff06d015f..cc126b1cc5 100644 --- a/docs/services/storage/endorserdb.md +++ b/docs/services/storage/endorserdb.md @@ -77,6 +77,22 @@ err := store.AppendValidationRecord( ) ``` +Unlike TTXDB and AuditDB, which are handed an already-deserialised `*token.Request`, this store +receives **raw token request bytes taken off the wire**. It therefore checks them before filing them +as a validated request: `txID`, `tokenRequest`, and `ppHash` must all be non-empty, and +`tokenRequest` must deserialise at a supported protocol version and carry **at least one action**. A +validation record asserting that a request was validated is worthless if the request it names carries +nothing to validate. + +The check is not a substitute for validation. The caller +(`token/services/network/fabric/endorsement/fsc/responder.go`) must still run +`validator.UnmarshallAndVerifyWithMetadata` and take `ppHash` from its **own** TMS rather than from +the peer that sent the request. The store's checks narrow what a skipped validation can look like; +they cannot tell whether the actions are legal. Note also that this format is the bare +actions-and-signatures encoding, which carries no anchor, so — unlike TTXDB — a record here cannot be +bound to its transaction id by a structural check. See +[**Store Integrity Verification**](../../security/store_integrity_verification.md). + ### Querying Validation Records ```go @@ -209,4 +225,5 @@ If you're migrating code that previously used ttxdb for validation records: ## See Also - [Storage Services Overview](../storage.md) -- [TTXDB/AuditDB Documentation](ttxdb.md) \ No newline at end of file +- [TTXDB/AuditDB Documentation](ttxdb.md) +- [Store Integrity Verification](../../security/store_integrity_verification.md) \ No newline at end of file diff --git a/docs/services/storage/ttxdb.md b/docs/services/storage/ttxdb.md index bc62a68c16..133809e4f8 100644 --- a/docs/services/storage/ttxdb.md +++ b/docs/services/storage/ttxdb.md @@ -38,6 +38,21 @@ It is also possible to append just the transaction records corresponding to a gi } ``` +### Integrity of appended requests + +Appending refuses a request that could not later be checked: an empty transaction id, empty request +bytes, or an empty public parameters hash are all errors rather than rows. On the way back out, +`GetTokenRequest` and `GetTokenRequests` deserialize the stored payload and require its **anchor to +equal the transaction id it is filed under** — callers treat a retrieved request as authentic +evidence about that transaction, so a payload that is truncated, encoded at an unsupported protocol +version, or anchored to a different transaction is reported as an error instead of being returned. A +transaction id with no stored request is still reported as "not found" (`nil`, no error), unchanged. + +Note that the `pp_hash` column is the hash of the *public parameters*, not of the request bytes, so it +is not what makes the retrieved request checkable — the anchor is. See +[**Store Integrity Verification**](../../security/store_integrity_verification.md) for the reasoning +and for the endorsement-acknowledgement posture. + ## Payments The following example shows how to retrieve the total amount of last 10 payments made by a given diff --git a/token/services/auditor/auditor_internal_test.go b/token/services/auditor/auditor_internal_test.go index c592f9d064..02be227921 100644 --- a/token/services/auditor/auditor_internal_test.go +++ b/token/services/auditor/auditor_internal_test.go @@ -227,6 +227,7 @@ func newInternalTestTMS(t *testing.T, toks []*token2.Token) (*token.ManagementSe mockPP := &drivermock.PublicParameters{} mockPP.PrecisionReturns(64) mockPPM.PublicParametersReturns(mockPP) + mockPPM.PublicParamsHashReturns([]byte("pp-hash")) mockTMS.PublicParamsManagerReturns(mockPPM) mockTMS.TokensServiceReturns(&drivermock.TokensService{}) @@ -343,6 +344,7 @@ func TestCompleteInputsWithEmptyEID_ListTokensError(t *testing.T) { mockPP := &drivermock.PublicParameters{} mockPP.PrecisionReturns(64) mockPPM.PublicParametersReturns(mockPP) + mockPPM.PublicParamsHashReturns([]byte("pp-hash")) mockTMS.PublicParamsManagerReturns(mockPPM) mockTMS.ValidatorReturns(&drivermock.Validator{}, nil) mockTMS.TokensServiceReturns(&drivermock.TokensService{}) @@ -378,6 +380,7 @@ func TestCompleteInputsWithEmptyEID_ToQuantityError(t *testing.T) { mockPP := &drivermock.PublicParameters{} mockPP.PrecisionReturns(64) mockPPM.PublicParametersReturns(mockPP) + mockPPM.PublicParamsHashReturns([]byte("pp-hash")) mockTMS.PublicParamsManagerReturns(mockPPM) mockTMS.ValidatorReturns(&drivermock.Validator{}, nil) mockTMS.TokensServiceReturns(&drivermock.TokensService{}) diff --git a/token/services/auditor/auditor_test.go b/token/services/auditor/auditor_test.go index 23023d1258..29c889c359 100644 --- a/token/services/auditor/auditor_test.go +++ b/token/services/auditor/auditor_test.go @@ -14,7 +14,9 @@ import ( "time" "github.com/LFDT-Panurus/panurus/token" + "github.com/LFDT-Panurus/panurus/token/driver" drivermock "github.com/LFDT-Panurus/panurus/token/driver/mock" + "github.com/LFDT-Panurus/panurus/token/driver/protos-go/v1/request" tokenmock "github.com/LFDT-Panurus/panurus/token/mock" "github.com/LFDT-Panurus/panurus/token/services/auditor" auditmock "github.com/LFDT-Panurus/panurus/token/services/auditor/mock" @@ -23,6 +25,7 @@ import ( "github.com/LFDT-Panurus/panurus/token/services/storage/auditdb" auditdbmock "github.com/LFDT-Panurus/panurus/token/services/storage/auditdb/mock" dbdriver "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/LFDT-Panurus/panurus/token/services/tokens" depmock "github.com/LFDT-Panurus/panurus/token/services/ttx/dep/mock" token2 "github.com/LFDT-Panurus/panurus/token/token" @@ -30,6 +33,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace/noop" + "google.golang.org/protobuf/proto" ) // fakeServiceProvider is a simple test stub implementing token.ServiceProvider. @@ -57,6 +61,7 @@ func newTestManagementService(t *testing.T) *token.ManagementService { mockPP := &drivermock.PublicParameters{} mockPP.PrecisionReturns(64) mockPPM.PublicParametersReturns(mockPP) + mockPPM.PublicParamsHashReturns([]byte("pp-hash")) mockTMS.PublicParamsManagerReturns(mockPPM) mockTMS.TokensServiceReturns(&drivermock.TokensService{}) @@ -237,8 +242,22 @@ func TestService_GetStatus_Error(t *testing.T) { assert.ErrorIs(t, err, expectedErr) } +// storedTokenRequest builds the wire format the audit store holds: a +// TokenRequestWithMetadata anchored to the passed anchor. +func storedTokenRequest(t *testing.T, anchor string) []byte { + t.Helper() + raw, err := proto.Marshal(&request.TokenRequestWithMetadata{ + Version: uint32(driver.ProtocolV1), + Anchor: anchor, + Request: &request.TokenRequest{Version: uint32(driver.ProtocolV1)}, + }) + require.NoError(t, err) + + return raw +} + func TestService_GetTokenRequest_Success(t *testing.T) { - data := []byte("raw-token-request") + data := storedTokenRequest(t, "tx-tok") fakeStore := newFakeStore() fakeStore.GetTokenRequestReturns(data, nil) svc := newTestService(newTestStoreService(t, fakeStore), nil) @@ -247,6 +266,29 @@ func TestService_GetTokenRequest_Success(t *testing.T) { assert.Equal(t, data, got) } +// TestService_GetTokenRequest_AnchorMismatch checks that a stored request +// belonging to another transaction is reported as an error rather than returned +// as this transaction's request. +func TestService_GetTokenRequest_AnchorMismatch(t *testing.T) { + fakeStore := newFakeStore() + fakeStore.GetTokenRequestReturns(storedTokenRequest(t, "tx-other"), nil) + svc := newTestService(newTestStoreService(t, fakeStore), nil) + got, err := svc.GetTokenRequest(context.Background(), "tx-tok") + require.ErrorIs(t, err, integrity.ErrAnchorMismatch) + assert.Nil(t, got) +} + +// TestService_GetTokenRequest_Malformed checks that bytes that are not a token +// request at all are refused rather than handed to the caller. +func TestService_GetTokenRequest_Malformed(t *testing.T) { + fakeStore := newFakeStore() + fakeStore.GetTokenRequestReturns([]byte("raw-token-request"), nil) + svc := newTestService(newTestStoreService(t, fakeStore), nil) + got, err := svc.GetTokenRequest(context.Background(), "tx-tok") + require.ErrorIs(t, err, integrity.ErrMalformedTokenRequest) + assert.Nil(t, got) +} + func TestService_GetTokenRequest_Error(t *testing.T) { expectedErr := errors.New("not found") fakeStore := newFakeStore() diff --git a/token/services/identity/driver/storage.go b/token/services/identity/driver/storage.go index 29670aef7e..472b48f0af 100644 --- a/token/services/identity/driver/storage.go +++ b/token/services/identity/driver/storage.go @@ -178,20 +178,47 @@ type IdentityStoreService interface { // Notifier returns an IdentityConfigurationNotifier for this store to subscribe to configuration changes. Notifier() (IdentityConfigurationNotifier, error) // StoreIdentityData stores the passed identity and token information + // + // Verification: an implementation must refuse an empty id — see + // integrity.CheckIdentity. Rows are addressed by driver.Identity.UniqueID, + // which maps the empty identity to the constant "" rather than to a + // hash, so every empty identity shares one row: one caller's audit info + // would be readable by any other empty-identity lookup. StoreIdentityData(ctx context.Context, id []byte, identityAudit []byte, tokenMetadata []byte, tokenMetadataAudit []byte) error // GetAuditInfo retrieves the audit info bounded to the given identity + // + // Verification: an empty id is refused, and — because the row is addressed + // by identity hash rather than by the identity itself — the identity stored + // alongside the audit info must be compared against id before the audit info + // is returned or cached (see integrity.CheckIdentityMatch). Audit info is + // what attributes a transaction to a party, so audit info belonging to a + // different identity misattributes it. GetAuditInfo(ctx context.Context, id []byte) ([]byte, error) // GetTokenInfo returns the token information related to the passed identity + // + // Verification: as for GetAuditInfo. GetTokenInfo(ctx context.Context, id []byte) ([]byte, []byte, error) // StoreSignerInfo stores the passed signer info and bound it to the given identity + // + // Verification: an empty id is refused, for the reason given on + // StoreIdentityData. StoreSignerInfo(ctx context.Context, id driver.Identity, info []byte) error // GetExistingSignerInfo returns the hashes of the identities for which StoreSignerInfo was called GetExistingSignerInfo(ctx context.Context, ids ...driver.Identity) ([]string, error) // SignerInfoExists returns true if StoreSignerInfo was called on input the given identity SignerInfoExists(ctx context.Context, id []byte) (bool, error) // GetSignerInfo returns the signer info bound to the given identity + // + // Verification: as for GetAuditInfo. Signer info is what a key manager + // resolves into a signer, so returning another identity's signer info would + // route signing to the wrong key. GetSignerInfo(ctx context.Context, id []byte) ([]byte, error) // RegisterIdentityDescriptor registers a descriptor for an identity and associates it with an alias + // + // Verification: a nil descriptor and an empty descriptor.Identity are + // refused, for the reason given on StoreIdentityData. This holds even for an + // ephemeral descriptor, which writes nothing to storage but still populates + // the in-memory caches, which are keyed the same way. RegisterIdentityDescriptor(ctx context.Context, descriptor *IdentityDescriptor, alias driver.Identity) error // IterateSigners returns a page of SignerEntry values from the Signers table ordered by // identity_hash, starting at the given offset and returning at most limit entries. diff --git a/token/services/identity/provider.go b/token/services/identity/provider.go index 0952394cbb..9965824bfe 100644 --- a/token/services/identity/provider.go +++ b/token/services/identity/provider.go @@ -14,6 +14,7 @@ import ( "github.com/LFDT-Panurus/panurus/token/driver" idriver "github.com/LFDT-Panurus/panurus/token/services/identity/driver" "github.com/LFDT-Panurus/panurus/token/services/logging" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/hyperledger-labs/fabric-smart-client/platform/common/utils/cache/secondcache" "github.com/hyperledger-labs/fabric-smart-client/platform/common/utils/collections" @@ -124,7 +125,20 @@ func (p *Provider) SetSignerRouter(router *SignerRouter) { } // RegisterRecipientData stores the passed recipient data in the configured storage. +// +// Verification: the identity must be non-empty. Identities are keyed by their +// unique id, and the unique id of an empty identity is a fixed string rather +// than a hash of any content, so every empty identity collides on one key: the +// audit info registered for one of them is what a later lookup for any other +// returns. See docs/security/store_integrity_verification.md. func (p *Provider) RegisterRecipientData(ctx context.Context, data *driver.RecipientData) error { + if data == nil { + return errors.New("cannot register nil recipient data") + } + if err := integrity.CheckIdentity(data.Identity); err != nil { + return errors.WithMessage(err, "refusing to register recipient data") + } + return p.storage.StoreIdentityData(ctx, data.Identity, data.AuditInfo, data.TokenMetadata, data.TokenMetadataAuditInfo) } @@ -227,7 +241,21 @@ func (p *Provider) RollbackPartialRecipientRegistration(ctx context.Context, id // RegisterIdentityDescriptor stores the given identity descriptor in the configured storage. // If alias is not nil, the alias can be used as an alternative to `idriver.IdentityDescriptor#Identity`. +// +// Verification: the descriptor's identity must be non-empty. This is checked +// here rather than only in the storage layer because an ephemeral descriptor +// never reaches storage and yet still populates the signer cache, which is +// keyed the same way — an empty identity would install a signer under the key +// shared by every empty identity. See +// docs/security/store_integrity_verification.md. func (p *Provider) RegisterIdentityDescriptor(ctx context.Context, identityDescriptor *idriver.IdentityDescriptor, alias driver.Identity) error { + if identityDescriptor == nil { + return errors.New("cannot register nil identity descriptor") + } + if err := integrity.CheckIdentity(identityDescriptor.Identity); err != nil { + return errors.WithMessage(err, "refusing to register identity descriptor") + } + // register in the Storage if !identityDescriptor.Ephemeral { if err := p.storage.RegisterIdentityDescriptor(ctx, identityDescriptor, alias); err != nil { diff --git a/token/services/identity/wallet/service.go b/token/services/identity/wallet/service.go index b21fe99ac8..38195b0461 100644 --- a/token/services/identity/wallet/service.go +++ b/token/services/identity/wallet/service.go @@ -13,6 +13,7 @@ import ( "github.com/LFDT-Panurus/panurus/token/services/identity" idriver "github.com/LFDT-Panurus/panurus/token/services/identity/driver" "github.com/LFDT-Panurus/panurus/token/services/logging" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/LFDT-Panurus/panurus/token/services/utils" "github.com/LFDT-Panurus/panurus/token/token" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" @@ -101,22 +102,43 @@ func (s *Service) GetEIDAndRH(ctx context.Context, identity tdriver.Identity, au // RegisterRecipientIdentity registers the passed identity as a third-party recipient identity. // The function performs these steps: // - validate the input +// - reject an empty identity // - match the identity against the provided audit info using the Deserializer (before any provider registration) +// - require the identity to deserialize into an owner verifier // - ask the IdentityProvider to register the recipient identity // - store the recipient data via the IdentityProvider // // If RegisterRecipientData fails after RegisterRecipientIdentity succeeds, the IdentityProvider // may implement identity.RecipientRegistrationRollback so partial registration can be undone. +// +// Verification: the identity arrives from a remote party over the recipient +// exchange, so the two checks around the audit-info match are what stop a +// well-formed-looking but unusable identity from being recorded as a recipient. +// An empty identity is refused because identities are keyed by unique id and +// every empty identity shares one key. Requiring GetOwnerVerifier to succeed +// refuses an identity no verifier can ever be built for: such an identity +// cannot validate a signature, so tokens sent to it could never be spent, and +// the same call is what a validator performs on the owner of every transfer +// input. Both checks run against the same typed-identity deserializer +// multiplex that MatchIdentity already uses, so they accept exactly the +// identity types this driver supports. +// See docs/security/store_integrity_verification.md. func (s *Service) RegisterRecipientIdentity(ctx context.Context, data *tdriver.RecipientData) error { if data == nil { return errors.Wrapf(ErrNilRecipientData, "invalid recipient data") } + if err := integrity.CheckIdentity(data.Identity); err != nil { + return errors.WithMessage(err, "invalid recipient data") + } s.Logger.DebugfContext(ctx, "register recipient identity [%s] with audit info [%s]", data.Identity, utils.Hashable(data.AuditInfo)) if err := s.Deserializer.MatchIdentity(ctx, data.Identity, data.AuditInfo); err != nil { return errors.Wrapf(err, "failed to match identity to audit information for [%s]:[%s]", data.Identity, utils.Hashable(data.AuditInfo)) } + if _, err := s.Deserializer.GetOwnerVerifier(ctx, data.Identity); err != nil { + return errors.Wrapf(err, "failed to derive an owner verifier for recipient identity [%s]", data.Identity) + } if err := s.IdentityProvider.RegisterRecipientIdentity(ctx, data.Identity); err != nil { return errors.Wrapf(err, "failed to register recipient identity") diff --git a/token/services/storage/auditdb/store.go b/token/services/storage/auditdb/store.go index 316baca8da..29d058ee31 100644 --- a/token/services/storage/auditdb/store.go +++ b/token/services/storage/auditdb/store.go @@ -20,6 +20,7 @@ import ( "github.com/LFDT-Panurus/panurus/token/services/storage/db/common" dbdriver "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver" "github.com/LFDT-Panurus/panurus/token/services/storage/db/multiplexed" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/LFDT-Panurus/panurus/token/services/storage/ttxdb" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" cdriver "github.com/hyperledger-labs/fabric-smart-client/platform/common/driver" @@ -219,7 +220,12 @@ func (d *StoreService) Append(ctx context.Context, req tokenRequest) error { } logger.DebugfContext(ctx, "storing new records... [%d,%d,%d]", len(raw), len(mov), len(txs)) - if err := d.locker.AssertLocksHeld(ctx, string(record.Anchor)); err != nil { + anchor := string(record.Anchor) + ppHash := req.PublicParamsHash() + if err := integrity.CheckTokenRequestForStorage(anchor, raw, ppHash); err != nil { + return errors.WithMessagef(err, "refusing to append audit records for txid [%s]", record.Anchor) + } + if err := d.locker.AssertLocksHeld(ctx, anchor); err != nil { return errors.WithMessagef(err, "locks lost before write for request [%s]", req) } w, err := d.db.NewTransactionStoreTransaction() @@ -228,11 +234,11 @@ func (d *StoreService) Append(ctx context.Context, req tokenRequest) error { } if err := w.AddTokenRequest( ctx, - string(record.Anchor), + anchor, raw, req.AllApplicationMetadata(), record.Attributes, - req.PublicParamsHash(), + ppHash, ); err != nil { w.Rollback() @@ -324,15 +330,53 @@ func (d *StoreService) GetStatuses(ctx context.Context, txIDs []string) (map[str } // GetTokenRequest returns the token request bound to the passed transaction id, if available. +// It returns nil without error if no request is stored for txID. +// +// Verification: the returned bytes are checked with +// integrity.CheckStoredTokenRequest before they are handed back, so a request +// that does not deserialize, declares an unsupported protocol version, or is +// anchored to a transaction other than txID is reported as an error rather than +// returned. This store is read by auditors, for whom a request attributed to +// the wrong transaction is worse than no request at all. func (d *StoreService) GetTokenRequest(ctx context.Context, txID string) ([]byte, error) { - return d.db.GetTokenRequest(ctx, txID) + raw, err := d.db.GetTokenRequest(ctx, txID) + if err != nil { + return nil, err + } + if raw == nil { + // not found, which is not an error at this layer + return nil, nil + } + if err := integrity.CheckStoredTokenRequest(txID, raw); err != nil { + logger.ErrorfContext(ctx, "stored token request for [%s] failed the integrity check: %v", txID, err) + + return nil, errors.WithMessagef(err, "stored token request for [%s] failed the integrity check", txID) + } + + return raw, nil } // GetTokenRequests returns the token requests bound to the given tx ids in // a single query. See driver.TransactionStore.GetTokenRequests for details // about missing-key semantics. +// +// Verification: as for GetTokenRequest, every returned request is checked with +// integrity.CheckStoredTokenRequest against the transaction id it is keyed +// under, and a single failing record fails the whole call. func (d *StoreService) GetTokenRequests(ctx context.Context, txIDs []string) (map[string][]byte, error) { - return d.db.GetTokenRequests(ctx, txIDs) + requests, err := d.db.GetTokenRequests(ctx, txIDs) + if err != nil { + return nil, err + } + for txID, raw := range requests { + if err := integrity.CheckStoredTokenRequest(txID, raw); err != nil { + logger.ErrorfContext(ctx, "stored token request for [%s] failed the integrity check: %v", txID, err) + + return nil, errors.WithMessagef(err, "stored token request for [%s] failed the integrity check", txID) + } + } + + return requests, nil } // AcquireLocks acquires locks for the passed anchor and enrollment ids. diff --git a/token/services/storage/db/dbtest/endorser.go b/token/services/storage/db/dbtest/endorser.go index c2fe6d8ef9..4e56ae1f8f 100644 --- a/token/services/storage/db/dbtest/endorser.go +++ b/token/services/storage/db/dbtest/endorser.go @@ -54,7 +54,7 @@ func EValidationRecordQueries(t *testing.T, db driver3.EndorserStore) { }, { TxID: "2", - TokenRequest: []byte{}, + TokenRequest: []byte("tr2"), Metadata: nil, }, { diff --git a/token/services/storage/db/dbtest/identity.go b/token/services/storage/db/dbtest/identity.go index 02f55b5293..d93f7cf1a0 100644 --- a/token/services/storage/db/dbtest/identity.go +++ b/token/services/storage/db/dbtest/identity.go @@ -66,6 +66,7 @@ var IdentityCases = []struct { {"SignerInfoConcurrent", TSignerInfoConcurrent}, {"GetExistingSignerInfo", TGetExistingSignerInfo}, {"RegisterIdentityDescriptor", TRegisterIdentityDescriptor}, + {"EmptyIdentityRejected", TEmptyIdentityRejected}, } var IdentityNotificationCases = []struct { @@ -341,6 +342,45 @@ func TRegisterIdentityDescriptor(t *testing.T, db driver.IdentityStore) { require.NoError(t, db.RegisterIdentityDescriptor(ctx, descriptor, aliasID)) } +// TEmptyIdentityRejected holds both backends to the same spec on empty +// identities. Identity rows are keyed by unique id, and the unique id of the +// empty identity is a fixed string rather than a hash, so every empty identity +// shares one key: a store that accepted them would let one caller's audit info +// or signer info be read back by any other empty-identity lookup. Every write +// and read path must therefore refuse an empty identity outright, and the +// well-known key must stay unoccupied. +func TEmptyIdentityRejected(t *testing.T, db driver.IdentityStore) { + t.Helper() + ctx := t.Context() + empty := []byte(nil) + auditInfo := []byte("audit_info") + + require.Error(t, db.StoreIdentityData(ctx, empty, auditInfo, []byte("tok_meta"), []byte("tok_meta_audit"))) + require.Error(t, db.StoreIdentityData(ctx, []byte{}, auditInfo, []byte("tok_meta"), []byte("tok_meta_audit"))) + require.Error(t, db.StoreSignerInfo(ctx, empty, []byte("signer_info"))) + + _, err := db.GetAuditInfo(ctx, empty) + require.Error(t, err) + _, _, err = db.GetTokenInfo(ctx, empty) + require.Error(t, err) + _, err = db.GetSignerInfo(ctx, empty) + require.Error(t, err) + + require.Error(t, db.RegisterIdentityDescriptor(ctx, &idriver.IdentityDescriptor{ + Identity: empty, + AuditInfo: auditInfo, + Signer: &mock.Signer{}, + SignerInfo: []byte("signer_info"), + Verifier: &mock.Verifier{}, + }, nil)) + + // nothing was written under the shared key + exists, err := db.SignerInfoExists(ctx, empty) + if err == nil { + assert.False(t, exists, "the empty-identity key must stay unoccupied") + } +} + func TIdentityNotifier(t *testing.T, db driver.IdentityStore) { t.Helper() logging.Init(logging.Config{ diff --git a/token/services/storage/db/dbtest/tokens.go b/token/services/storage/db/dbtest/tokens.go index 2a9ff2a87f..4164425e64 100644 --- a/token/services/storage/db/dbtest/tokens.go +++ b/token/services/storage/db/dbtest/tokens.go @@ -822,6 +822,20 @@ func TPublicParams(t *testing.T, db TestTokenDB) { res, err = db.PublicParamsByHash(ctx, b1Hash) require.NoError(t, err) assert.Equal(t, res, b1) + + // A hash nothing was stored under must report nothing, not whichever row the + // query happened to reach. Public parameters carry the issuer and auditor + // keys every action is validated against, and a caller asking by hash is + // asking for the setup a specific transaction was created under. + res, err = db.PublicParamsByHash(ctx, utils.Hashable([]byte("never stored")).Raw()) + require.NoError(t, err) + assert.Nil(t, res) + + // An empty hash matches nothing and would make the hash comparison vacuous. + res, err = db.PublicParamsByHash(ctx, nil) + if err == nil { + assert.Nil(t, res, "an empty hash must not resolve to stored public parameters") + } } func TCertification(t *testing.T, db TestTokenDB) { diff --git a/token/services/storage/db/dbtest/transactions.go b/token/services/storage/db/dbtest/transactions.go index 84610bd895..649712ea37 100644 --- a/token/services/storage/db/dbtest/transactions.go +++ b/token/services/storage/db/dbtest/transactions.go @@ -192,9 +192,9 @@ func TMovements(t *testing.T, db driver3.TokenTransactionStore) { ctx := t.Context() w, err := db.NewTransactionStoreTransaction() require.NoError(t, err) - require.NoError(t, w.AddTokenRequest(ctx, "0", []byte{}, map[string][]byte{}, nil, driver2.PPHash("tr"))) - require.NoError(t, w.AddTokenRequest(ctx, "1", []byte{}, map[string][]byte{}, nil, driver2.PPHash("tr"))) - require.NoError(t, w.AddTokenRequest(ctx, "2", []byte{}, map[string][]byte{}, nil, driver2.PPHash("tr"))) + require.NoError(t, w.AddTokenRequest(ctx, "0", []byte("token request for 0"), map[string][]byte{}, nil, driver2.PPHash("tr"))) + require.NoError(t, w.AddTokenRequest(ctx, "1", []byte("token request for 1"), map[string][]byte{}, nil, driver2.PPHash("tr"))) + require.NoError(t, w.AddTokenRequest(ctx, "2", []byte("token request for 2"), map[string][]byte{}, nil, driver2.PPHash("tr"))) require.NoError(t, w.AddMovement(ctx, driver3.MovementRecord{ TxID: "0", EnrollmentID: "alice", @@ -560,7 +560,7 @@ func TAllowsSameTxID(t *testing.T, db driver3.TokenTransactionStore) { } w, err := db.NewTransactionStoreTransaction() require.NoError(t, err) - require.NoError(t, w.AddTokenRequest(ctx, tr1.TxID, []byte{}, map[string][]byte{}, nil, driver2.PPHash("tr"))) + require.NoError(t, w.AddTokenRequest(ctx, tr1.TxID, []byte("token request for "+tr1.TxID), map[string][]byte{}, nil, driver2.PPHash("tr"))) require.NoError(t, w.AddTransaction(ctx, tr1)) require.NoError(t, w.AddTransaction(ctx, tr2)) require.NoError(t, w.Commit()) @@ -823,7 +823,7 @@ func TTransactionQueries(t *testing.T, db driver3.TokenTransactionStore) { var previous string for _, r := range tr { if r.TxID != previous { - require.NoError(t, w.AddTokenRequest(ctx, r.TxID, []byte{}, map[string][]byte{}, nil, driver2.PPHash("tr"))) + require.NoError(t, w.AddTokenRequest(ctx, r.TxID, []byte("token request for "+r.TxID), map[string][]byte{}, nil, driver2.PPHash("tr"))) } require.NoError(t, w.AddTransaction(ctx, r)) previous = r.TxID @@ -907,7 +907,7 @@ func createTestTransaction(t *testing.T, db driver3.TokenTransactionStore, txID if err != nil { t.Fatalf("error creating transaction while trying to test something else: %s", err) } - if err := w.AddTokenRequest(t.Context(), txID, []byte{}, map[string][]byte{}, nil, driver2.PPHash("tr")); err != nil { + if err := w.AddTokenRequest(t.Context(), txID, []byte("token request for "+txID), map[string][]byte{}, nil, driver2.PPHash("tr")); err != nil { t.Fatalf("error creating token request while trying to test something else: %s", err) } tr1 := driver3.TransactionRecord{ diff --git a/token/services/storage/db/driver/audit.go b/token/services/storage/db/driver/audit.go index 37f2be5303..1e2ce5d11a 100644 --- a/token/services/storage/db/driver/audit.go +++ b/token/services/storage/db/driver/audit.go @@ -46,11 +46,20 @@ type AuditTransactionStore interface { // GetTokenRequest returns the token request bound to the passed transaction id, if available. // It returns nil without error if the key is not found. + // + // Verification: a returned payload is a TokenRequestWithMetadata whose + // anchor is txID — see integrity.CheckStoredTokenRequest. This is the audit + // trail an auditor replays to attribute a transaction, so a payload anchored + // to another transaction would attribute the wrong one; it must be reported + // as an error rather than returned. Not-found stays nil, nil. GetTokenRequest(ctx context.Context, txID string) ([]byte, error) // GetTokenRequests returns the token requests bound to the given tx ids // in a single query. Missing tx ids are absent from the returned map. // Empty input returns an empty map without touching the database. + // + // Verification: as for GetTokenRequest, applied to every entry. One failing + // entry fails the whole call. GetTokenRequests(ctx context.Context, txIDs []string) (map[string][]byte, error) // AcquireRecoveryLeadership tries to acquire the PostgreSQL advisory lock backing the sweeper leader election. diff --git a/token/services/storage/db/driver/endorser.go b/token/services/storage/db/driver/endorser.go index 9a97705b25..392ef4175c 100644 --- a/token/services/storage/db/driver/endorser.go +++ b/token/services/storage/db/driver/endorser.go @@ -39,6 +39,15 @@ type EndorserStoreTransaction interface { // AddValidationRecord adds a new validation record for the given params. // The token request is stored directly in the validation table. + // + // Verification: an implementation must refuse an empty txID, an empty + // tokenRequest, or an empty ppHash. Unlike the ttx and audit stores, this one + // holds the bare actions-and-signatures format rather than + // TokenRequestWithMetadata, so there is no anchor to bind to txID; what is + // checked instead is that the payload deserializes at a supported version and + // carries at least one action — see integrity.CheckTokenRequestActions. A + // validation record asserting that a request was validated is worthless if + // the request it names carries nothing to validate. AddValidationRecord(ctx context.Context, txID string, tokenRequest []byte, meta map[string][]byte, ppHash driver.PPHash) error // SetStatus sets the status of a validation record diff --git a/token/services/storage/db/driver/token.go b/token/services/storage/db/driver/token.go index 40058789df..b168081bfa 100644 --- a/token/services/storage/db/driver/token.go +++ b/token/services/storage/db/driver/token.go @@ -257,6 +257,14 @@ type TokenStore interface { PublicParams(ctx context.Context) ([]byte, error) // PublicParamsByHash returns the public parameters whose hash matches the passed one. // If not public parameters are available for that hash, it returns an error + // + // Verification: an implementation must hash the parameters it is about to + // return and compare against rawHash — see + // integrity.CheckPublicParamsHash. Public parameters carry the issuer and + // auditor keys and the cryptographic setup every action is validated against, + // and a caller fetching by hash is asking for the setup one specific + // transaction was created under. An empty rawHash is refused, since it makes + // the comparison vacuous. PublicParamsByHash(ctx context.Context, rawHash driver.PPHash) ([]byte, error) // NewTokenDBTransaction returns a new Transaction to commit atomically multiple operations NewTokenDBTransaction() (TokenStoreTransaction, error) diff --git a/token/services/storage/db/driver/ttx.go b/token/services/storage/db/driver/ttx.go index ada8cc7031..d93329edff 100644 --- a/token/services/storage/db/driver/ttx.go +++ b/token/services/storage/db/driver/ttx.go @@ -30,6 +30,12 @@ type TransactionStoreTransaction interface { Transaction // AddTokenRequest binds the passed transaction id to the passed token request + // + // Verification: an implementation must refuse an empty txID, an empty token + // request, or an empty ppHash — see + // integrity.CheckTokenRequestForStorage. A stored request must remain + // retrievable as evidence about txID under the public parameters ppHash + // identifies, and none of the three is meaningful when empty. AddTokenRequest(ctx context.Context, txID string, tr []byte, applicationMetadata, publicMetadata map[string][]byte, ppHash driver.PPHash) error // AddMovement adds a movement record to the database transaction. @@ -79,6 +85,13 @@ type TransactionStore interface { // GetTokenRequest returns the token request bound to the passed transaction id, if available. // It returns nil without error if the key is not found. + // + // Verification: a returned payload is a TokenRequestWithMetadata whose + // anchor is txID — see integrity.CheckStoredTokenRequest. Callers treat it + // as authentic evidence about txID (an auditor replays it, a recovery sweep + // resubmits it), so a payload that does not parse, that carries an + // unsupported version, or that is anchored to another transaction must be + // reported as an error rather than returned. Not-found stays nil, nil. GetTokenRequest(ctx context.Context, txID string) ([]byte, error) // GetTokenRequests returns the token requests bound to the given @@ -87,6 +100,10 @@ type TransactionStore interface { // treat a missing key identically to GetTokenRequest returning nil // (no error, no record). An empty or nil txIDs slice returns an empty // map without touching the database. + // + // Verification: as for GetTokenRequest, applied to every entry. One + // failing entry fails the whole call: the caller cannot tell which entries + // were checked from a partially filled map. GetTokenRequests(ctx context.Context, txIDs []string) (map[string][]byte, error) // AcquireRecoveryLeadership tries to acquire the PostgreSQL advisory lock backing the sweeper leader election. @@ -109,9 +126,20 @@ type TransactionStore interface { type TransactionEndorsementAckStore interface { // AddTransactionEndorsementAck records the signature of a given endorser for a given transaction + // + // Verification: an implementation must refuse an empty txID, an empty + // endorser, or an empty sigma — see integrity.CheckEndorsementAck. The + // signature itself is verified by the caller against the payload it sent to + // that endorser; the store never sees that payload and cannot repeat the + // check. See token/services/ttx.Service.AppendTransactionEndorseAck. AddTransactionEndorsementAck(ctx context.Context, txID string, endorser token.Identity, sigma []byte) error // GetTransactionEndorsementAcks returns the endorsement signatures for the given transaction id + // + // Verification: the returned signatures are returned as stored. The message + // each one signs is the per-party filtered payload that was sent to that + // endorser, and it is not persisted, so neither the store nor the caller can + // re-verify a signature after the fact. GetTransactionEndorsementAcks(ctx context.Context, txID string) (map[string][]byte, error) } diff --git a/token/services/storage/db/kvs/identitydb.go b/token/services/storage/db/kvs/identitydb.go index f7e5a27a4d..4f9eebb5a3 100644 --- a/token/services/storage/db/kvs/identitydb.go +++ b/token/services/storage/db/kvs/identitydb.go @@ -15,6 +15,7 @@ import ( tdriver "github.com/LFDT-Panurus/panurus/token/driver" idriver "github.com/LFDT-Panurus/panurus/token/services/identity/driver" "github.com/LFDT-Panurus/panurus/token/services/storage" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/storage/kvs" ) @@ -28,6 +29,12 @@ const ( // RecipientData contains information about the identity of a token owner type RecipientData struct { + // Identity is the identity this record belongs to. Rows are keyed by the + // hash of the identity, so this field is what lets a read verify that the + // row it landed on is the one it asked for. It is absent in rows written by + // releases before the check was introduced; such rows are read back without + // the verification rather than rejected. + Identity []byte // AuditInfo contains private information Identity AuditInfo []byte // TokenMetadata contains public information related to the token to be assigned to this Recipient. @@ -205,7 +212,18 @@ func (s *IdentityStore) Notifier() (idriver.IdentityConfigurationNotifier, error return nil, storage.ErrNotSupported } +// StoreIdentityData binds id to its audit info and token metadata. +// +// Verification: an empty id is refused. Rows are keyed by +// tdriver.Identity.String, which maps the empty identity to the constant +// "" rather than to a hash, so an empty identity would write to a +// well-known key that any later empty-identity lookup reads back as its own. +// The identity is stored in the record so that GetAuditInfo can verify the row +// it reads belongs to the identity that was asked for. func (s *IdentityStore) StoreIdentityData(ctx context.Context, id []byte, identityAudit []byte, tokenMetadata []byte, tokenMetadataAudit []byte) error { + if err := integrity.CheckIdentity(id); err != nil { + return errors.WithMessage(err, "refusing to store identity data") + } k := kvs.CreateCompositeKeyOrPanic( IdentityDBPrefix, []string{ @@ -215,6 +233,7 @@ func (s *IdentityStore) StoreIdentityData(ctx context.Context, id []byte, identi }, ) if err := s.kvs.Put(ctx, k, &RecipientData{ + Identity: id, AuditInfo: identityAudit, TokenMetadata: tokenMetadata, TokenMetadataAuditInfo: tokenMetadataAudit, @@ -225,7 +244,20 @@ func (s *IdentityStore) StoreIdentityData(ctx context.Context, id []byte, identi return nil } +// GetAuditInfo returns the audit info stored for identity, or nil if none is +// stored. +// +// Verification: the row is addressed by identity hash, so the identity stored +// in the record is compared against the requested one before the audit info is +// returned. Audit info is what an auditor uses to attribute a transaction to a +// party, so handing back audit info belonging to a different identity than the +// caller asked for would misattribute it. Records written before the identity +// was stored alongside the audit info carry no identity and are returned +// unverified; see docs/security/store_integrity_verification.md. func (s *IdentityStore) GetAuditInfo(ctx context.Context, identity []byte) ([]byte, error) { + if err := integrity.CheckIdentity(identity); err != nil { + return nil, errors.WithMessage(err, "refusing to look up audit info") + } k := kvs.CreateCompositeKeyOrPanic( IdentityDBPrefix, []string{ @@ -241,11 +273,24 @@ func (s *IdentityStore) GetAuditInfo(ctx context.Context, identity []byte) ([]by if err := s.kvs.Get(ctx, k, &res); err != nil { return nil, err } + if len(res.Identity) != 0 { + if err := integrity.CheckIdentityMatch(identity, res.Identity); err != nil { + return nil, errors.WithMessagef(err, "identity data record under [%s]", tdriver.Identity(identity).String()) + } + } return res.AuditInfo, nil } +// GetTokenInfo returns the token metadata and its audit info stored for +// identity, or nil if none is stored. +// +// Verification: as for GetAuditInfo, the identity stored in the record is +// compared against the requested one. func (s *IdentityStore) GetTokenInfo(ctx context.Context, identity []byte) ([]byte, []byte, error) { + if err := integrity.CheckIdentity(identity); err != nil { + return nil, nil, errors.WithMessage(err, "refusing to look up token info") + } k := kvs.CreateCompositeKeyOrPanic( IdentityDBPrefix, []string{ @@ -261,11 +306,27 @@ func (s *IdentityStore) GetTokenInfo(ctx context.Context, identity []byte) ([]by if err := s.kvs.Get(ctx, k, &res); err != nil { return nil, nil, err } + if len(res.Identity) != 0 { + if err := integrity.CheckIdentityMatch(identity, res.Identity); err != nil { + return nil, nil, errors.WithMessagef(err, "identity data record under [%s]", tdriver.Identity(identity).String()) + } + } return res.TokenMetadata, res.TokenMetadataAuditInfo, nil } +// StoreSignerInfo binds id to the signer info a key manager resolves into a +// signer. +// +// Verification: an empty id is refused, for the reason given on +// StoreIdentityData — the empty identity does not hash, it maps to the shared +// "" row key. Note that unlike the SQL backend this store keeps only the +// signer info under the identity hash, so GetSignerInfo cannot verify the +// identity a record belongs to; see docs/security/store_integrity_verification.md. func (s *IdentityStore) StoreSignerInfo(ctx context.Context, id tdriver.Identity, info []byte) error { + if err := integrity.CheckIdentity(id); err != nil { + return errors.WithMessage(err, "refusing to store signer info") + } idHash := id.UniqueID() k, err := kvs.CreateCompositeKey( IdentityDBPrefix, @@ -321,7 +382,17 @@ func (s *IdentityStore) SignerInfoExists(ctx context.Context, id []byte) (bool, return len(existing) > 0, nil } +// GetSignerInfo returns the signer info stored for identity, or nil if none is +// stored. +// +// Verification: an empty identity is refused. This store does not keep the +// identity alongside the signer info, so — unlike the SQL backend — it cannot +// verify that the record found under the identity hash belongs to the requested +// identity. See docs/security/store_integrity_verification.md. func (s *IdentityStore) GetSignerInfo(ctx context.Context, identity []byte) ([]byte, error) { + if err := integrity.CheckIdentity(identity); err != nil { + return nil, errors.WithMessage(err, "refusing to look up signer info") + } idHash := tdriver.Identity(identity).UniqueID() k, err := kvs.CreateCompositeKey( IdentityDBPrefix, @@ -342,14 +413,31 @@ func (s *IdentityStore) GetSignerInfo(ctx context.Context, identity []byte) ([]b return res, nil } +// RegisterIdentityDescriptor stores the descriptor's signer info and audit info +// under its own identity and, when one is given, under alias. +// +// Verification: an empty descriptor identity is refused, as it is by +// StoreSignerInfo and StoreIdentityData. An empty alias is skipped rather than +// refused — callers legitimately pass none — which also matches the SQL +// backend, where the alias is only written when it is set and differs from the +// descriptor's identity. func (s *IdentityStore) RegisterIdentityDescriptor(ctx context.Context, descriptor *idriver.IdentityDescriptor, alias tdriver.Identity) error { + if descriptor == nil { + return errors.New("identity descriptor is nil") + } + if err := integrity.CheckIdentity(descriptor.Identity); err != nil { + return errors.WithMessage(err, "refusing to register identity descriptor") + } if err := s.StoreSignerInfo(ctx, descriptor.Identity, descriptor.SignerInfo); err != nil { return err } - if err := s.StoreSignerInfo(ctx, alias, descriptor.SignerInfo); err != nil { + if err := s.StoreIdentityData(ctx, descriptor.Identity, descriptor.AuditInfo, nil, nil); err != nil { return err } - if err := s.StoreIdentityData(ctx, descriptor.Identity, descriptor.AuditInfo, nil, nil); err != nil { + if alias.IsNone() || descriptor.Identity.Equal(alias) { + return nil + } + if err := s.StoreSignerInfo(ctx, alias, descriptor.SignerInfo); err != nil { return err } if err := s.StoreIdentityData(ctx, alias, descriptor.AuditInfo, nil, nil); err != nil { diff --git a/token/services/storage/db/sql/common/endorser.go b/token/services/storage/db/sql/common/endorser.go index 3f5b30a6ed..fee401faea 100644 --- a/token/services/storage/db/sql/common/endorser.go +++ b/token/services/storage/db/sql/common/endorser.go @@ -18,6 +18,7 @@ import ( q "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query" common3 "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/common" "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/cond" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/hyperledger-labs/fabric-smart-client/platform/common/utils/collections/iterators" "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/storage/driver/sql/common" @@ -174,10 +175,20 @@ func (w *EndorserStoreTransaction) Rollback() { _ = w.tx.Rollback() } -// AddValidationRecord adds a validation record to the database +// AddValidationRecord adds a validation record to the database. +// +// Verification: as for TransactionStoreTransaction.AddTokenRequest, this layer +// does not interpret tokenRequest — the wire-format check lives in the +// endorserdb service. What is refused here is a row no later check could act +// on: an empty transaction id, empty request bytes, or an empty public +// parameters hash. func (w *EndorserStoreTransaction) AddValidationRecord(ctx context.Context, txID string, tokenRequest []byte, meta map[string][]byte, ppHash driver2.PPHash) error { logger.DebugfContext(ctx, "adding validation record [%s]", txID) + if err := integrity.CheckTokenRequestForStorage(txID, tokenRequest, ppHash); err != nil { + return errors.WithMessagef(err, "refusing to add validation record for txid [%s]", txID) + } + metaBytes, err := marshal(meta) if err != nil { return errors.Wrapf(err, "failed to marshal metadata for tx [%s]", txID) diff --git a/token/services/storage/db/sql/common/identity.go b/token/services/storage/db/sql/common/identity.go index 29afc6009e..e139ef38d2 100644 --- a/token/services/storage/db/sql/common/identity.go +++ b/token/services/storage/db/sql/common/identity.go @@ -20,6 +20,7 @@ import ( q "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query" common3 "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/common" "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/cond" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/LFDT-Panurus/panurus/token/services/utils" cache2 "github.com/LFDT-Panurus/panurus/token/services/utils/cache" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" @@ -305,43 +306,93 @@ func (db *IdentityStore) Notifier() (idriver.IdentityConfigurationNotifier, erro return db.notifier, nil } +// StoreIdentityData binds id to its audit info and token metadata. +// +// Verification: an empty id is refused. Rows are keyed by +// tdriver.Identity.UniqueID, which maps the empty identity to the constant +// "" rather than to a hash, so an empty identity would write to a +// well-known key that any later empty-identity lookup reads back as its own. func (db *IdentityStore) StoreIdentityData(ctx context.Context, id []byte, identityAudit []byte, tokenMetadata []byte, tokenMetadataAudit []byte) error { + if err := integrity.CheckIdentity(id); err != nil { + return errors.WithMessage(err, "refusing to store identity data") + } + return db.storeIdentityData(ctx, db.writeDB, tdriver.Identity(id).UniqueID(), id, identityAudit, tokenMetadata, tokenMetadataAudit, true) } +// GetAuditInfo returns the audit info stored for id, or nil if none is stored. +// +// Verification: the row is addressed by identity hash, so the identity stored +// alongside the audit info is compared against id before the audit info is +// returned or cached. Audit info is what an auditor uses to attribute a +// transaction to a party, so handing back audit info belonging to a different +// identity than the caller asked for would misattribute it. A row whose +// identity and identity_hash columns disagree is reported rather than returned. func (db *IdentityStore) GetAuditInfo(ctx context.Context, id []byte) ([]byte, error) { + if err := integrity.CheckIdentity(id); err != nil { + return nil, errors.WithMessage(err, "refusing to look up audit info") + } h := token.Identity(id).String() logger.DebugfContext(ctx, "get audit info for [%s]", h) value, _, err := db.auditInfoCache.GetOrLoad(h, func() ([]byte, error) { logger.DebugfContext(ctx, "load from backend identity data for [%s]", view.Identity(id)) query, args := q.Select(). - FieldsByName("identity_audit_info"). + FieldsByName("identity", "identity_audit_info"). From(q.Table(db.table.IdentityInfo)). Where(cond.Eq("identity_hash", h)). Format(db.ci) + logging.Debug(logger, query, args) + + row := db.readDB.QueryRowContext(ctx, query, args...) + var storedIdentity []byte + var auditInfo []byte + if err := row.Scan(&storedIdentity, &auditInfo); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, errors.Wrapf(err, "error querying db") + } + if err := integrity.CheckIdentityMatch(id, storedIdentity); err != nil { + logger.ErrorfContext(ctx, "identity data row under [%s] does not belong to the requested identity: %v", h, err) - return common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) + return nil, errors.WithMessagef(err, "identity data row under [%s]", h) + } + + return auditInfo, nil }) return value, err } +// GetTokenInfo returns the token metadata stored for id, or nil if none is +// stored. +// +// Verification: as for GetAuditInfo — an empty id is refused, and the identity +// stored alongside the metadata is compared against id before the metadata is +// returned. Token metadata is what the owner of a token uses to recognise and +// spend it, so metadata belonging to a different identity is not a usable +// substitute for the caller's own. func (db *IdentityStore) GetTokenInfo(ctx context.Context, id []byte) ([]byte, []byte, error) { + if err := integrity.CheckIdentity(id); err != nil { + return nil, nil, errors.WithMessage(err, "refusing to look up token info") + } h := token.Identity(id).String() logger.DebugfContext(ctx, "get identity data for [%s]", h) query, args := q.Select(). - FieldsByName("token_metadata", "token_metadata_audit_info"). + FieldsByName("identity", "token_metadata", "token_metadata_audit_info"). From(q.Table(db.table.IdentityInfo)). Where(cond.Eq("identity_hash", h)). Format(db.ci) logging.Debug(logger, query, args) row := db.readDB.QueryRowContext(ctx, query, args...) + var storedIdentity []byte var tokenMetadata []byte var tokenMetadataAuditInfo []byte - err := row.Scan(&tokenMetadata, &tokenMetadataAuditInfo) + err := row.Scan(&storedIdentity, &tokenMetadata, &tokenMetadataAuditInfo) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil, nil @@ -349,11 +400,25 @@ func (db *IdentityStore) GetTokenInfo(ctx context.Context, id []byte) ([]byte, [ return nil, nil, errors.Wrapf(err, "error querying db") } + if err := integrity.CheckIdentityMatch(id, storedIdentity); err != nil { + logger.ErrorfContext(ctx, "identity data row under [%s] does not belong to the requested identity: %v", h, err) + + return nil, nil, errors.WithMessagef(err, "identity data row under [%s]", h) + } return tokenMetadata, tokenMetadataAuditInfo, nil } +// StoreSignerInfo binds id to the signer info a key manager resolves into a +// signer. +// +// Verification: an empty id is refused, for the reason given on +// StoreIdentityData — the empty identity does not hash, it maps to the shared +// "" row key. func (db *IdentityStore) StoreSignerInfo(ctx context.Context, id tdriver.Identity, info []byte) error { + if err := integrity.CheckIdentity(id); err != nil { + return errors.WithMessage(err, "refusing to store signer info") + } _, err := db.storeSignerInfo(ctx, db.writeDB, id.UniqueID(), id, info, true) return err @@ -414,14 +479,44 @@ func (db *IdentityStore) SignerInfoExists(ctx context.Context, id []byte) (bool, return len(existing) > 0, nil } +// GetSignerInfo returns the signer info stored for identity, or nil if none is +// stored. +// +// Verification: the row is addressed by identity hash, so the identity stored +// alongside the signer info is compared against the requested one before the +// info is returned. Signer info is what a key manager resolves into a signer, so +// returning the info of a different identity than the caller named would route +// signing to the wrong key. A row whose identity and identity_hash columns +// disagree is reported rather than returned. func (db *IdentityStore) GetSignerInfo(ctx context.Context, identity []byte) ([]byte, error) { + if err := integrity.CheckIdentity(identity); err != nil { + return nil, errors.WithMessage(err, "refusing to look up signer info") + } + h := token.Identity(identity).UniqueID() query, args := q.Select(). - FieldsByName("info"). + FieldsByName("identity", "info"). From(q.Table(db.table.Signers)). - Where(cond.Eq("identity_hash", token.Identity(identity).UniqueID())). + Where(cond.Eq("identity_hash", h)). Format(db.ci) + logging.Debug(logger, query, args) - return common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) + row := db.readDB.QueryRowContext(ctx, query, args...) + var storedIdentity []byte + var info []byte + if err := row.Scan(&storedIdentity, &info); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, errors.Wrapf(err, "error querying db") + } + if err := integrity.CheckIdentityMatch(identity, storedIdentity); err != nil { + logger.ErrorfContext(ctx, "signer row under [%s] does not belong to the requested identity: %v", h, err) + + return nil, errors.WithMessagef(err, "signer row under [%s]", h) + } + + return info, nil } // IterateSigners returns a page of SignerEntry values from the Signers table, ordered by @@ -491,6 +586,9 @@ func (db *IdentityStore) registerIdentityDescriptor( if descriptor == nil { return errors.New("identity descriptor is nil") } + if err := integrity.CheckIdentity(descriptor.Identity); err != nil { + return errors.WithMessage(err, "refusing to register identity descriptor") + } tx, err := db.writeDB.BeginTx(ctx, nil) if err != nil { return err diff --git a/token/services/storage/db/sql/common/tokens.go b/token/services/storage/db/sql/common/tokens.go index 534e8f4da2..112cd4ff3a 100644 --- a/token/services/storage/db/sql/common/tokens.go +++ b/token/services/storage/db/sql/common/tokens.go @@ -23,6 +23,7 @@ import ( q "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query" common3 "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/common" "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/cond" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/LFDT-Panurus/panurus/token/services/utils" "github.com/LFDT-Panurus/panurus/token/token" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" @@ -1161,6 +1162,14 @@ func (db *TokenStore) PublicParams(ctx context.Context) ([]byte, error) { return common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) } +// PublicParamsByHash returns the public parameters whose hash matches the passed +// one, or nil if none are stored under it. +// +// Verification: the returned parameters are hashed and compared against rawHash +// — see integrity.CheckPublicParamsHash. Callers fetch by hash in order to +// re-validate a transaction against the setup it was created under, so +// parameters that do not hash to the requested hash are reported rather than +// returned. func (db *TokenStore) PublicParamsByHash(ctx context.Context, rawHash tdriver.PPHash) ([]byte, error) { query, args := q.Select(). FieldsByName("raw"). @@ -1168,7 +1177,17 @@ func (db *TokenStore) PublicParamsByHash(ctx context.Context, rawHash tdriver.PP Where(cond.Eq("raw_hash", rawHash)). Format(db.ci) - return common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) + raw, err := common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) + if err != nil { + return nil, err + } + if err := integrity.CheckPublicParamsHash(rawHash, raw); err != nil { + logger.ErrorfContext(ctx, "refusing to return public parameters: %v", err) + + return nil, err + } + + return raw, nil } func (db *TokenStore) StoreCertifications(ctx context.Context, certifications map[*token.ID][]byte) error { diff --git a/token/services/storage/db/sql/common/transactions.go b/token/services/storage/db/sql/common/transactions.go index 9031b3f011..9dd1d6cf24 100644 --- a/token/services/storage/db/sql/common/transactions.go +++ b/token/services/storage/db/sql/common/transactions.go @@ -24,6 +24,7 @@ import ( common3 "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/common" "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/cond" _select "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/select" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/hashicorp/go-uuid" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" driver3 "github.com/hyperledger-labs/fabric-smart-client/platform/common/driver" @@ -426,9 +427,25 @@ func (db *TransactionStore) ReleaseRecoveryClaim(context.Context, string, string return nil } +// AddTransactionEndorsementAck records the signature of endorser over the +// payload it was sent for txID. +// +// Verification: the signature cannot be verified here — the payload it was +// produced over is not persisted, so the caller is the last holder of it. This +// layer refuses a vacuous acknowledgement: one with no endorser identity, which +// would collide with every other such row when read back into a map keyed by +// endorser, or no signature, which would be indistinguishable from a genuine +// acknowledgement to every consumer. func (db *TransactionStore) AddTransactionEndorsementAck(ctx context.Context, txID string, endorser token.Identity, sigma []byte) (err error) { logger.DebugfContext(ctx, "adding transaction endorse ack record [%s]", txID) + if txID == "" { + return errors.WithMessage(integrity.ErrEmptyTxID, "refusing to add endorsement ack") + } + if err := integrity.CheckEndorsementAck(endorser, sigma); err != nil { + return errors.WithMessagef(err, "refusing to add endorsement ack for txid [%s]", txID) + } + now := time.Now().UTC() id, err := uuid.GenerateUUID() if err != nil { @@ -654,11 +671,22 @@ func (w *TransactionStoreTransaction) AddTransaction(ctx context.Context, rs ... return ttxDBError(err) } +// AddTokenRequest binds txID to the serialized token request tr. +// +// Verification: this layer is a byte store and does not interpret tr — the +// wire-format and anchor checks live in the ttxdb and auditdb services, which +// know which of the two token request formats they hold. What is refused here +// is a row that no later check could act on: an empty transaction id, empty +// request bytes, or an empty public parameters hash. See +// docs/security/store_integrity_verification.md. func (w *TransactionStoreTransaction) AddTokenRequest(ctx context.Context, txID string, tr []byte, applicationMetadata, publicMetadata map[string][]byte, ppHash driver2.PPHash) error { logger.DebugfContext(ctx, "adding token request [%s]", txID) if w.txn == nil { return errors.New("no db transaction in progress") } + if err := integrity.CheckTokenRequestForStorage(txID, tr, ppHash); err != nil { + return errors.WithMessagef(err, "refusing to add token request for txid [%s]", txID) + } if applicationMetadata == nil { applicationMetadata = make(map[string][]byte) } diff --git a/token/services/storage/endorserdb/store.go b/token/services/storage/endorserdb/store.go index 1444c2089f..8e51aff333 100644 --- a/token/services/storage/endorserdb/store.go +++ b/token/services/storage/endorserdb/store.go @@ -17,6 +17,7 @@ import ( "github.com/LFDT-Panurus/panurus/token/services/storage/db/common" dbdriver "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver" "github.com/LFDT-Panurus/panurus/token/services/storage/db/multiplexed" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" ) @@ -118,10 +119,32 @@ func (d *StoreService) ValidationRecords(ctx context.Context, params QueryValida return &ValidationRecordsIterator{it: it}, nil } -// AppendValidationRecord appends the given validation metadata related to the given transaction id +// AppendValidationRecord appends the given validation metadata related to the given transaction id. +// +// Verification: tokenRequest is expected to have been validated against the +// ledger by the caller — this store holds it as the validated request for txID, +// and nothing downstream re-validates it. What is enforced here is that the +// payload is a token request at all: it must deserialize as a +// driver.TokenRequest of a supported protocol version and carry at least one +// action, which are the conditions a validator rejects on before it looks at +// any action. A payload reaching this store on a path that skipped validation +// is therefore refused rather than filed as validated. An empty public +// parameters hash is refused for the same reason it is refused by the ttx and +// audit stores: it disables the public-parameters-mismatch check instead of +// failing it. +// +// Note that this format carries no anchor, so the record cannot be bound to +// txID by a structural check — see docs/security/store_integrity_verification.md. func (d *StoreService) AppendValidationRecord(ctx context.Context, txID string, tokenRequest []byte, meta map[string][]byte, ppHash driver2.PPHash) error { logger.DebugfContext(ctx, "appending new validation record... [%s]", txID) + if err := integrity.CheckTokenRequestForStorage(txID, tokenRequest, ppHash); err != nil { + return errors.WithMessagef(err, "refusing to append validation record for txid [%s]", txID) + } + if err := integrity.CheckTokenRequestActions(tokenRequest); err != nil { + return errors.WithMessagef(err, "refusing to append validation record for txid [%s]", txID) + } + w, err := d.db.NewEndorserStoreTransaction() if err != nil { return errors.WithMessagef(err, "begin update for txid [%s] failed", txID) diff --git a/token/services/storage/integrity/identity.go b/token/services/storage/integrity/identity.go new file mode 100644 index 0000000000..09a6e872ad --- /dev/null +++ b/token/services/storage/integrity/identity.go @@ -0,0 +1,68 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package integrity + +import ( + "bytes" + + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" +) + +var ( + // ErrEmptyIdentity is returned when an empty identity is about to be stored + // or looked up. + ErrEmptyIdentity = errors.New("empty identity") + // ErrIdentityMismatch is returned when a record retrieved by identity hash + // turns out to belong to a different identity than the one requested. + ErrIdentityMismatch = errors.New("stored identity does not match requested identity") +) + +// CheckIdentity rejects an empty identity. +// +// This is not a style guard. Identity rows are keyed by +// [github.com/hyperledger-labs/fabric-smart-client/platform/common/services/identity.Identity.UniqueID], +// which maps the empty identity to the constant string "" rather than to +// a hash. Every empty identity therefore shares one row key: storing signer +// info or audit info for an empty identity writes it to a well-known key that a +// later empty-identity lookup — from an unmarshalling slip, a zero-valued +// struct field, or an attacker-supplied empty identity — will read back as if +// it belonged to whoever asked. Refusing empty identities at the boundary keeps +// that key unoccupied. +func CheckIdentity(id []byte) error { + if len(id) == 0 { + return ErrEmptyIdentity + } + + return nil +} + +// CheckIdentityMatch verifies that a record retrieved by identity hash actually +// belongs to the identity that was requested, by comparing the identity stored +// alongside the record against the requested one. +// +// Identity records are addressed by hash, so callers get back whatever row the +// hash of their identity lands on. Comparing the stored identity turns the +// store's promise from "this is the row your hash pointed at" into "this +// belongs to the identity you named": it catches a row whose identity and +// identity_hash columns disagree — a corrupted or out-of-band-modified row, or +// one written under the shared "" key described on CheckIdentity — before +// the signer info or audit info reaches a caller that will use it to sign or to +// attribute a transaction. +// +// stored may be empty, which means the store holds no identity for the row; that +// is reported as a mismatch, since the record then cannot be attributed to +// anyone. +func CheckIdentityMatch(requested []byte, stored []byte) error { + if len(requested) == 0 { + return ErrEmptyIdentity + } + if !bytes.Equal(requested, stored) { + return errors.Wrapf(ErrIdentityMismatch, "requested identity of %d bytes, stored identity of %d bytes", len(requested), len(stored)) + } + + return nil +} diff --git a/token/services/storage/integrity/integrity.go b/token/services/storage/integrity/integrity.go new file mode 100644 index 0000000000..b537891749 --- /dev/null +++ b/token/services/storage/integrity/integrity.go @@ -0,0 +1,241 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package integrity provides the integrity checks the storage services apply to +// high-value payloads — token requests, public parameters, identities, and +// endorsement acknowledgements — before persisting them and after reading them +// back. +// +// The checks are deliberately cheap and self-contained: they need only the +// bytes themselves plus the key those bytes are stored under. They are not +// cryptographic verification. A token request is only fully verified by a +// [github.com/LFDT-Panurus/panurus/token.Validator] against a ledger, and an +// endorsement acknowledgement is only fully verified against the payload that +// was signed; both of those require state this package does not have, and both +// already happen at the layer that does — see +// docs/security/store_integrity_verification.md for the full division of +// responsibility. +// +// What these checks give is a fail-closed storage boundary. A payload that +// could not have been produced by a correct caller — empty, not a token +// request, declaring a protocol version this build does not implement, or bound +// to a transaction other than the one it is filed under — is rejected instead +// of being stored, or instead of being handed to a caller that will treat it as +// authentic evidence. +package integrity + +import ( + "bytes" + "encoding/base64" + + "github.com/LFDT-Panurus/panurus/token/driver" + "github.com/LFDT-Panurus/panurus/token/driver/protos-go/v1/request" + "github.com/LFDT-Panurus/panurus/token/services/utils" + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" + "google.golang.org/protobuf/proto" +) + +// Sentinel errors returned by the checks in this package. Callers that need to +// tell "this payload is structurally impossible" apart from a transport or +// database failure should match against these with errors.Is. +var ( + // ErrEmptyTxID is returned when a payload is filed under, or looked up by, + // an empty transaction id. + ErrEmptyTxID = errors.New("empty transaction id") + // ErrEmptyTokenRequest is returned when a token request payload is empty. + ErrEmptyTokenRequest = errors.New("empty token request") + // ErrMalformedTokenRequest is returned when a token request payload cannot + // be deserialized as the wire format expected at that storage boundary. + ErrMalformedTokenRequest = errors.New("malformed token request") + // ErrUnsupportedTokenRequestVersion is returned when a token request + // declares a protocol version this build does not implement. + ErrUnsupportedTokenRequestVersion = errors.New("unsupported token request version") + // ErrAnchorMismatch is returned when a token request is bound to an anchor + // other than the transaction id it is stored under. + ErrAnchorMismatch = errors.New("token request anchor does not match transaction id") + // ErrNoActions is returned when a token request carries no actions, and so + // could not have been accepted by a validator. + ErrNoActions = errors.New("token request carries no actions") + // ErrEmptyPublicParamsHash is returned when the public parameters hash + // accompanying a token request is empty. + ErrEmptyPublicParamsHash = errors.New("empty public parameters hash") + // ErrEmptyEndorser is returned when an endorsement acknowledgement carries + // no endorser identity. + ErrEmptyEndorser = errors.New("empty endorser identity") + // ErrEmptySignature is returned when an endorsement acknowledgement carries + // no signature. + ErrEmptySignature = errors.New("empty endorsement signature") + // ErrPublicParamsHashMismatch is returned when stored public parameters do + // not hash to the hash they are filed under. + ErrPublicParamsHashMismatch = errors.New("stored public parameters do not hash to the requested hash") +) + +// CheckTokenRequestForStorage is the check applied before a token request is +// persisted. It is constant-time on purpose: at every insert site the payload +// was either just serialized from an in-memory request or just validated in the +// caller's own scope, so re-parsing it would cost time proportional to the +// request size and learn nothing new. +// +// What it does enforce is that the record will be checkable later. A record +// stored under an empty transaction id cannot be bound to a transaction; a +// record stored with empty bytes cannot be replayed or hashed; a record stored +// with an empty public parameters hash silently disables the +// public-parameters-mismatch check performed on the finality and recovery +// paths, turning a check that would have failed into one that never runs. +func CheckTokenRequestForStorage(txID string, raw []byte, ppHash driver.PPHash) error { + if txID == "" { + return ErrEmptyTxID + } + if len(raw) == 0 { + return errors.Wrapf(ErrEmptyTokenRequest, "refusing to store token request for [%s]", txID) + } + if len(ppHash) == 0 { + return errors.Wrapf(ErrEmptyPublicParamsHash, "refusing to store token request for [%s]", txID) + } + + return nil +} + +// CheckStoredTokenRequest verifies that raw, as read back from storage under +// txID, is a well-formed serialized [github.com/LFDT-Panurus/panurus/token.Request] +// — the format produced by Request.Bytes and stored by the ttxdb and auditdb +// services — and that it is anchored to txID. +// +// The anchor check is the substantive one. The anchor is the transaction id the +// request commits to and is covered by the signatures inside the request, so it +// is the one field that ties the bytes to the row they were found in. Callers +// treat a retrieved request as authentic evidence about txID: they hash it and +// compare against the ledger, re-broadcast it, or hand it to an auditor. If two +// rows were swapped, or a row was rewritten out of band, the request's +// signatures authorize a different transaction than the caller is about to +// attribute them to, and no downstream check catches it — the finality-path +// hash comparison would report a hash mismatch for whichever transaction the +// bytes actually belong to. +// +// The cost is one protobuf unmarshal per retrieved request, on paths that then +// do considerably more work with the result. +func CheckStoredTokenRequest(txID string, raw []byte) error { + if txID == "" { + return ErrEmptyTxID + } + if len(raw) == 0 { + return errors.Wrapf(ErrEmptyTokenRequest, "token request for [%s]", txID) + } + + requestWithMetadata := &request.TokenRequestWithMetadata{} + if err := proto.Unmarshal(raw, requestWithMetadata); err != nil { + return errors.Wrapf(ErrMalformedTokenRequest, "failed unmarshalling token request for [%s]: %v", txID, err) + } + if requestWithMetadata.Version != driver.ProtocolV1 { + return errors.Wrapf(ErrUnsupportedTokenRequestVersion, "token request for [%s] declares version [%d], expected [%d]", txID, requestWithMetadata.Version, driver.ProtocolV1) + } + if requestWithMetadata.Anchor != txID { + return errors.Wrapf(ErrAnchorMismatch, "token request stored under [%s] is anchored to [%s]", txID, requestWithMetadata.Anchor) + } + + return nil +} + +// CheckTokenRequestActions verifies that raw is a well-formed serialized +// [github.com/LFDT-Panurus/panurus/token/driver.TokenRequest] — the bare +// actions-and-signatures format, without anchor or metadata, that the endorser +// store holds — and that it carries at least one action. +// +// The two conditions are exactly the ones a validator rejects on before looking +// at any action: an unsupported protocol version, and an empty action list. +// This is not a substitute for validation. It is applied where a raw payload +// arrives from the network and is about to be persisted as the validated +// request for a transaction, so that a payload reaching that store on a path +// that skipped validation is rejected rather than filed as validated. +// +// Unlike CheckStoredTokenRequest there is no anchor to compare: this format +// does not carry one, so records in this format cannot be bound to their +// transaction id by a structural check alone. +func CheckTokenRequestActions(raw []byte) error { + if len(raw) == 0 { + return ErrEmptyTokenRequest + } + + tokenRequest := &driver.TokenRequest{} + if err := tokenRequest.FromBytes(raw); err != nil { + if errors.Is(err, driver.ErrUnsupportedVersion) { + return errors.Wrapf(ErrUnsupportedTokenRequestVersion, "%v", err) + } + + return errors.Wrapf(ErrMalformedTokenRequest, "failed unmarshalling token request actions: %v", err) + } + if len(tokenRequest.Actions) == 0 { + return ErrNoActions + } + + return nil +} + +// CheckEndorsementAck verifies that an endorsement acknowledgement carries both +// an endorser identity and a signature. +// +// An acknowledgement row is evidence that a party signed off on a transaction. +// Consumers of [github.com/LFDT-Panurus/panurus/token/services/ttx.TransactionInfo] +// read acknowledgements as a map keyed by endorser and do not inspect the +// values, so a row with an empty signature is indistinguishable from a genuine +// one and turns "this party never signed" into "this party signed". An empty +// endorser identity collapses to a single map key and would mask, or be masked +// by, an unrelated party's acknowledgement. +// +// This does not verify the signature. Verifying it requires the payload that +// was signed, which is not persisted alongside the acknowledgement; the +// verification therefore happens where that payload is still in scope, before +// the acknowledgement reaches storage. +func CheckEndorsementAck(endorser []byte, sigma []byte) error { + if len(endorser) == 0 { + return ErrEmptyEndorser + } + if len(sigma) == 0 { + return ErrEmptySignature + } + + return nil +} + +// CheckPublicParamsHash verifies that raw, read back from storage under +// rawHash, actually hashes to rawHash. +// +// Public parameters are the highest-value payload the token store holds: they +// carry the issuer and auditor public keys and the cryptographic setup every +// action is validated against. They are addressed by hash precisely because the +// hash is what a caller has already established out of band — a transaction +// records the hash of the parameters it was created under, and the finality and +// recovery paths fetch parameters by that hash in order to re-validate. Recomputing +// the hash is what makes that addressing mean anything: without it, a row whose +// raw and raw_hash columns disagree hands back parameters the caller did not ask +// for, and every subsequent check runs against the wrong setup while appearing to +// run against the right one. +// +// An empty rawHash is refused, because it would make the comparison vacuous. An +// empty raw is not an error: the store's convention is that an absent record +// reads back as nil bytes with no error, and distinguishing "absent" from +// "corrupt" is the caller's job. +// +// The cost is one SHA-256 over the parameters, on paths that then deserialize +// and use them. +func CheckPublicParamsHash(rawHash driver.PPHash, raw []byte) error { + if len(rawHash) == 0 { + return ErrEmptyPublicParamsHash + } + if len(raw) == 0 { + return nil + } + if computed := utils.Hashable(raw).Raw(); !bytes.Equal(computed, rawHash) { + return errors.Wrapf( + ErrPublicParamsHashMismatch, + "public parameters stored under hash [%s] hash to [%s]", + base64.StdEncoding.EncodeToString(rawHash), + base64.StdEncoding.EncodeToString(computed), + ) + } + + return nil +} diff --git a/token/services/storage/integrity/integrity_test.go b/token/services/storage/integrity/integrity_test.go new file mode 100644 index 0000000000..c41200db17 --- /dev/null +++ b/token/services/storage/integrity/integrity_test.go @@ -0,0 +1,499 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package integrity_test + +import ( + "testing" + + "github.com/LFDT-Panurus/panurus/token/driver" + "github.com/LFDT-Panurus/panurus/token/driver/protos-go/v1/request" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" + "github.com/LFDT-Panurus/panurus/token/services/utils" + "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +// storedTokenRequest builds the wire format the ttx and audit stores hold: a +// TokenRequestWithMetadata at the given version, anchored to the given anchor. +// It panics rather than taking a *testing.T so it can also build fuzz seeds. +func storedTokenRequest(version uint32, anchor string) []byte { + raw, err := proto.Marshal(&request.TokenRequestWithMetadata{ + Version: version, + Anchor: anchor, + Request: &request.TokenRequest{ + Version: uint32(driver.ProtocolV1), + Actions: []*request.Action{{ + Action: &request.Action_TypedAction{ + TypedAction: &request.TypedAction{ + Type: request.ActionType_ACTION_TYPE_TRANSFER, + Raw: []byte("action"), + }, + }, + }}, + }, + }) + if err != nil { + panic(err) + } + + return raw +} + +// actionsTokenRequest builds the bare actions-and-signatures wire format the +// endorser store holds, with the given number of actions. It panics rather than +// taking a *testing.T so it can also build fuzz seeds. +func actionsTokenRequest(version uint32, actions int) []byte { + tr := &request.TokenRequest{Version: version} + for range actions { + tr.Actions = append(tr.Actions, &request.Action{ + Action: &request.Action_TypedAction{ + TypedAction: &request.TypedAction{ + Type: request.ActionType_ACTION_TYPE_ISSUE, + Raw: []byte("action"), + }, + }, + }) + } + raw, err := proto.Marshal(tr) + if err != nil { + panic(err) + } + + return raw +} + +func TestCheckTokenRequestForStorage(t *testing.T) { + tests := []struct { + name string + txID string + raw []byte + ppHash driver.PPHash + expected error + }{ + { + name: "valid", + txID: "tx1", + raw: []byte("token request"), + ppHash: driver.PPHash("pp-hash"), + }, + { + name: "empty tx id", + txID: "", + raw: []byte("token request"), + ppHash: driver.PPHash("pp-hash"), + expected: integrity.ErrEmptyTxID, + }, + { + name: "nil token request", + txID: "tx1", + raw: nil, + ppHash: driver.PPHash("pp-hash"), + expected: integrity.ErrEmptyTokenRequest, + }, + { + name: "empty token request", + txID: "tx1", + raw: []byte{}, + ppHash: driver.PPHash("pp-hash"), + expected: integrity.ErrEmptyTokenRequest, + }, + { + name: "nil public params hash", + txID: "tx1", + raw: []byte("token request"), + ppHash: nil, + expected: integrity.ErrEmptyPublicParamsHash, + }, + { + name: "empty public params hash", + txID: "tx1", + raw: []byte("token request"), + ppHash: driver.PPHash{}, + expected: integrity.ErrEmptyPublicParamsHash, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := integrity.CheckTokenRequestForStorage(test.txID, test.raw, test.ppHash) + if test.expected == nil { + assert.NoError(t, err) + + return + } + require.Error(t, err) + assert.True(t, errors.Is(err, test.expected), "expected [%v], got [%v]", test.expected, err) + }) + } +} + +func TestCheckStoredTokenRequest(t *testing.T) { + valid := storedTokenRequest(uint32(driver.ProtocolV1), "tx1") + + tests := []struct { + name string + txID string + raw []byte + expected error + }{ + { + name: "valid", + txID: "tx1", + raw: valid, + }, + { + name: "empty tx id", + txID: "", + raw: valid, + expected: integrity.ErrEmptyTxID, + }, + { + name: "empty token request", + txID: "tx1", + raw: nil, + expected: integrity.ErrEmptyTokenRequest, + }, + { + name: "not a token request", + txID: "tx1", + raw: []byte{0xff, 0xff, 0xff, 0xff}, + expected: integrity.ErrMalformedTokenRequest, + }, + { + name: "unsupported version", + txID: "tx1", + raw: storedTokenRequest(uint32(driver.ProtocolV1)+1, "tx1"), + expected: integrity.ErrUnsupportedTokenRequestVersion, + }, + { + // the request of another transaction, filed under tx1 + name: "anchor mismatch", + txID: "tx1", + raw: storedTokenRequest(uint32(driver.ProtocolV1), "tx2"), + expected: integrity.ErrAnchorMismatch, + }, + { + name: "no anchor", + txID: "tx1", + raw: storedTokenRequest(uint32(driver.ProtocolV1), ""), + expected: integrity.ErrAnchorMismatch, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := integrity.CheckStoredTokenRequest(test.txID, test.raw) + if test.expected == nil { + assert.NoError(t, err) + + return + } + require.Error(t, err) + assert.True(t, errors.Is(err, test.expected), "expected [%v], got [%v]", test.expected, err) + }) + } +} + +func TestCheckTokenRequestActions(t *testing.T) { + tests := []struct { + name string + raw []byte + expected error + }{ + { + name: "valid", + raw: actionsTokenRequest(uint32(driver.ProtocolV1), 1), + }, + { + name: "valid with several actions", + raw: actionsTokenRequest(uint32(driver.ProtocolV1), 3), + }, + { + name: "empty", + raw: nil, + expected: integrity.ErrEmptyTokenRequest, + }, + { + name: "not a token request", + raw: []byte{0xff, 0xff, 0xff, 0xff}, + expected: integrity.ErrMalformedTokenRequest, + }, + { + name: "unsupported version", + raw: actionsTokenRequest(uint32(driver.ProtocolV1)+1, 1), + expected: integrity.ErrUnsupportedTokenRequestVersion, + }, + { + name: "no actions", + raw: actionsTokenRequest(uint32(driver.ProtocolV1), 0), + expected: integrity.ErrNoActions, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := integrity.CheckTokenRequestActions(test.raw) + if test.expected == nil { + assert.NoError(t, err) + + return + } + require.Error(t, err) + assert.True(t, errors.Is(err, test.expected), "expected [%v], got [%v]", test.expected, err) + }) + } +} + +// TestCheckStoredTokenRequest_RejectsActionsOnlyFormat pins the two wire formats +// apart: the endorser store's bare actions format has no anchor field, so it can +// never satisfy the anchor binding the ttx and audit stores require. +func TestCheckStoredTokenRequest_RejectsActionsOnlyFormat(t *testing.T) { + raw := actionsTokenRequest(uint32(driver.ProtocolV1), 1) + require.NoError(t, integrity.CheckTokenRequestActions(raw)) + + err := integrity.CheckStoredTokenRequest("tx1", raw) + require.Error(t, err) + assert.True(t, errors.Is(err, integrity.ErrAnchorMismatch) || errors.Is(err, integrity.ErrMalformedTokenRequest) || + errors.Is(err, integrity.ErrUnsupportedTokenRequestVersion), "unexpected error [%v]", err) +} + +func TestCheckEndorsementAck(t *testing.T) { + tests := []struct { + name string + endorser []byte + sigma []byte + expected error + }{ + { + name: "valid", + endorser: []byte("endorser"), + sigma: []byte("signature"), + }, + { + name: "nil endorser", + endorser: nil, + sigma: []byte("signature"), + expected: integrity.ErrEmptyEndorser, + }, + { + name: "empty endorser", + endorser: []byte{}, + sigma: []byte("signature"), + expected: integrity.ErrEmptyEndorser, + }, + { + name: "nil signature", + endorser: []byte("endorser"), + sigma: nil, + expected: integrity.ErrEmptySignature, + }, + { + name: "empty signature", + endorser: []byte("endorser"), + sigma: []byte{}, + expected: integrity.ErrEmptySignature, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := integrity.CheckEndorsementAck(test.endorser, test.sigma) + if test.expected == nil { + assert.NoError(t, err) + + return + } + require.Error(t, err) + assert.True(t, errors.Is(err, test.expected), "expected [%v], got [%v]", test.expected, err) + }) + } +} + +func TestCheckPublicParamsHash(t *testing.T) { + raw := []byte("public parameters") + hash := driver.PPHash(utils.Hashable(raw).Raw()) + other := []byte("other public parameters") + + tests := []struct { + name string + hash driver.PPHash + raw []byte + expected error + }{ + { + name: "matching hash", + hash: hash, + raw: raw, + }, + { + // the store reports an absent record as nil bytes and no error; + // telling absent from corrupt is the caller's job + name: "no stored parameters", + hash: hash, + raw: nil, + }, + { + name: "parameters of another setup", + hash: hash, + raw: other, + expected: integrity.ErrPublicParamsHashMismatch, + }, + { + name: "truncated parameters", + hash: hash, + raw: raw[:len(raw)-1], + expected: integrity.ErrPublicParamsHashMismatch, + }, + { + name: "hash is not a hash of anything", + hash: driver.PPHash("not-a-hash"), + raw: raw, + expected: integrity.ErrPublicParamsHashMismatch, + }, + { + name: "nil hash", + hash: nil, + raw: raw, + expected: integrity.ErrEmptyPublicParamsHash, + }, + { + name: "empty hash", + hash: driver.PPHash{}, + raw: raw, + expected: integrity.ErrEmptyPublicParamsHash, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := integrity.CheckPublicParamsHash(test.hash, test.raw) + if test.expected == nil { + assert.NoError(t, err) + + return + } + require.Error(t, err) + assert.True(t, errors.Is(err, test.expected), "expected [%v], got [%v]", test.expected, err) + }) + } +} + +func TestCheckIdentity(t *testing.T) { + require.NoError(t, integrity.CheckIdentity([]byte("alice"))) + assert.True(t, errors.Is(integrity.CheckIdentity(nil), integrity.ErrEmptyIdentity)) + assert.True(t, errors.Is(integrity.CheckIdentity([]byte{}), integrity.ErrEmptyIdentity)) +} + +func TestCheckIdentityMatch(t *testing.T) { + tests := []struct { + name string + requested []byte + stored []byte + expected error + }{ + { + name: "match", + requested: []byte("alice"), + stored: []byte("alice"), + }, + { + name: "different identity of the same length", + requested: []byte("alice"), + stored: []byte("bobby"), + expected: integrity.ErrIdentityMismatch, + }, + { + name: "prefix is not a match", + requested: []byte("alice"), + stored: []byte("alice-and-bob"), + expected: integrity.ErrIdentityMismatch, + }, + { + name: "no stored identity", + requested: []byte("alice"), + stored: nil, + expected: integrity.ErrIdentityMismatch, + }, + { + name: "empty requested identity", + requested: nil, + stored: []byte("alice"), + expected: integrity.ErrEmptyIdentity, + }, + { + // both empty must not be reported as a match: it is the shared + // "" row key, not an identity + name: "both empty", + requested: nil, + stored: nil, + expected: integrity.ErrEmptyIdentity, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := integrity.CheckIdentityMatch(test.requested, test.stored) + if test.expected == nil { + assert.NoError(t, err) + + return + } + require.Error(t, err) + assert.True(t, errors.Is(err, test.expected), "expected [%v], got [%v]", test.expected, err) + }) + } +} + +// TestCheckIdentityMatch_DoesNotLeakIdentities checks that the mismatch error +// reports lengths only: the message ends up in logs, and the identities involved +// are the caller's and the store's, not the log reader's. +func TestCheckIdentityMatch_DoesNotLeakIdentities(t *testing.T) { + err := integrity.CheckIdentityMatch([]byte("alice-secret"), []byte("bob-secret")) + require.Error(t, err) + assert.NotContains(t, err.Error(), "alice-secret") + assert.NotContains(t, err.Error(), "bob-secret") +} + +func FuzzCheckStoredTokenRequest(f *testing.F) { + f.Add("tx1", storedTokenRequest(uint32(driver.ProtocolV1), "tx1")) + f.Add("tx1", storedTokenRequest(uint32(driver.ProtocolV1), "tx2")) + f.Add("tx1", storedTokenRequest(uint32(driver.ProtocolV1)+1, "tx1")) + f.Add("tx1", []byte(nil)) + f.Add("", []byte(nil)) + f.Add("tx1", []byte{0xff, 0xff, 0xff, 0xff}) + f.Add("tx1", []byte{0x08}) + f.Add("tx1", actionsTokenRequest(uint32(driver.ProtocolV1), 1)) + + f.Fuzz(func(t *testing.T, txID string, raw []byte) { + // the contract is that no input panics and that success implies the + // payload is anchored to txID + if err := integrity.CheckStoredTokenRequest(txID, raw); err != nil { + return + } + requestWithMetadata := &request.TokenRequestWithMetadata{} + require.NoError(t, proto.Unmarshal(raw, requestWithMetadata)) + require.Equal(t, txID, requestWithMetadata.Anchor) + require.NotEmpty(t, txID) + }) +} + +func FuzzCheckTokenRequestActions(f *testing.F) { + f.Add(actionsTokenRequest(uint32(driver.ProtocolV1), 1)) + f.Add(actionsTokenRequest(uint32(driver.ProtocolV1), 0)) + f.Add(actionsTokenRequest(uint32(driver.ProtocolV1)+1, 1)) + f.Add([]byte(nil)) + f.Add([]byte{0xff, 0xff, 0xff, 0xff}) + f.Add([]byte{0x08}) + f.Add(storedTokenRequest(uint32(driver.ProtocolV1), "tx1")) + + f.Fuzz(func(t *testing.T, raw []byte) { + // the contract is that no input panics and that success implies a + // deserializable request with at least one action + if err := integrity.CheckTokenRequestActions(raw); err != nil { + return + } + tokenRequest := &driver.TokenRequest{} + require.NoError(t, tokenRequest.FromBytes(raw)) + require.NotEmpty(t, tokenRequest.Actions) + }) +} diff --git a/token/services/storage/integrity/nobypass_test.go b/token/services/storage/integrity/nobypass_test.go new file mode 100644 index 0000000000..8d20d032b1 --- /dev/null +++ b/token/services/storage/integrity/nobypass_test.go @@ -0,0 +1,264 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +// The tests in this file enforce the no-bypass rule stated in +// docs/security/store_integrity_verification.md: the checks in this package are +// unconditional, and nothing — no functional option, no setter, no configuration +// key — may be added that turns one of them off. A security check a deployment +// can disable is not a security posture, and a check that is disabled by default +// in some deployment is worse than no check at all, because the contract clauses +// on the store interfaces claim it holds. +// +// The rule is enforced by reading the source, rather than by convention, because +// the failure it guards against is a future well-meaning change ("make this +// opt-in so it does not break my deployment") that no behavioural test would +// catch. +package integrity_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// checkedPackages are the packages that apply the checks in this package. A +// bypass would have to be introduced in one of them, or in this package itself. +var checkedPackages = []string{ + ".", + "../ttxdb", + "../auditdb", + "../endorserdb", + "../db/sql/common", + "../db/kvs", + "../../identity", + "../../identity/wallet", + "../../ttx", + "../../..", +} + +// bypassNames matches identifiers that would name a way to skip verification. +// It is deliberately broad: the point is to fail on the attempt, and a rename to +// something this does not match is a conscious act rather than an oversight. Add +// to it rather than narrowing it. +var bypassNames = regexp.MustCompile( + `(?i)(skip|disable|without|no|bypass|unsafe|unchecked|ignore)_?` + + `(integrity|verification|verify|check|validation|validate)`, +) + +// parsePackageDir parses the non-test Go files of one package directory. +func parsePackageDir(t *testing.T, dir string) (*token.FileSet, []*ast.File) { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err, "cannot read package directory [%s]", dir) + + fset := token.NewFileSet() + files := make([]*ast.File, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.SkipObjectResolution) + require.NoError(t, err, "cannot parse [%s]", name) + files = append(files, file) + } + require.NotEmpty(t, files, "no source files found in [%s]", dir) + + return fset, files +} + +// TestNoBypassIdentifiers asserts that no package applying the integrity checks +// declares anything named like a way to turn them off — no function, method, +// type, field, variable, or constant. +func TestNoBypassIdentifiers(t *testing.T) { + for _, dir := range checkedPackages { + t.Run(dir, func(t *testing.T) { + fset, files := parsePackageDir(t, dir) + for _, file := range files { + ast.Inspect(file, func(n ast.Node) bool { + ident, ok := n.(*ast.Ident) + if !ok { + return true + } + assert.False(t, bypassNames.MatchString(ident.Name), + "%s: identifier [%s] names a way to skip verification; the checks in "+ + "token/services/storage/integrity are unconditional by design — see "+ + "docs/security/store_integrity_verification.md", + fset.Position(ident.Pos()), ident.Name) + + return true + }) + } + }) + } +} + +// TestChecksTakeNoOptions asserts that the exported checks of this package are +// plain functions of their inputs: not variadic, and returning only an error. +// A variadic parameter is how an option that weakens a check would be added +// without changing any call site, so it must not exist in the first place. +func TestChecksTakeNoOptions(t *testing.T) { + fset, files := parsePackageDir(t, ".") + + found := 0 + for _, file := range files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || !fn.Name.IsExported() || !strings.HasPrefix(fn.Name.Name, "Check") { + continue + } + found++ + + for _, param := range fn.Type.Params.List { + _, variadic := param.Type.(*ast.Ellipsis) + assert.False(t, variadic, + "%s: %s takes a variadic parameter; the checks must not be configurable", + fset.Position(param.Pos()), fn.Name.Name) + } + + require.NotNil(t, fn.Type.Results, "%s must return an error", fn.Name.Name) + assert.Len(t, fn.Type.Results.List, 1, + "%s must return an error and nothing else, so that a caller cannot ignore the "+ + "verdict while still using a result", fn.Name.Name) + } + } + assert.GreaterOrEqual(t, found, 6, "expected to have found the exported Check functions") +} + +// TestChecksExposeNoMutableState asserts that this package holds no mutable +// package-level state. Anything settable at runtime — a flag, a hook, a +// replaceable function value — is a bypass, whether or not it is named like one. +// The only package-level values allowed are the sentinel errors, which are +// compared against and never assigned. +func TestChecksExposeNoMutableState(t *testing.T) { + fset, files := parsePackageDir(t, ".") + + for _, file := range files { + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.VAR { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for _, name := range value.Names { + assert.True(t, strings.HasPrefix(name.Name, "Err") || strings.HasPrefix(name.Name, "err"), + "%s: package-level variable [%s] is not a sentinel error; the integrity "+ + "package must hold no state a deployment could change", + fset.Position(name.Pos()), name.Name) + } + } + } + } +} + +// TestNoVerificationConfigKey asserts that no configuration key read by the +// storage layer controls verification. The keys are declared as constants, so +// this reads them from the source rather than from a hand-maintained list that +// would drift. +func TestNoVerificationConfigKey(t *testing.T) { + for _, dir := range []string{"../db/sql/common", "../services/cleanup", "../services/recovery"} { + t.Run(dir, func(t *testing.T) { + fset, files := parsePackageDir(t, dir) + for _, file := range files { + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range value.Names { + if !strings.HasPrefix(name.Name, "ConfigKey") { + continue + } + assert.False(t, bypassNames.MatchString(name.Name), + "%s: configuration key constant [%s] controls verification", + fset.Position(name.Pos()), name.Name) + if i < len(value.Values) { + if lit, ok := value.Values[i].(*ast.BasicLit); ok { + assert.False(t, bypassNames.MatchString(lit.Value), + "%s: configuration key [%s] controls verification", + fset.Position(lit.Pos()), lit.Value) + } + } + } + } + } + } + }) + } +} + +// TestCheckResultsAreNotDiscarded asserts that no caller of an integrity check +// throws its verdict away. A check whose error is assigned to the blank +// identifier, or called as a bare statement, reports nothing and is +// indistinguishable at runtime from a check that was never added — which is +// exactly the bypass this file exists to prevent, arrived at by accident rather +// than by design. +func TestCheckResultsAreNotDiscarded(t *testing.T) { + for _, dir := range checkedPackages { + if dir == "." { + continue // the checks do not call each other + } + t.Run(dir, func(t *testing.T) { + fset, files := parsePackageDir(t, dir) + for _, file := range files { + ast.Inspect(file, func(n ast.Node) bool { + switch stmt := n.(type) { + case *ast.ExprStmt: + // integrity.CheckX(...) as a statement of its own + assert.False(t, isIntegrityCheckCall(stmt.X), + "%s: the result of this integrity check is discarded", + fset.Position(stmt.Pos())) + case *ast.AssignStmt: + if len(stmt.Rhs) != 1 || !isIntegrityCheckCall(stmt.Rhs[0]) { + return true + } + for _, lhs := range stmt.Lhs { + ident, ok := lhs.(*ast.Ident) + assert.False(t, ok && ident.Name == "_", + "%s: the result of this integrity check is assigned to the blank identifier", + fset.Position(stmt.Pos())) + } + } + + return true + }) + } + }) + } +} + +// isIntegrityCheckCall reports whether expr is a call of the form +// integrity.CheckSomething(...). +func isIntegrityCheckCall(expr ast.Expr) bool { + call, ok := expr.(*ast.CallExpr) + if !ok { + return false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !strings.HasPrefix(sel.Sel.Name, "Check") { + return false + } + pkg, ok := sel.X.(*ast.Ident) + + return ok && pkg.Name == "integrity" +} diff --git a/token/services/storage/ttxdb/store.go b/token/services/storage/ttxdb/store.go index d175657cb6..9a4574c573 100644 --- a/token/services/storage/ttxdb/store.go +++ b/token/services/storage/ttxdb/store.go @@ -18,6 +18,7 @@ import ( "github.com/LFDT-Panurus/panurus/token/services/storage/db/common" dbdriver "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver" "github.com/LFDT-Panurus/panurus/token/services/storage/db/multiplexed" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" cdriver "github.com/hyperledger-labs/fabric-smart-client/platform/common/driver" ) @@ -209,18 +210,22 @@ func (d *StoreService) AppendTransactionRecord(ctx context.Context, req *token.R } logger.DebugfContext(ctx, "storing new records... [%d,%d]", len(raw), len(txs)) + anchor := string(record.Anchor) + ppHash := req.PublicParamsHash() + if err := integrity.CheckTokenRequestForStorage(anchor, raw, ppHash); err != nil { + return errors.WithMessagef(err, "refusing to append transaction record for txid [%s]", record.Anchor) + } w, err := d.db.NewTransactionStoreTransaction() if err != nil { return errors.WithMessagef(err, "begin update for txid [%s] failed", record.Anchor) } - anchor := string(record.Anchor) if err := w.AddTokenRequest( ctx, anchor, raw, req.AllApplicationMetadata(), record.Attributes, - req.PublicParamsHash(), + ppHash, ); err != nil { w.Rollback() @@ -284,25 +289,99 @@ func (d *StoreService) GetStatuses(ctx context.Context, txIDs []string) (map[str } // GetTokenRequest returns the token request bound to the passed transaction id, if available. +// It returns nil without error if no request is stored for txID. +// +// Verification: the returned bytes are checked with +// integrity.CheckStoredTokenRequest before they are handed back, so a request +// that does not deserialize, declares an unsupported protocol version, or is +// anchored to a transaction other than txID is reported as an error rather than +// returned. Callers treat the result as authentic evidence about txID — they +// hash it against the ledger, re-broadcast it, or show it to an auditor — so a +// record that cannot be bound to txID must not reach them. func (d *StoreService) GetTokenRequest(ctx context.Context, txID string) ([]byte, error) { - return d.db.GetTokenRequest(ctx, txID) + raw, err := d.db.GetTokenRequest(ctx, txID) + if err != nil { + return nil, err + } + if raw == nil { + // not found, which is not an error at this layer + return nil, nil + } + if err := integrity.CheckStoredTokenRequest(txID, raw); err != nil { + logger.ErrorfContext(ctx, "stored token request for [%s] failed the integrity check: %v", txID, err) + + return nil, errors.WithMessagef(err, "stored token request for [%s] failed the integrity check", txID) + } + + return raw, nil } // GetTokenRequests returns the token requests bound to the given tx ids in // a single query. See driver.TransactionStore.GetTokenRequests for details // about missing-key semantics. +// +// Verification: as for GetTokenRequest, every returned request is checked with +// integrity.CheckStoredTokenRequest against the transaction id it is keyed +// under. A single failing record fails the whole call: the caller asked for a +// set of requests and cannot be expected to notice that one key silently went +// missing. func (d *StoreService) GetTokenRequests(ctx context.Context, txIDs []string) (map[string][]byte, error) { - return d.db.GetTokenRequests(ctx, txIDs) + requests, err := d.db.GetTokenRequests(ctx, txIDs) + if err != nil { + return nil, err + } + for txID, raw := range requests { + if err := integrity.CheckStoredTokenRequest(txID, raw); err != nil { + logger.ErrorfContext(ctx, "stored token request for [%s] failed the integrity check: %v", txID, err) + + return nil, errors.WithMessagef(err, "stored token request for [%s] failed the integrity check", txID) + } + } + + return requests, nil } -// AddTransactionEndorsementAck records the signature of a given endorser for a given transaction +// AddTransactionEndorsementAck records the signature of a given endorser for a given transaction. +// +// Verification: the caller must have verified sigma against the payload the +// endorser signed before calling this — that payload is not persisted, so it is +// the last point at which the signature can be checked. What this method +// enforces is that the acknowledgement is not vacuous: an empty endorser or an +// empty signature is rejected, because consumers read acknowledgements as a map +// keyed by endorser and never inspect the values, so such a row would be +// indistinguishable from a genuine acknowledgement. func (d *StoreService) AddTransactionEndorsementAck(ctx context.Context, txID string, id token.Identity, sigma []byte) error { + if txID == "" { + return errors.WithMessage(integrity.ErrEmptyTxID, "refusing to store endorsement ack") + } + if err := integrity.CheckEndorsementAck(id, sigma); err != nil { + return errors.WithMessagef(err, "refusing to store endorsement ack for txid [%s]", txID) + } + return d.db.AddTransactionEndorsementAck(ctx, txID, id, sigma) } -// GetTransactionEndorsementAcks returns the endorsement signatures for the given transaction id +// GetTransactionEndorsementAcks returns the endorsement signatures for the given transaction id. +// +// Verification: the signatures cannot be re-verified here, because the payload +// they were produced over is not persisted alongside them. Each row is checked +// for the same non-vacuity AddTransactionEndorsementAck enforces, so a row that +// carries no signature is reported instead of being passed off as an +// acknowledgement. func (d *StoreService) GetTransactionEndorsementAcks(ctx context.Context, txID string) (map[string][]byte, error) { - return d.db.GetTransactionEndorsementAcks(ctx, txID) + acks, err := d.db.GetTransactionEndorsementAcks(ctx, txID) + if err != nil { + return nil, err + } + for endorser, sigma := range acks { + if len(sigma) == 0 { + logger.ErrorfContext(ctx, "stored endorsement ack of [%s] for [%s] carries no signature", endorser, txID) + + return nil, errors.WithMessagef(integrity.ErrEmptySignature, "stored endorsement ack of [%s] for [%s]", endorser, txID) + } + } + + return acks, nil } // AcquireRecoveryLeadership tries to acquire the DB-backed recovery leadership lease. diff --git a/token/services/ttx/db.go b/token/services/ttx/db.go index 74dfb0f75c..be0e1c307c 100644 --- a/token/services/ttx/db.go +++ b/token/services/ttx/db.go @@ -106,10 +106,31 @@ func (a *Service) GetTokenRequest(ctx context.Context, txID string) ([]byte, err return a.ttxStoreService.GetTokenRequest(ctx, txID) } +// AppendTransactionEndorseAck records the endorsement acknowledgement signature +// produced by id over the transaction with the passed id. +// +// Verification: sigma is expected to have been verified against id before it +// reaches this method — the only production writer, +// CollectEndorsementsView.distributeTxToParty, verifies it against the exact +// payload it sent to that party before returning it. This layer therefore +// records the ack rather than re-establishing it, and the store refuses a +// record no later check could act on (empty transaction id, endorser or +// signature). See docs/security/store_integrity_verification.md. func (a *Service) AppendTransactionEndorseAck(ctx context.Context, txID string, id view.Identity, sigma []byte) error { return a.ttxStoreService.AddTransactionEndorsementAck(ctx, txID, id, sigma) } +// GetTransactionEndorsementAcks returns the endorsement acknowledgement +// signatures recorded for the passed transaction id, keyed by the unique id of +// the endorser. +// +// Verification: the signatures cannot be re-verified on read. The message each +// ack was produced over is the per-party transaction payload, which is filtered +// by the recipient's enrollment id and is not persisted, so the read side has +// no message to verify against — only the fact that a verified ack was recorded +// at dissemination time. Persisting a digest of the signed payload alongside +// each ack would make read-side re-verification possible and is left to a +// follow-up: it needs a schema change. func (a *Service) GetTransactionEndorsementAcks(ctx context.Context, id string) (map[string][]byte, error) { return a.ttxStoreService.GetTransactionEndorsementAcks(ctx, id) } diff --git a/token/services/ttx/owner.go b/token/services/ttx/owner.go index 4b29e69f5c..d99b8b5fbc 100644 --- a/token/services/ttx/owner.go +++ b/token/services/ttx/owner.go @@ -81,6 +81,9 @@ func (a *TxOwner) Check(ctx context.Context) ([]string, error) { // appendTransactionEndorseAck records an endorsement acknowledgment signature from a party // for the given transaction. This is used internally during transaction distribution to // track which parties have acknowledged receipt of the transaction. +// +// The caller must have verified sigma against id and the payload actually sent +// to that party before calling this — see Service.AppendTransactionEndorseAck. func (a *TxOwner) appendTransactionEndorseAck(ctx context.Context, tx *Transaction, id view.Identity, sigma []byte) error { return a.owner.AppendTransactionEndorseAck(ctx, tx.ID(), id, sigma) } diff --git a/token/sig.go b/token/sig.go index 958e441e1b..d7b46c867a 100644 --- a/token/sig.go +++ b/token/sig.go @@ -10,6 +10,7 @@ import ( "context" "github.com/LFDT-Panurus/panurus/token/driver" + "github.com/LFDT-Panurus/panurus/token/services/storage/integrity" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" ) @@ -55,15 +56,70 @@ func (s *SignatureService) GetSigner(ctx context.Context, id Identity) (Signer, } // RegisterSigner registers the pair (signer, verifier) bound to the given identity +// +// Verification: see checkSignerIdentity. The identity must be non-empty and must +// be one this driver can derive a verifier for. func (s *SignatureService) RegisterSigner(ctx context.Context, identity Identity, signer Signer, verifier Verifier) error { + if err := s.checkSignerIdentity(ctx, identity); err != nil { + return errors.WithMessage(err, "refusing to register signer") + } + return s.identityProvider.RegisterSigner(ctx, identity, signer, verifier, nil, false) } // RegisterEphemeralSigner registers the pair (signer, verifier) bound to the given identity only in memory +// +// Verification: as for RegisterSigner. An ephemeral registration never reaches +// storage but still populates the in-memory signer cache, which is keyed the +// same way, so it is held to the same conditions. func (s *SignatureService) RegisterEphemeralSigner(ctx context.Context, identity Identity, signer Signer, verifier Verifier) error { + if err := s.checkSignerIdentity(ctx, identity); err != nil { + return errors.WithMessage(err, "refusing to register ephemeral signer") + } + return s.identityProvider.RegisterSigner(ctx, identity, signer, verifier, nil, true) } +// checkSignerIdentity is the check applied before a signer is bound to an +// identity. +// +// It enforces two conditions. The identity must be non-empty, because identities +// are keyed by unique id and the unique id of the empty identity is a fixed +// string rather than a hash — every empty identity would share one cache and +// storage key, so a signer registered for one would be returned for any other. +// And the identity must be one this driver can derive a verifier for: signers are +// registered for identities that arrive from a remote party (see the recipient +// and multisig flows in token/services/ttx), and binding a signer to bytes no +// verifier can be built from produces an identity that can sign but whose +// signatures nothing can check. Any of the three roles is accepted, since this +// service is role-agnostic and each driver routes all three through the same +// typed-identity deserializer. +// +// What this deliberately does not do is check the supplied verifier against the +// identity. driver.Verifier exposes only Verify(message, sigma), with no +// canonical public key to compare, so establishing agreement would require a new +// accessor on every identity type. The in-tree callers that pass a verifier are +// the x509 and idemix key managers, which derive it from the identity they are +// registering, so the comparison would be a tautology there; the ttx callers +// pass nil. See docs/security/store_integrity_verification.md. +func (s *SignatureService) checkSignerIdentity(ctx context.Context, identity Identity) error { + if err := integrity.CheckIdentity(identity); err != nil { + return err + } + if _, err := s.deserializer.GetOwnerVerifier(ctx, identity); err == nil { + return nil + } + if _, err := s.deserializer.GetIssuerVerifier(ctx, identity); err == nil { + return nil + } + _, err := s.deserializer.GetAuditorVerifier(ctx, identity) + if err != nil { + return errors.Wrapf(err, "failed to derive any verifier for identity [%s]", identity) + } + + return nil +} + // AreMe returns the hashes of the passed identities that have a signer registered before func (s *SignatureService) AreMe(ctx context.Context, identities ...Identity) []string { return s.identityProvider.AreMe(ctx, identities...)