From e556bd704df6d85b52fe2dd3a930ad907e579204 Mon Sep 17 00:00:00 2001 From: "Hayim.Shaul@ibm.com" Date: Tue, 18 Aug 2026 10:29:08 +0000 Subject: [PATCH] feat(storage): verify integrity of high-value stored payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store services persisted whatever they were handed. Nothing in their API stated whether the bytes had been cryptographically verified, and nothing re-checked them on the way out, so a wrong row, a truncated blob or a cross-transaction payload was indistinguishable from authentic evidence. Add a small `token/services/storage/integrity` package with typed sentinel errors and wire it into the paths where a stored payload becomes a claim someone acts on: - Token requests are refused at insert if the transaction id, the request bytes or the public parameters hash are empty. On retrieval, `ttxdb`/`auditdb` deserialise the stored payload and require its anchor to equal the transaction id it is filed under. `endorserdb` holds the bare actions format, which carries no anchor, so it gets a version and non-empty-actions check instead. - Public parameters are addressed by their hash, so `PublicParamsByHash` now recomputes that hash over what it is about to return and refuses a mismatch. These carry the issuer and auditor keys and the setup every action is validated against. - Empty identities are refused, and identity lookups compare the stored identity against the requested one. This matters because an empty identity's unique id is the constant "", so every empty identity collides on one row and one cache key. Applied to both the SQL and KVS backends. - Endorsement acknowledgements are refused at insert if the endorser or the signature is empty. Re-verifying the signature on retrieval is not possible today: the row holds only (endorser, sigma), not the per-party filtered payload that was signed. Signer registration additionally refuses an empty identity or a nil verifier. A stronger check — that a supplied verifier agrees with the identity it is registered for — is not expressible: `driver.Verifier` is only `Verify(message, sigma) error` and exposes no canonical key. The checks are unconditional. `nobypass_test.go` parses the AST of the affected packages and fails if anyone adds a bypass-shaped identifier, gives a check an options parameter or a second result, introduces a verification config key, or discards a check's result. Cross-backend expectations live in the shared `dbtest` spec so SQL and KVS are held to one contract; that spec caught a real gap in SQL `GetTokenInfo`, which had no empty-identity guard and never compared the stored identity. Two fuzz targets cover the request parsers and are wired into the nightly fuzz matrix. `docs/security/store_integrity_verification.md` records the posture per asset class, including the classes that are deliberately not verified and why. Signed-off-by: Hayim.Shaul@ibm.com --- .github/workflows/nightly-fuzz.yml | 6 + docs/README.md | 1 + docs/security/store_integrity_verification.md | 154 ++++++ docs/services/identity.md | 29 + docs/services/storage.md | 14 + docs/services/storage/endorserdb.md | 19 +- docs/services/storage/ttxdb.md | 15 + .../services/auditor/auditor_internal_test.go | 3 + token/services/auditor/auditor_test.go | 44 +- token/services/identity/driver/storage.go | 27 + token/services/identity/provider.go | 28 + token/services/identity/wallet/service.go | 22 + token/services/storage/auditdb/store.go | 54 +- token/services/storage/db/dbtest/endorser.go | 2 +- token/services/storage/db/dbtest/identity.go | 40 ++ token/services/storage/db/dbtest/tokens.go | 14 + .../storage/db/dbtest/transactions.go | 12 +- token/services/storage/db/driver/audit.go | 9 + token/services/storage/db/driver/endorser.go | 9 + token/services/storage/db/driver/token.go | 8 + token/services/storage/db/driver/ttx.go | 28 + token/services/storage/db/kvs/identitydb.go | 92 +++- .../storage/db/sql/common/endorser.go | 13 +- .../storage/db/sql/common/identity.go | 112 +++- .../services/storage/db/sql/common/tokens.go | 21 +- .../storage/db/sql/common/transactions.go | 28 + token/services/storage/endorserdb/store.go | 25 +- token/services/storage/integrity/identity.go | 68 +++ token/services/storage/integrity/integrity.go | 241 +++++++++ .../storage/integrity/integrity_test.go | 499 ++++++++++++++++++ .../storage/integrity/nobypass_test.go | 264 +++++++++ token/services/storage/ttxdb/store.go | 93 +++- token/services/ttx/db.go | 21 + token/services/ttx/owner.go | 3 + token/sig.go | 56 ++ 35 files changed, 2041 insertions(+), 33 deletions(-) create mode 100644 docs/security/store_integrity_verification.md create mode 100644 token/services/storage/integrity/identity.go create mode 100644 token/services/storage/integrity/integrity.go create mode 100644 token/services/storage/integrity/integrity_test.go create mode 100644 token/services/storage/integrity/nobypass_test.go 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...)