|
| 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