Skip to content

Commit 56e4155

Browse files
authored
validator: define consensus-safe resource limits for untrusted requests (#1948)
Signed-off-by: Angelo De Caro <adc@zurich.ibm.com>
1 parent 1a7f69b commit 56e4155

75 files changed

Lines changed: 2671 additions & 67 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/nightly-fuzz.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ jobs:
5757
- name: identity-idemixnym-audit-info-deserializer
5858
pkg: ./token/services/identity/idemixnym/nym
5959
func: FuzzDeserializeAuditInfoNoPanic
60+
- name: common-request-limits
61+
pkg: ./token/core/common
62+
func: FuzzRequestResourceLimits
63+
- name: zkatdlog-action-limits
64+
pkg: ./token/core/zkatdlog/nogh/v1/validator
65+
func: FuzzActionResourceLimits
66+
- name: fabtoken-action-limits
67+
pkg: ./token/core/fabtoken/v1/validator
68+
func: FuzzActionResourceLimits
6069

6170
steps:
6271
- name: Checkout code
@@ -78,6 +87,7 @@ jobs:
7887
7988
- name: Run fuzz campaign
8089
run: |
90+
set -o pipefail
8191
go test ${{ matrix.pkg }} \
8292
-run='^$' \
8393
-fuzz='^${{ matrix.func }}$' \

cmd/token_validation_service/token_validation_service_bench.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
fabtoken "github.com/LFDT-Panurus/panurus/token/core/fabtoken/v1/driver"
2525
dlog "github.com/LFDT-Panurus/panurus/token/core/zkatdlog/nogh/v1/driver"
2626
v1setup "github.com/LFDT-Panurus/panurus/token/core/zkatdlog/nogh/v1/setup"
27+
"github.com/LFDT-Panurus/panurus/token/driver"
2728
tk "github.com/LFDT-Panurus/panurus/token/token"
2829
"github.com/hyperledger-labs/fabric-smart-client/platform/view/view"
2930
)
@@ -159,7 +160,7 @@ func (*fakeLedger) GetState(_ tk.ID) ([]byte, error) {
159160
}
160161

161162
func newTokenValidator(ppRaw []byte) (*token.Validator, error) {
162-
is := core.NewValidatorDriverService(fabtoken.NewValidatorDriver(), dlog.NewValidatorDriver())
163+
is := core.NewValidatorDriverService(driver.DefaultResourceLimits(), fabtoken.NewValidatorDriver(), dlog.NewValidatorDriver())
163164
ppm, err := is.PublicParametersFromBytes(ppRaw)
164165
if err != nil {
165166
return nil, fmt.Errorf("failed to deserialize public parameters: %w", err)

docs/configuration.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,29 @@ token:
107107
# interval is the polling interval for one-time lookups. Defaults to 2s.
108108
interval: 2s
109109

110+
# validation configures the resource limits enforced on untrusted token requests/actions
111+
# before they reach cryptographic verification. Process-wide (not per-TMS): both the FSC/DI
112+
# runtime and the standalone Fabric chaincode process resolve a single ResourceLimits value.
113+
# Every field is optional; any field left unset defaults to the safe, historical value.
114+
# The chaincode process (which has no config service) reads the same limits from
115+
# TOKEN_VALIDATION_MAX_* environment variables instead. See
116+
# docs/drivers/validation-resource-limits.md for the full enforcement and consensus-safety
117+
# contract: if you override any limit, every peer validating the same channel/namespace MUST
118+
# be configured with the identical value.
119+
validation:
120+
limits:
121+
maxRequestBytes: 262144
122+
maxActions: 256
123+
maxSignatures: 4096
124+
maxSignatureBytes: 4096
125+
maxActionBytes: 262144
126+
maxInputs: 256
127+
maxOutputs: 256
128+
maxMetadataEntries: 64
129+
maxMetadataKeyBytes: 256
130+
maxMetadataValueBytes: 4096
131+
maxProofBytes: 131072
132+
110133
# optional global SQL table name overrides (applied to all TMS instances).
111134
# The value replaces the short code; the FSC-generated prefix and params still wrap it.
112135
# Unknown keys are warned and ignored. Omit the section to keep all default names.
@@ -449,6 +472,60 @@ Default values:
449472

450473
---
451474

475+
### Optional: token.validation.limits
476+
477+
Process-wide (not per-TMS) resource limits enforced on untrusted token requests/actions before
478+
cryptographic verification. See [docs/drivers/validation-resource-limits.md](drivers/validation-resource-limits.md)
479+
for the full enforcement points and the consensus-safety contract.
480+
481+
If not specified, the default configuration is:
482+
483+
```yaml
484+
token:
485+
validation:
486+
limits:
487+
maxRequestBytes: 262144
488+
maxActions: 256
489+
maxSignatures: 4096
490+
maxSignatureBytes: 4096
491+
maxActionBytes: 262144
492+
maxInputs: 256
493+
maxOutputs: 256
494+
maxMetadataEntries: 64
495+
maxMetadataKeyBytes: 256
496+
maxMetadataValueBytes: 4096
497+
maxProofBytes: 131072
498+
```
499+
500+
Default values:
501+
502+
- maxRequestBytes: 262144 (256 KiB)
503+
- maxActions: 256
504+
- maxSignatures: 4096
505+
- maxSignatureBytes: 4096 (4 KiB)
506+
- maxActionBytes: 262144 (256 KiB)
507+
- maxInputs: 256
508+
- maxOutputs: 256
509+
- maxMetadataEntries: 64
510+
- maxMetadataKeyBytes: 256
511+
- maxMetadataValueBytes: 4096 (4 KiB)
512+
- maxProofBytes: 131072 (128 KiB) — ignored by drivers without a zero-knowledge proof (fabtoken)
513+
514+
Every field is optional; any field omitted (or the whole `token.validation.limits` key omitted)
515+
resolves to its default. Read via the config service, so this key applies only to the FSC/DI
516+
runtime. The standalone Fabric chaincode process (`token/services/network/fabric/tcc/main`) has no
517+
config service and instead reads the equivalent `TOKEN_VALIDATION_MAX_*` environment variables
518+
(e.g. `TOKEN_VALIDATION_MAX_ACTIONS`), with the same unset-defaults overlay.
519+
520+
**Consensus-safety warning:** the defaults above are safe and identical across every peer that
521+
does not override them. If you override any limit, every peer validating the same
522+
channel/namespace — and the chaincode process, if it enforces limits independently — MUST be
523+
configured with the identical value, or endorsement determinism silently breaks (one peer accepts
524+
a request another rejects). Treat a limits change like a `driver.MaxAnchorSize` change: roll it
525+
out as a coordinated configuration change before any peer relies on the new value.
526+
527+
---
528+
452529
### Optional: token.fabricx.lookup
453530

454531
If not specified, the default configuration is:

docs/driverapi.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ Panurus comes equipped with two reference drivers:
261261
- [**FabToken**](./drivers/fabtoken.md): A straightforward implementation prioritizing simplicity. It stores token transaction details (type, value, owner) in cleartext on the ledger, using X.509 certificates for identities.
262262
- [**DLOG w/o Graph Hiding (NOGH)**](./drivers/dlogwogh.md): A privacy-preserving driver using Zero-Knowledge Proofs (ZKP) to hide token types and values via Pedersen commitments. It leverages Idemix for owner anonymity while revealing the spending graph.
263263
- [**Extending a Validator Driver**](./drivers/extending_validator.md)
264+
- [**Validator Resource Limits**](./drivers/validation-resource-limits.md): the consensus-safe resource limits enforced on untrusted requests and actions before cryptographic verification.
264265

265266
## Observability
266267

docs/drivers/extending_validator.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,24 @@ The `ValidatorDriverService` (found in `token/core/service.go`) maintains a map
2020
```go
2121
type ValidatorDriverService struct {
2222
*factoryDirectory[driver.ValidatorDriver]
23+
limits driver.ResourceLimits
2324
}
2425

2526
func (s *ValidatorDriverService) NewValidator(pp driver.PublicParameters) (driver.Validator, error) {
2627
if driver, ok := s.factories[DriverIdentifierFromPP(pp)]; ok {
27-
return driver.NewValidator(pp)
28+
return driver.NewValidator(pp, s.limits)
2829
}
2930
return nil, errors.Errorf("no validator found for token driver [%s]", DriverIdentifierFromPP(pp))
3031
}
3132
```
3233

34+
`s.limits` is a `driver.ResourceLimits` value (see
35+
[Validator Resource Limits](validation-resource-limits.md)) resolved once at composition-root time
36+
and applied to every validator the service creates. Every `driver.ValidatorDriver.NewValidator`
37+
implementation — including any custom one you register — must accept and forward it; a wrapper
38+
that constructs a validator without it would silently drop the resource-limit enforcement described
39+
below.
40+
3341
By providing a custom factory with the same identifier as an existing driver, you can effectively "hijack" the validator creation process.
3442

3543
## Example: Extending the ZKAT-DLog Validator
@@ -70,7 +78,7 @@ type MyValidatorDriver struct {
7078
driver.ValidatorDriver // Wrap the existing driver
7179
}
7280

73-
func (d *MyValidatorDriver) NewValidator(pp driver.PublicParameters) (driver.Validator, error) {
81+
func (d *MyValidatorDriver) NewValidator(pp driver.PublicParameters, limits driver.ResourceLimits) (driver.Validator, error) {
7482
// We can't easily use the wrapped driver's NewValidator if we want to
7583
// inject functions into its internal pipeline, so we replicate its logic.
7684

@@ -86,11 +94,14 @@ func (d *MyValidatorDriver) NewValidator(pp driver.PublicParameters) (driver.Val
8694

8795
logger := logging.DriverLoggerFromPP("panurus.driver.myextension", string(pp.TokenDriverName()))
8896

89-
// Instantiate the validator with your custom function
97+
// Instantiate the validator with your custom function, forwarding the resource limits the
98+
// ValidatorDriverService resolved (see the Architecture section above) so this driver enforces
99+
// the same consensus-relevant bounds as every other validating peer.
90100
return validator.New(
91101
logger,
92102
ppp,
93103
deserializer,
104+
limits,
94105
[]validator.ValidateTransferFunc{MyCustomTransferValidation}, // Extra transfer validators
95106
nil, // Extra issuer validators
96107
nil, // Extra auditor validators
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Validator Resource Limits
2+
3+
This page describes the resource limits enforced on untrusted token requests and actions before
4+
they reach cryptographic verification, the configuration mechanism that controls them, and the
5+
consensus-safety contract that mechanism carries.
6+
7+
## Why limits exist
8+
9+
The token request validators (`token/core/common`, and the fabtoken/zkatdlog drivers built on top
10+
of it) accept raw, attacker-controlled bytes over the network. Aside from the signing anchor
11+
(`driver.MaxAnchorSize`), nothing else bounds the size of the raw request, the number of actions or
12+
signatures, the size of an individual action or signature, the number of inputs/outputs/metadata
13+
entries in an action, or the length of a zero-knowledge proof — unless these limits are enforced.
14+
Without them, an attacker could force unbounded allocations
15+
(`make([]..., len(attackerControlledCount))`) and expensive cryptographic work (proof
16+
deserialization, ZK verification) purely by shaping the wire bytes, without needing any valid
17+
signature.
18+
19+
## Configuration mechanism
20+
21+
Limits are held in a single struct, `driver.ResourceLimits` (`token/driver/limits.go`), injected
22+
into every validator at construction time — the validator itself never reads a package constant.
23+
`driver.DefaultResourceLimits()` returns the historical, always-safe values (see the tables below);
24+
`driver.ResourceLimits.WithDefaults()` overlays those defaults onto any zero-valued field, so a
25+
partially-specified override never silently disables a limit by leaving it at zero.
26+
27+
Two sources resolve a `driver.ResourceLimits` value at composition-root time, both implementing
28+
`driver.ResourceLimitsProvider`:
29+
30+
- **Config-backed** (`token/services/config.ResourceLimitsProvider`) — used by the FSC/DI runtime
31+
(`token/sdk/dig/providers.go`). Reads the process-wide key `token.validation.limits` via the
32+
configuration service and overlays `DefaultResourceLimits()` onto any field left unset:
33+
34+
```yaml
35+
token:
36+
validation:
37+
limits:
38+
maxActions: 128
39+
maxProofBytes: 65536
40+
```
41+
42+
Every field is optional; an entirely absent `token.validation.limits` key resolves to
43+
`DefaultResourceLimits()` unchanged.
44+
45+
- **Env-backed** (`token/services/network/fabric/tcc.EnvResourceLimitsProvider`) — used by the
46+
standalone Fabric chaincode process (`token/services/network/fabric/tcc/main/main.go`), which has
47+
no configuration service wired. Reads `TOKEN_VALIDATION_MAX_*` environment variables (e.g.
48+
`TOKEN_VALIDATION_MAX_ACTIONS`), applying the same unset-field-defaults overlay.
49+
50+
A `driver.StaticResourceLimits` provider (a trivial wrapper returning a fixed value) is used by
51+
tests, tools, and any caller that only needs the defaults (e.g.
52+
`cmd/token_validation_service`, the zkatdlog regression suite).
53+
54+
The resolved `driver.ResourceLimits` flows: composition root → `core.NewValidatorDriverService(limits, ...)`
55+
→ `driver.ValidatorDriver.NewValidator(pp, limits)` → the per-driver `common.NewValidator(..., limits, ...)`
56+
→ `ActionDeserializer.DeserializeActions`, which calls `action.SetLimits(limits)` on every
57+
deserialized action before `Deserialize` runs. Any action constructed without `SetLimits` (e.g. in
58+
tests or other non-validator call sites) falls back to `DefaultResourceLimits()` via an internal
59+
`effectiveLimits()` helper — never more permissive than the historical behavior.
60+
61+
## Consensus-safety contract
62+
63+
Every validating peer must reject or accept the same request identically, or endorsement becomes
64+
nondeterministic. Limits are no longer baked-in constants — they are configurable — which shifts
65+
the uniformity guarantee from "guaranteed by the binary" to **an explicit operator
66+
responsibility**:
67+
68+
- The out-of-the-box defaults (`DefaultResourceLimits()`) are safe and identical across every peer
69+
that does not override them; deployments that never touch `token.validation.limits` or
70+
`TOKEN_VALIDATION_MAX_*` keep the historical, always-consistent behavior.
71+
- **If you override any limit, every peer validating the same channel/namespace MUST be configured
72+
with the identical `ResourceLimits` value.** A peer with a looser `maxActions` will accept
73+
requests that a peer with the default (or a stricter) value rejects, silently breaking
74+
endorsement determinism — this will not surface as an error until peers disagree on a
75+
transaction's validity.
76+
- Treat a limits change the same way you would treat a `driver.MaxAnchorSize` change: roll it out
77+
as a coordinated configuration change across every validating peer (and the chaincode process, if
78+
it enforces limits independently) before any peer relies on the new value.
79+
80+
## Enforcement points
81+
82+
Limits are enforced at two boundaries, both strictly before the request or action is used to
83+
allocate proportional memory or is handed to a cryptographic verifier:
84+
85+
### 1. Common request envelope (`token/core/common/limits.go`)
86+
87+
Enforced by `(*Validator).CheckRawRequestSize` / `CheckRequestLimits`
88+
(`token/core/common/validator.go`), reading the validator's injected `Limits` field:
89+
90+
| Field | Default | Checked | Enforced by |
91+
| --- | --- | --- | --- |
92+
| `MaxRequestBytes` | 256 KiB | Raw serialized request size | `CheckRawRequestSize`, before `TokenRequest.FromBytes` |
93+
| `MaxActions` | 256 | Number of actions in the request | `CheckRequestLimits`, immediately after parsing |
94+
| `MaxSignatures` | 4096 | Number of request signatures | `CheckRequestLimits`, immediately after parsing |
95+
| `MaxActionBytes` | 256 KiB | Length of a single action's raw bytes | `CheckRequestLimits`, immediately after parsing |
96+
| `MaxSignatureBytes` | 4 KiB | Length of a single auditor or action signature | `CheckRequestLimits`, immediately after parsing |
97+
98+
`CheckRawRequestSize` runs before the protobuf decode, so an oversized message never reaches an
99+
allocation proportional to its own claimed size. `CheckRequestLimits` runs on the parsed request,
100+
before `MarshalToMessageToSign` and before any signature verification, so oversized or
101+
over-counted requests never reach cryptographic work. Violations return a typed error
102+
(`ErrRequestTooLarge`, `ErrTooManyActions`, `ErrTooManySignatures`, `ErrActionTooLarge`,
103+
`ErrSignatureTooLarge`), wrapping the effective (possibly configured) limit value.
104+
105+
### 2. Driver-specific action internals
106+
107+
Each driver bounds the shape of its own action payload, checked inside `Deserialize` (before the
108+
proportional-size allocations for inputs/outputs) and `Validate` (before proof-specific
109+
cryptographic work), using the action's `effectiveLimits()` (the limits injected via `SetLimits`,
110+
or `DefaultResourceLimits()` if none were set):
111+
112+
**ZKAT-DLOG NOGH v1** (`token/core/zkatdlog/nogh/v1/issue/limits.go`,
113+
`.../transfer/limits.go` — identical field defaults for issue and transfer actions):
114+
115+
| Field | Default |
116+
| --- | --- |
117+
| `MaxInputs` | 256 |
118+
| `MaxOutputs` | 256 |
119+
| `MaxMetadataEntries` | 64 |
120+
| `MaxMetadataKeyBytes` | 256 |
121+
| `MaxMetadataValueBytes` | 4 KiB |
122+
| `MaxProofBytes` | 128 KiB |
123+
124+
`MaxProofBytes` is checked before the zero-knowledge proof body is handed to the bulletproof/CSP
125+
verifier for deserialization, so an oversized proof is rejected without running any ZK-specific
126+
cryptographic code.
127+
128+
**FabToken v1** (`token/core/fabtoken/v1/actions/limits.go` — fabtoken has no ZK proof, so there is
129+
no `MaxProofBytes`):
130+
131+
| Field | Default |
132+
| --- | --- |
133+
| `MaxInputs` | 256 |
134+
| `MaxOutputs` | 256 |
135+
| `MaxMetadataEntries` | 64 |
136+
| `MaxMetadataKeyBytes` | 256 |
137+
| `MaxMetadataValueBytes` | 4 KiB |
138+
139+
Each driver-level violation returns its own typed error (e.g. `ErrTooManyInputs`,
140+
`ErrProofTooLarge`), wrapping the effective limit at check time.
141+
142+
Auditor-side deserializers (`.../audit/auditor.go` in both drivers) are not the
143+
consensus-endorsement boundary and are unaffected by this configuration mechanism — they always
144+
run with `DefaultResourceLimits()`.
145+
146+
## Choosing and changing these values
147+
148+
The default values are conservative but comfortably above real usage observed across the unit,
149+
regression, and integration test suites — no currently-valid request or action is rejected by any
150+
of the defaults. If a deployment needs a different limit:
151+
152+
1. Confirm no currently-valid production traffic pattern needs a value close to the existing
153+
limit, to avoid an unnecessarily invasive change.
154+
2. Roll the configuration change out to every validating peer (and the chaincode process) before
155+
relying on it — see [Consensus-safety contract](#consensus-safety-contract) above.
156+
3. If you are changing a *default* (not just deploying an override), update the exact-boundary unit
157+
tests (`limit-1`/`limit`/`limit+1`) and the fuzz seed corpus (`testdata/fuzz/<TargetName>/`)
158+
alongside `DefaultResourceLimits()`.
159+
160+
## Testing
161+
162+
- **Exact-boundary unit tests**: every field has a table-driven test asserting `limit-1` and
163+
`limit` succeed and `limit+1` fails with the specific typed error, both against
164+
`DefaultResourceLimits()` and against an injected custom override (`limits_test.go` next to each
165+
`limits.go`), proving overrides actually take effect and are not just read-only documentation.
166+
- **Provider tests**: the config-backed and env-backed providers each have tests covering an unset
167+
source (resolves to defaults), a partial override (unset fields still default), and an
168+
invalid/unparseable value (returns an error).
169+
- **Wiring test**: `TestValidatorDriverService_ForwardsConfiguredLimits`
170+
(`token/core/service_test.go`) asserts the exact `ResourceLimits` value passed into
171+
`NewValidatorDriverService` is the one forwarded to the driver's `NewValidator`, end to end.
172+
- **Reject-before-cryptographic-work tests**: `RejectsBeforeCryptographicWork` tests assert an
173+
oversized proof is rejected in well under 50ms — i.e. before any verifier is constructed. This is
174+
a timing property, verified as a plain (non-fuzzed) unit test so it isn't subject to fuzz-worker
175+
CPU contention.
176+
- **Fuzzing**: `common.FuzzRequestResourceLimits`, `zkatdlog validator.FuzzActionResourceLimits`,
177+
and `fabtoken validator.FuzzActionResourceLimits` fuzz requests/actions shaped directly by their
178+
resource dimensions (counts and byte lengths) against `DefaultResourceLimits()`, asserting no
179+
panic and the expected typed error at every boundary. Each target has a persisted seed corpus
180+
under its package's `testdata/fuzz/<TargetName>/` covering every default's boundary, and runs
181+
nightly via [`.github/workflows/nightly-fuzz.yml`](../../.github/workflows/nightly-fuzz.yml).

0 commit comments

Comments
 (0)