Two related issues in the Python SDK's constraint layer, found while building a verification layer against AP2.
Both were reported through the Google OSS VRP (issues 551304805, 551303152) and closed as Won't Fix (Intended Behavior). The reviewer confirmed the mechanism and suggested I raise it here:
"You've clearly identified a mechanism where a selectively withheld constraint could lead to a permissions bypass, potentially allowing reported over-cap payments."
"We still encourage you to open an issue or submit a pull request directly on the GitHub repository to help the maintainers address this."
I'm filing this as a documentation and API-safety issue rather than a vulnerability report. If the behaviour is intended — and I accept that it is — the remaining risk is that integrators won't realise what an empty violation list does and does not mean.
Tested against e1ea56db72a6385bce3e5c1112b3a56ce60acb43 (current main), Python 3.13.2.
1. An empty violation list does not mean the constraints were satisfied
create_payment_evaluator() is invoked once per constraint found in the OpenPaymentMandate. There is no step asserting which constraints should be present, so a constraint that is not disclosed produces no evaluator and therefore no violation.
check_preset_payment_claims() documents the same shape:
"If a field is set in the open mandate, the closed mandate must contain an identical value."
A field that is not set is not checked. ExecutionDateEvaluator shows it directly: if not exec_date: return [].
Separately, DisclosureMetadata accepts arbitrary sd_array_indices, so an issuer can mark entries of the constraints array selectively disclosable. That seems like a reasonable thing for an issuer to want — not revealing a customer's total budget to a merchant is close to the motivating use case for SD-JWT here.
Composed, the privacy feature disables the enforcement mechanism.
Reproduction
Full script: spike_chain_e2e.py
issuer marks constraints[0..1] selectively disclosable:
DisclosureMetadata(children={"constraints":
DisclosureMetadata(sd_array_indices=[0, 1])})
open mandate : [Budget(max=5000.0, currency="INR"), AllowedPayees([merchant])]
closed mandate: 750000 paise (INR 7,500) -- above the INR 5,000 cap
hash_mode : sd_hash (the default)
| Presentation |
verify() |
constraints visible |
violations |
outcome |
| full disclosure |
passes |
payment.budget, payment.allowed_payees |
1 |
blocked |
| Budget withheld |
passes |
none |
0 |
allowed |
The blocked case reports Cumulative spend 750000 exceeds budget limit 500000 (past spend: 0). The allowed case reports nothing — correctly, since nothing remained to evaluate.
What this is not
Stated explicitly so it isn't read as more than it is:
- Not a signature forgery or chain-integrity break.
verify_chain behaves correctly throughout.
- The redaction is performed by the legitimate holder through the documented
claims_to_disclose parameter, not injected by an unauthorised party.
- I used the default
hash_mode="sd_hash". I have not demonstrated a downstream delegate stripping a disclosure under issuer_jwt_hash, though the SDK README describes that mode as permitting exactly that. I'd genuinely be interested in whether that path is reachable adversarially.
- Single delegation hop, and the withheld disclosure removed the whole
constraints array rather than the Budget entry alone.
Suggested changes
In order of preference:
- A verifier-side API to declare required constraint types, raising if any are absent from the presented mandate.
- Return coverage metadata alongside violations, so an integrator can assert completeness rather than infer it.
- At minimum: document in the SDK README that an empty violation list does not imply the constraints were present, and that integrators should pin their expected constraint set.
Happy to open a PR for (2) or (3) if either would be welcome.
2. Budget.max and AmountRange.max use different units
payment.amount_range.max is documented as:
"Maximum allowed amount in minor (cents) unit of currency."
and AmountRangeEvaluator compares directly, which is correct:
and amount.amount > self.constraint.max
payment.budget.max is documented as:
"Maximum amount for the budget."
No unit stated. But BudgetEvaluator multiplies by 100:
budget_max_cents = int(self.constraint.max * 100)
if total_spend > budget_max_cents:
So budget.max is in major units and amount_range.max is in minor units, in the same module, with only one of the two saying which.
Impact
An issuer populating budget.max in minor units — consistent with its sibling constraint, and with Amount.amount — gets a cap 100x larger than intended. An INR 5,000 cap silently becomes INR 500,000. Enforcement looks like it is working: the evaluator runs, returns no violation, the transaction is authorised.
I hit this myself on first implementation, using the schema descriptions as the guide. I set Budget(max=5000.0) against a charge of 47500 paise and expected a violation; there is none, because the effective ceiling is 500000 paise.
Suggested change
A one-line clarification in code/sdk/schemas/ap2/open_payment_mandate.json:
"max": {
"type": "number",
"description": "Maximum amount for the budget, in the major unit of the currency (e.g. 5000.0 is 5000 rupees, not 5000 paise). Note this differs from payment.amount_range.max, which is expressed in the minor unit."
}
I'm happy to send that as a PR — I'll sign the CLA first. Aligning both constraints on minor units would be cleaner still, but that's a breaking change and your call, not mine.
Context, for transparency: I built PRAMANA, an open-source verification layer that treats an unevaluated constraint as indeterminate rather than satisfied. So I have an interest in this area. The two issues above stand on their own regardless, and I'd rather they were fixed upstream than kept as a reason for my project to exist.
Two related issues in the Python SDK's constraint layer, found while building a verification layer against AP2.
Both were reported through the Google OSS VRP (issues
551304805,551303152) and closed as Won't Fix (Intended Behavior). The reviewer confirmed the mechanism and suggested I raise it here:I'm filing this as a documentation and API-safety issue rather than a vulnerability report. If the behaviour is intended — and I accept that it is — the remaining risk is that integrators won't realise what an empty violation list does and does not mean.
Tested against
e1ea56db72a6385bce3e5c1112b3a56ce60acb43(currentmain), Python 3.13.2.1. An empty violation list does not mean the constraints were satisfied
create_payment_evaluator()is invoked once per constraint found in theOpenPaymentMandate. There is no step asserting which constraints should be present, so a constraint that is not disclosed produces no evaluator and therefore no violation.check_preset_payment_claims()documents the same shape:A field that is not set is not checked.
ExecutionDateEvaluatorshows it directly:if not exec_date: return [].Separately,
DisclosureMetadataaccepts arbitrarysd_array_indices, so an issuer can mark entries of theconstraintsarray selectively disclosable. That seems like a reasonable thing for an issuer to want — not revealing a customer's total budget to a merchant is close to the motivating use case for SD-JWT here.Composed, the privacy feature disables the enforcement mechanism.
Reproduction
Full script:
spike_chain_e2e.pyverify()payment.budget,payment.allowed_payeesThe blocked case reports
Cumulative spend 750000 exceeds budget limit 500000 (past spend: 0). The allowed case reports nothing — correctly, since nothing remained to evaluate.What this is not
Stated explicitly so it isn't read as more than it is:
verify_chainbehaves correctly throughout.claims_to_discloseparameter, not injected by an unauthorised party.hash_mode="sd_hash". I have not demonstrated a downstream delegate stripping a disclosure underissuer_jwt_hash, though the SDK README describes that mode as permitting exactly that. I'd genuinely be interested in whether that path is reachable adversarially.constraintsarray rather than theBudgetentry alone.Suggested changes
In order of preference:
Happy to open a PR for (2) or (3) if either would be welcome.
2.
Budget.maxandAmountRange.maxuse different unitspayment.amount_range.maxis documented as:and
AmountRangeEvaluatorcompares directly, which is correct:payment.budget.maxis documented as:No unit stated. But
BudgetEvaluatormultiplies by 100:So
budget.maxis in major units andamount_range.maxis in minor units, in the same module, with only one of the two saying which.Impact
An issuer populating
budget.maxin minor units — consistent with its sibling constraint, and withAmount.amount— gets a cap 100x larger than intended. An INR 5,000 cap silently becomes INR 500,000. Enforcement looks like it is working: the evaluator runs, returns no violation, the transaction is authorised.I hit this myself on first implementation, using the schema descriptions as the guide. I set
Budget(max=5000.0)against a charge of47500paise and expected a violation; there is none, because the effective ceiling is500000paise.Suggested change
A one-line clarification in
code/sdk/schemas/ap2/open_payment_mandate.json:I'm happy to send that as a PR — I'll sign the CLA first. Aligning both constraints on minor units would be cleaner still, but that's a breaking change and your call, not mine.
Context, for transparency: I built PRAMANA, an open-source verification layer that treats an unevaluated constraint as indeterminate rather than satisfied. So I have an interest in this area. The two issues above stand on their own regardless, and I'd rather they were fixed upstream than kept as a reason for my project to exist.