Verify an authorization in Java, Go and .NET
An enforcement point must check an authorization before it releases funds. You do not need an SDK for that: it is one signed token, checked with Polivor's public keys. This page has complete code for Java and Go that we run against a test suite, and says what to use on .NET.
The code checks the signature, the algorithm and token type, the audience, the expiry, and that action_sha256 matches the action. Two checks stay in your own code because only you can make them: that the payment you are about to make equals action exactly, and that this jti has not been used before. The enforcement guide lists all seven checks.
Keys
Fetch GET /.well-known/jwks.json (no API key). Use only keys with use: authz and crv: Ed25519; the x value is the raw 32-byte public key, base64url encoded. Cache the document, and refetch it when a token carries an unknown kid.
A test vector
Use this to check your canonicalization against ours. The action below, canonicalized with RFC 8785 and hashed with SHA-256, gives the hex value shown. Java, Go and Python all produce it.
{
"type": "PayInvoice",
"amount_minor": 420000,
"currency": "EUR",
"debtor_account": "ES9121000418450200051332",
"payment_type": "SEPA_CT",
"beneficiary_iban": "ES7620770024003102575766",
"reference": "INV-104"
}
Canonical form: {"amount_minor":420000,"beneficiary_iban":"ES7620770024003102575766","currency":"EUR","debtor_account":"ES9121000418450200051332","payment_type":"SEPA_CT","reference":"INV-104","type":"PayInvoice"}
SHA-256: 751209c0d96e3523fb2b38776e1e7da20aa164cb19c73eb833dca52103bb65f0
Java
Needs Java 17 or later (Ed25519 is built in), com.nimbusds:nimbus-jose-jwt to read the token, and io.github.erdtman:java-json-canonicalization for RFC 8785.
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.util.JSONObjectUtils;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import org.erdtman.jcs.JsonCanonicalizer;
public final class AuthorizationVerifier {
private static final String TOKEN_TYPE = "polivor-authz+jwt";
private static final String KEY_USE = "authz";
private static final byte[] ED25519_SPKI_PREFIX = HexFormat.of().parseHex("302a300506032b6570032100");
public static Map<String, Object> verify(String token, String jwksJson, String audience, Instant now) throws Exception {
JWSObject jws = JWSObject.parse(token);
if (!JWSAlgorithm.EdDSA.equals(jws.getHeader().getAlgorithm())) throw new SecurityException("algorithm must be EdDSA");
if (jws.getHeader().getType() == null || !TOKEN_TYPE.equals(jws.getHeader().getType().getType())) throw new SecurityException("wrong token type");
byte[] publicKeyBytes = publicKeyFor(jws.getHeader().getKeyID(), jwksJson);
var publicKey = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(concat(ED25519_SPKI_PREFIX, publicKeyBytes)));
Signature signature = Signature.getInstance("Ed25519");
signature.initVerify(publicKey);
signature.update(jws.getSigningInput());
if (!signature.verify(jws.getSignature().decode())) throw new SecurityException("bad signature");
Map<String, Object> claims = JSONObjectUtils.parse(jws.getPayload().toString());
if (!Instant.ofEpochSecond(((Number) claims.get("exp")).longValue()).isAfter(now)) throw new SecurityException("expired");
if (!audience.equals(claims.get("aud"))) throw new SecurityException("wrong audience");
@SuppressWarnings("unchecked") Map<String, Object> action = (Map<String, Object>) claims.get("action");
String canonicalAction = new JsonCanonicalizer(JSONObjectUtils.toJSONString(action)).getEncodedString();
String actionHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonicalAction.getBytes(StandardCharsets.UTF_8)));
if (!actionHash.equals(claims.get("action_sha256"))) throw new SecurityException("action does not match its signed hash");
return claims;
}
private static byte[] publicKeyFor(String keyId, String jwksJson) throws Exception {
@SuppressWarnings("unchecked") List<Object> keys = (List<Object>) JSONObjectUtils.parse(jwksJson).get("keys");
for (Object entry : keys) {
@SuppressWarnings("unchecked") Map<String, Object> key = (Map<String, Object>) entry;
if (keyId != null && keyId.equals(key.get("kid")) && KEY_USE.equals(key.get("use")) && "Ed25519".equals(key.get("crv"))) {
return java.util.Base64.getUrlDecoder().decode((String) key.get("x"));
}
}
throw new SecurityException("no authorization key with that kid");
}
private static byte[] concat(byte[] first, byte[] second) {
byte[] result = Arrays.copyOf(first, first.length + second.length);
System.arraycopy(second, 0, result, first.length, second.length);
return result;
}
}
Call AuthorizationVerifier.verify(token, jwksJson, "your-enforcement-point-audience", Instant.now()). It returns the claims or throws SecurityException. We test it against a valid token and against a wrong audience, an expired token, a wrong token type, an action swapped under a valid signature, a forged payload and an unknown key.
Go
Needs only the standard library plus an RFC 8785 package: go get github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer.
package main
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer"
)
const (
tokenType = "polivor-authz+jwt"
keyUse = "authz"
)
type header struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
}
type jsonWebKey struct {
KeyID string `json:"kid"`
Use string `json:"use"`
Curve string `json:"crv"`
X string `json:"x"`
}
type claims struct {
Audience string `json:"aud"`
Expiry int64 `json:"exp"`
Action json.RawMessage `json:"action"`
ActionSHA256 string `json:"action_sha256"`
}
// VerifyAuthorization checks signature, algorithm, type, audience, expiry and the action hash of a Polivor authorization.
func VerifyAuthorization(token string, jwksJSON []byte, audience string, now time.Time) (*claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errors.New("not a compact JWS")
}
var head header
if err := decodePart(parts[0], &head); err != nil {
return nil, err
}
if head.Algorithm != "EdDSA" || head.Type != tokenType {
return nil, errors.New("wrong algorithm or token type")
}
publicKey, err := publicKeyFor(head.KeyID, jwksJSON)
if err != nil {
return nil, err
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !ed25519.Verify(publicKey, []byte(parts[0]+"."+parts[1]), signature) {
return nil, errors.New("bad signature")
}
var payload claims
if err := decodePart(parts[1], &payload); err != nil {
return nil, err
}
if !time.Unix(payload.Expiry, 0).After(now) {
return nil, errors.New("expired")
}
if payload.Audience != audience {
return nil, errors.New("wrong audience")
}
canonicalAction, err := jsoncanonicalizer.Transform(payload.Action)
if err != nil {
return nil, err
}
digest := sha256.Sum256(canonicalAction)
if hex.EncodeToString(digest[:]) != payload.ActionSHA256 {
return nil, errors.New("action does not match its signed hash")
}
return &payload, nil
}
func decodePart(part string, target any) error {
raw, err := base64.RawURLEncoding.DecodeString(part)
if err != nil {
return err
}
return json.Unmarshal(raw, target)
}
func publicKeyFor(keyID string, jwksJSON []byte) (ed25519.PublicKey, error) {
var jwks struct {
Keys []jsonWebKey `json:"keys"`
}
if err := json.Unmarshal(jwksJSON, &jwks); err != nil {
return nil, err
}
for _, key := range jwks.Keys {
if key.KeyID == keyID && key.Use == keyUse && key.Curve == "Ed25519" {
raw, err := base64.RawURLEncoding.DecodeString(key.X)
if err != nil || len(raw) != ed25519.PublicKeySize {
return nil, errors.New("malformed key")
}
return ed25519.PublicKey(raw), nil
}
}
return nil, errors.New("no authorization key with that kid")
}
Call VerifyAuthorization(token, jwksJSON, "your-enforcement-point-audience", time.Now()). It returns the claims or an error. The same six rejection cases are tested, plus the hash above.
.NET
We have not tested .NET code yet, so this page has none. What you need is a library that verifies an EdDSA (Ed25519) compact JWS, and one that implements RFC 8785 canonical JSON. Check whether the JWT library you already use supports EdDSA: many do not. Then apply the same seven checks, and use the test vector above to confirm that your canonicalization matches. If you integrate on .NET, tell us using the contact address in the footer of this page: we will test and publish a verified example.
TypeScript and Python
These have a maintained helper in the SDKs: verifyAuthorization in @polivor/sdk and verify_authorization in the polivor package. They check the signature, type, audience and expiry; compute the action hash yourself as described above.