Reference

Verdict test vectors

138 tokens with known answers for each of the twelve verification steps and for checking a stored verdict, signed with published test keys.

These vectors test a verifier against the checks a relying party applies before it settles on a verdict, and against the checks an auditor applies to a stored one. There are 138: 24 must be accepted and 114 rejected. Each comes with everything a verifier needs: the key sets, the clock, the payment record, the nonces already seen and, where it matters, the verifier's own settings.

Section numbers in the descriptions refer to draft-dias-agent-payment-verdicts-00, the Internet-Draft that describes the verdict format and its verification procedure.

Download: verdict-test-vectors.json

Test keys only

Every signature here is made with, or derived from, test keys whose private halves are in the same file, published on purpose so that anyone can reproduce and extend the vectors. The issuers are the reserved example domains https://issuer.example and https://issuer-b.example, not Saifuro. A token signed with these keys proves nothing. Never add these keys or key sets to a verifier that protects real payments.

The checks

Step numbers in the vectors refer to this order. A verifier may run the checks in another order, as long as it verifies the signature before it relies on any claim and records the nonce last; it can then report a different first failure, so expected.valid must match and expected.step should.

StepCheckWhat it rejects
1ParseBound the size; exactly three unpadded base64url segments; UTF-8; JSON objects; no duplicate member names.
2Headeralg exactly ES256; no crit; kid a non-empty string; no keys taken from jku, jwk, x5u or x5c.
3EnvironmentReject a kid that begins with sandbox- unless the verifier is used only for testing.
4Issuer and keyiss exactly one of your configured issuers; the kid found exactly once, in that issuer's key set only, after at most one rate-limited refetch; an EC P-256 key with alg ES256 and use sig if present.
5SignatureA 64-byte R||S signature, R and S in [1, n-1], valid for the key. High S is accepted.
6Token kind and structureNot an event token; claim types and presence; aud only if it names you.
7TimeNot at or after exp + 60 s; iat not more than 60 s ahead; not before nbf - 60 s.
8Decision and operatorverdict is allow, under a mandate the operator agreed with you.
9AmountEqual to your payment record by decimal value; as binary64 only when both amounts are below 2^30 with at most six fractional digits.
10CurrencyEqual to your record, case-sensitive.
11RecipientEqual to your record, code point for code point.
12ReplayThe pair of iss and nonce (or of iss and jti) not seen before; then recorded.

A stored verdict checked as a record ("mode": "record") goes through steps 1, 2, 4, 5 and 6, step 3 only if the checking party separates test verdicts, and step 7 against the time the verdict was received if that time was recorded, and otherwise not at all. Steps 8 to 12 do not apply, and a key missing from the stored copy of the key set is a failure: there is no refetch.

Running them

For each vector, configure your verifier with:

  • the accepted issuers in issuers, each mapped to its key set in keySets. A kid that is not in the set stays absent after your one refetch;
  • the clock pinned to now, with a clock skew allowance of exactly 60 seconds (several vectors sit on that boundary);
  • in settle mode: the payment record in payment, already converted to the unit agreed with the operator and to the issuer's form of the currency code (the amount is a decimal string), the mandate ids in agreedMandates, the stores seenNonces (or seenJtis, if your verifier records jti) and seenSupersedes, and testingOnly;
  • in record mode: receivedAt, distinguishTest and, where present, refetchWouldReturn, the set a refetch would return (a record checker must not refetch);
  • audience, your own identifier for the aud rule, or none;
  • a JSON nesting-depth bound of at least 4.

The expected results assume your verifier applies every check that is recommended rather than required; the vectors that depend on one say so.

A production verifier never pins its clock. The vectors pin it so that the answers do not change with the date.

ECDSA signatures are randomized, so the vectors cannot be regenerated byte for byte; the file is the reference.

Vectors

Step 1: Parse

IdWhat it checksExpected
segment-paddedThe payload segment keeps its base64 padding (=), and the signature covers the padded segment. Many base64 decoders accept padding; step 1 rejects it.reject
segment-whitespaceA line break inside the payload segment, covered by the signature.reject
segment-std-alphabetThe payload segment uses '+' or '/' from the standard base64 alphabet instead of '-' and '_', and the signature covers it as written.reject
segment-length-1-mod-4A header segment whose length is 1 modulo 4, covered by the signature. No base64 encoding has that length; some decoders drop the extra character.reject
two-segmentsOnly header and payload, no signature segment.reject
five-segmentsFive segments, shaped like a JWE, with a readable payload in the ciphertext position.reject
payload-invalid-utf8The payload contains a byte that is not valid UTF-8 inside the recipient; the signature covers it. The payment record's recipient ends in U+FFFD, so a decoder that substitutes U+FFFD instead of rejecting accepts it.reject
payload-arrayThe payload is a JSON array holding the claims object, not an object.reject
duplicate-claim-issThe payload has iss twice: the configured issuer first, then another. A verifier whose parser keeps the lexically last member rejects it at step 4 instead.reject
duplicate-member-recipientrequest has recipient twice: the payment's payee first, then another. Rests on step 1's SHOULD to reject duplicates at any depth (a MUST for a verifier that parses the payload more than once). A parser that keeps the last member rejects it at step 11; one that keeps the first member and does not apply the SHOULD accepts it.reject
duplicate-header-kidThe header has kid twice: example-1 first, then sandbox-example-1, whose key signed the token. A parser that keeps the last member rejects it at step 3; one that keeps the first, which step 1 forbids, rejects it at step 5.reject
header-arrayThe header is a JSON array, not an object.reject

Step 2: Header

IdWhat it checksExpected
alg-nonealg "none" and an empty signature.reject
alg-hs256-key-confusionalg HS256 with the SPKI PEM of example-1 (with its trailing newline) as the HMAC secret.reject
alg-lowercasealg written "es256"; the signature is a valid ES256 signature.reject
alg-es384-labelalg "ES384" on a token whose signature is ES256.reject
header-critA header with a crit parameter.reject
kid-missingNo kid header parameter.reject
kid-emptykid is the empty string.reject
alg-missingNo alg header parameter.reject
kid-not-stringkid is a number.reject
header-jwkSigned with an attacker's key and carrying a jwk header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5.reject
header-jkuSigned with an attacker's key and carrying a jku header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5.reject
header-x5uSigned with an attacker's key and carrying a x5u header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5.reject
header-x5cSigned with an attacker's key and carrying a x5c header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5.reject

Step 3: Environment

IdWhat it checksExpected
sandbox-key-productionSigned with the test key sandbox-example-1, checked by a verifier that protects real payments.reject

Step 4: Issuer and key

IdWhat it checksExpected
iss-trailing-slashiss is https://issuer.example/ (trailing slash).reject
iss-caseiss is https://Issuer.example (case differs).reject
iss-missingNo iss claim.reject
iss-not-configurediss names an issuer the verifier does not accept; the token is signed with example-1.reject
kid-unknownSigned with example-2, whose kid is in no key set. A refetch returns the same set.reject
kid-from-other-issuerThe verifier accepts both issuers. iss is https://issuer.example, but kid and signature belong to https://issuer-b.example's key b-1. The key is looked up only in the set of the issuer named by iss.reject
jwks-duplicate-kidThe key set lists kid example-1 twice with different keys; the token is signed with the second (example-2's key). A verifier that takes the first match rejects it at step 5; one that tries every match would accept it.reject
jwks-key-alg-es384The key set entry for example-1 has alg ES384.reject
jwks-key-use-encThe key set entry for example-1 has use enc.reject
jwks-key-crv-p384The key set entry for example-1 is a P-384 key.reject

Step 5: Signature

IdWhat it checksExpected
jwks-key-off-curveThe key set entry for example-1 has y increased by one, so the point is not on P-256. Step 5 requires an implementation that validates the public key.reject
tampered-amountThe payload's amount changed from 340 to 3400 after signing; the payment record says 3400.reject
signature-derThe signature re-encoded as ASN.1 DER.reject
signature-63-bytesA valid signature whose R begins with a zero byte, written with that byte removed (63 bytes). A verifier that splits at half the length or pads with zeros would accept it.reject
signature-zeroR and S are both zero (CVE-2022-21449).reject
signature-r-equals-nR is n, the group order; S is from a valid signature.reject
signature-s-equals-nS is n, the group order; R is from a valid signature.reject
signature-wrong-keykid example-1, signed with example-2.reject

Step 6: Token kind and structure

IdWhat it checksExpected
event-tokenA webhook event token: same key, issuer and header; a type claim, a jti beginning "evt_", no exp; data.verdict is allow.reject
type-claimA complete allow that also carries a type claim.reject
jti-evt-prefixA complete allow whose jti begins "evt_".reject
jti-missingNo jti claim.reject
jti-emptyjti is the empty string.reject
iat-fractioniat is 1791374400.5, a NumericDate that is not an integer.reject
iat-stringiat is a string.reject
exp-missingNo exp claim.reject
iat-missingNo iat claim.reject
exp-equals-iatexp equals iat (both 30 s after the verifier's clock, so step 7 alone would pass it).reject
verdict-missingNo verdict claim.reject
verdict-emptyverdict is the empty string.reject
policy-version-missingNo policy_version claim.reject
nonce-missingNo nonce claim.reject
nonce-emptynonce is the empty string.reject
request-missingNo request claim.reject
request-arrayrequest is an array.reject
amount-stringrequest.amount is the string "340".reject
amount-zerorequest.amount is 0.reject
amount-negativerequest.amount is -340.reject
currency-missingrequest has no currency member.reject
currency-emptyrequest.currency is the empty string.reject
recipient-emptyrequest.recipient is the empty string.reject
agent-missingrequest has no agent member.reject
category-missingrequest has no category member.reject
recipient-missingrequest has no recipient member.reject
amount-missingrequest has no amount member.reject
agent-emptyrequest.agent is the empty string.reject
allow-without-mandateAn allow with no mandate claim.reject
mandate-emptyAn allow whose mandate is the empty string.reject
supersedes-on-reviewA review verdict that carries supersedes. A verifier that checks the code first rejects it at step 8.reject
aud-otheraud names another relying party; the verifier identifies itself as https://rp.example.reject
aud-no-identityaud is present, and the verifier has no identifier configured.reject
aud-array-no-matchaud is an array of other relying parties; the verifier identifies itself as https://rp.example.reject

Step 7: Time

IdWhat it checksExpected
expiredChecked at exp + 60 s, the end of the allowance.reject
iat-ahead-beyond-skewiat is 61 s after the verifier's clock.reject
iat-in-futureiat is an hour after the verifier's clock.reject
nbf-beyond-skewnbf is 61 s after the verifier's clock.reject

Step 8: Decision and operator

IdWhat it checksExpected
reviewA review verdict: signed, not an approval.reject
deny-limitA deny_limit verdict.reject
deny-no-mandateA deny_no_mandate verdict, which has no mandate claim.reject
unknown-codeA verdict code this document does not define.reject
allow-capitalizedverdict is "Allow".reject
mandate-not-agreedAn allow under mnd_51c0e2aa, a mandate the operator has not agreed with this relying party (it agreed mnd_7f3a0d12).reject

Step 9: Amount

IdWhat it checksExpected
amount-mismatchAn allow for 340 against a payment of 340.50.reject
amount-binary64-collisionAn allow for 8589934592.000001 against a payment of 8589934592.000002 (Section 4.6). Both parse to the same binary64 value.reject
amount-sixteen-digitsAn allow for 9007199254740993, which has more digits before the decimal point than the issuer writes, against 9007199254740992; the two are one binary64 value. A verifier that applies the amount-form check of step 9 where it first converts the amount, for the comparison with zero in step 6, reports step 6.reject
amount-huge-exponentrequest.amount is 1e400000. A verifier rejects it without expanding it. A verifier that applies the amount-form check of step 9 where it first converts the amount, for the comparison with zero in step 6, reports step 6; one whose JSON parser refuses numbers outside the binary64 range reports step 1.reject
amount-form-exponentAn allow whose amount is written with an exponent (3.4E2), against a payment record of the same decimal value. Rests on the SHOULD of step 9: the amount is equal by decimal value, so only the check of the form the issuer writes rejects it.reject
amount-form-seven-decimalsAn allow whose amount is with seven fractional digits, against a payment record of the same decimal value. Rests on the SHOULD of step 9: the amount is equal by decimal value, so only the check of the form the issuer writes rejects it.reject
amount-form-sixteen-digitsAn allow whose amount is with sixteen digits before the decimal point, against a payment record of the same decimal value. Rests on the SHOULD of step 9: the amount is equal by decimal value, so only the check of the form the issuer writes rejects it.reject

Step 10: Currency

IdWhat it checksExpected
currency-mismatchAn allow in USD against a payment in EUR.reject
currency-caseAn allow in USD against a payment record that says usd after any conversion of Section 4.3.reject

Step 11: Recipient

IdWhat it checksExpected
recipient-mismatchAn allow for vendor:acme_saas against a payment to vendor:acme_saas_eu.reject
recipient-normalizationThe token's recipient is in NFC, the payment record's in NFD; no normalization.reject
recipient-trailing-spaceThe payment record's recipient has a trailing space.reject
recipient-zero-widthThe token's recipient contains a zero-width space.reject
recipient-caseThe payment record's recipient differs only in case.reject

Step 12: Replay

IdWhat it checksExpected
replayed-nonceThe token was accepted before: its pair of iss and nonce (and of iss and jti) is already in the store.reject
low-s-twin-replayThe low-S twin of the Appendix A token, after the Appendix A token was accepted. The replay key is a claim value, not the token string.reject
replayed-supersedesA second allow for the same review: its supersedes value was already accepted (Section 10.12). Rests on Section 10.12, a SHOULD: a verifier that does not apply it accepts this token, whose own nonce and jti are new.reject

Accepted

IdWhat it checksExpected
appendix-aThe token of Appendix A.2, checked as Appendix A.3 describes.accept
appendix-a-low-s-twinThe Appendix A token with S replaced by n - S. Both signatures are valid (step 5 forbids requiring low S); the claims, and so the nonce, are the same.accept
allow-decimal-equalrequest.amount 12.5 against a payment record of 12.50: equal by decimal value (step 9).accept
allow-below-onerequest.amount 0.5 against 0.500000.accept
allow-max-precisionrequest.amount 999999999999999.999999, the largest amount the issuer writes, against the same amount. Equal by decimal value. The amount is above 2^30, so a binary64 comparison is not permitted; a verifier that reads the claim as binary64 (1000000000000000) and compares that with the record's decimal value rejects this valid verdict.accept
allow-supersedesAn allow from an operator's approval of a review: supersedes names the review verdict.accept
allow-unknown-membersAn allow with a private claim and a member of request that this document does not define; verifiers ignore both (Section 4.2).accept
sandbox-key-testing-onlySigned with the test key sandbox-example-1, checked by a verifier used only for testing (step 3).accept
expired-within-skewChecked at exp + 59 s: inside the 60-second allowance (step 7).accept
iat-ahead-within-skewiat is 60 s after the verifier's clock: at the limit of the allowance (step 7).accept
nbf-within-skewnbf is 60 s after the verifier's clock: not rejected (step 7).accept
aud-array-matchingaud is an array that contains the verifier's identifier (step 6).accept
aud-string-matchingaud is a string equal to the verifier's identifier (step 6).accept
recipient-unicode-exactA non-ASCII recipient that matches the payment record code point for code point.accept
second-issuerA verifier that accepts two issuers; the token comes from the second, with that issuer's key.accept
nonce-seen-for-other-issuerThe nonce store holds the same nonce for another issuer; the replay key is the pair of iss and nonce (step 12).accept
allow-reordered-escapedA valid allow whose payload lists members in another order, has whitespace, and writes iss and recipient with JSON escapes (\/ and \u0061). Verifiers MUST NOT depend on member order, whitespace or escaping (Section 4.1).accept
typ-absentA valid allow whose header has no typ. Verifiers do not use typ (step 2).accept
production-key-testing-onlySigned with example-1, a production key, checked by a verifier used only for testing. Step 3 rejects test keys outside testing, not production keys inside it.accept
jwks-key-minimalA valid allow checked against a key set entry with no alg and no use member; both are checked only if present (step 4).accept

Stored verdicts

IdWhat it checksExpected
record-refusalA deny_limit verdict checked as a record, with the time it was received.accept
record-allow-expired-laterAn allow checked as a record a month later; it was received while valid.accept
record-no-receipt-timeA deny_no_mandate verdict checked as a record with no recorded receipt time: step 7 is not applied.accept
record-received-lateAn allow checked as a record; it was received at exp + 60 s.reject at step 7
record-key-not-in-copyA stored key-set copy that lacks example-1. No refetch for stored verdicts.reject at step 4
record-tamperedA deny_limit record whose verdict was changed to allow after signing.reject at step 5
record-test-verdictA test-environment verdict checked by a party that distinguishes test verdicts.reject at step 3
record-test-verdict-not-distinguishedThe same kind of test-environment verdict, checked as a record by a party that does not separate test verdicts (Section 6.2 applies step 3 only if it needs to).accept
record-allow-without-mandateChecked as a record: an allow with no mandate claim. Step 8 does not apply to records, so only step 6 catches it.reject at step 6
record-mandate-emptyChecked as a record: an allow whose mandate is the empty string. Step 8 does not apply to records, so only step 6 catches it.reject at step 6
record-supersedes-on-reviewChecked as a record: a review verdict that carries supersedes. Step 8 does not apply to records, so only step 6 catches it.reject at step 6
record-verdict-missingChecked as a record: a token with no verdict claim. Step 8 does not apply to records, so only step 6 catches it.reject at step 6
record-verdict-emptyChecked as a record: a token whose verdict is the empty string. Step 8 does not apply to records, so only step 6 catches it.reject at step 6

On this page