Skip to content
Trustaige Docs

Check the proof

Check a Countersign proof on your server before you act. Every claim it carries, the checks to make, and code that makes them.

View as Markdown

The proof is a JWT signed with the keys at https://id.trustaige.com/.well-known/jwks.json, with the algorithm your app registered for its ID tokens (EdDSA unless you chose another). Its header typ is verification+jwt, so it can never pass as an ID token (RFC 8725 §3.11). Check all of it before you change anything: a proof that fails one check is no proof.

What it carries

Claim
isshttps://id.trustaige.com
audYour app’s client ID
subThe person: the same sub as their ID token for your app
jtiThe auth_req_id this proof answers
iat, nbf, expValid for two minutes from the person’s answer
auth_timeWhen the person answered
authorization_detailsThe action, as Trustaige received it
txn_hashThe same value bc-authorize returned for the request
binding_message, nonceEchoed, when you sent them
acr, amrHow strongly the person proved it was them; amr includes mca for a phone approval (RFC 8176)
confirmationmethod (same_device_passkey or phone_approval), and for a phone, device and key_custody

The checks

  1. The signature verifies against the JWKS, and typ is verification+jwt.
  2. iss is https://id.trustaige.com and aud holds your client ID.
  3. exp hasn’t passed.
  4. txn_hash equals the one bc-authorize returned for this pending change, and authorization_details matches your record of it by value. Compare the parsed JSON, not the bytes: another JSON library may write the same action differently, so never rebuild the hash yourself.
  5. jti is the auth_req_id you stored, and you haven’t accepted it before.
  6. sub is the person signed in to your app who made the change.
  7. confirmation.method is at least as strong as the change needs, if you require one: phone_approval is stronger than same_device_passkey.
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { isDeepStrictEqual } from 'node:util';

const jwks = createRemoteJWKSet(new URL('https://id.trustaige.com/.well-known/jwks.json'));
const strength = { same_device_passkey: 1, phone_approval: 2 };

export async function checkProof(proof, change) {
  const { payload } = await jwtVerify(proof, jwks, {
    issuer: 'https://id.trustaige.com',
    audience: '<your-client-id>',
    typ: 'verification+jwt',
  });
  if (payload.txn_hash !== change.txnHash) throw new Error('the proof is for a different action');
  if (!isDeepStrictEqual(payload.authorization_details, change.details)) throw new Error('the proof is for a different action');
  if (payload.jti !== change.authReqId) throw new Error('the proof answers a different request');
  if (!(await usedProofs.add(payload.jti))) throw new Error('the proof was already used');
  if (payload.sub !== change.sub) throw new Error('the proof was made by someone else');
  if ((strength[payload.confirmation.method] ?? 0) < (strength[change.required] ?? 1)) {
    throw new Error(`this change needs ${change.required}`);
  }
}
type proofClaims struct {
	AuthorizationDetails json.RawMessage `json:"authorization_details"`
	TxnHash              string          `json:"txn_hash"`
	Confirmation         struct {
		Method string `json:"method"`
	} `json:"confirmation"`
	jwt.RegisteredClaims
}

var strength = map[string]int{"same_device_passkey": 1, "phone_approval": 2}

func checkProof(ctx context.Context, jwks keyfunc.Keyfunc, proof string, change Change) error {
	claims := &proofClaims{}
	tok, err := jwt.ParseWithClaims(proof, claims, jwks.Keyfunc,
		jwt.WithIssuer("https://id.trustaige.com"),
		jwt.WithAudience("<your-client-id>"),
		jwt.WithExpirationRequired(),
		jwt.WithValidMethods([]string{"EdDSA", "ES256", "RS256", "PS256"}))
	if err != nil {
		return fmt.Errorf("the proof is not valid: %w", err)
	}
	var got, want any
	_ = json.Unmarshal(claims.AuthorizationDetails, &got)
	_ = json.Unmarshal(change.Details, &want)
	switch {
	case tok.Header["typ"] != "verification+jwt":
		return errors.New("not a Countersign proof")
	case claims.TxnHash != change.TxnHash || !reflect.DeepEqual(got, want):
		return errors.New("the proof is for a different action")
	case claims.ID != change.AuthReqID:
		return errors.New("the proof answers a different request")
	case !usedProofs.Add(ctx, claims.ID):
		return errors.New("the proof was already used")
	case claims.Subject != change.Sub:
		return errors.New("the proof was made by someone else")
	case strength[claims.Confirmation.Method] < strength[change.Required]:
		return fmt.Errorf("this change needs %s", change.Required)
	}
	return nil
}
import jwt

jwks = jwt.PyJWKClient("https://id.trustaige.com/.well-known/jwks.json")
STRENGTH = {"same_device_passkey": 1, "phone_approval": 2}

def check_proof(proof: str, change) -> None:
    if jwt.get_unverified_header(proof).get("typ") != "verification+jwt":
        raise ValueError("not a Countersign proof")
    key = jwks.get_signing_key_from_jwt(proof)
    claims = jwt.decode(
        proof, key.key,
        algorithms=["EdDSA", "ES256", "RS256", "PS256"],
        audience="<your-client-id>",
        issuer="https://id.trustaige.com",
        options={"require": ["exp", "jti", "sub"]},
    )
    if claims["txn_hash"] != change.txn_hash or claims["authorization_details"] != change.details:
        raise ValueError("the proof is for a different action")
    if claims["jti"] != change.auth_req_id:
        raise ValueError("the proof answers a different request")
    if not used_proofs.add(claims["jti"]):
        raise ValueError("the proof was already used")
    if claims["sub"] != change.sub:
        raise ValueError("the proof was made by someone else")
    if STRENGTH.get(claims["confirmation"]["method"], 0) < STRENGTH.get(change.required, 1):
        raise ValueError(f"this change needs {change.required}")

The Go sample uses golang-jwt and keyfunc (keyfunc.NewDefaultCtx with the JWKS URL); the Python sample uses PyJWT with cryptography for EdDSA.

import { createRemoteJWKSet, jwtVerify } from 'jose';
import { isDeepStrictEqual } from 'node:util';

const jwks = createRemoteJWKSet(new URL('https://id.trustaige.com/.well-known/jwks.json'));
const strength = { same_device_passkey: 1, phone_approval: 2 };

export async function checkProof(proof, change) {
  const { payload } = await jwtVerify(proof, jwks, {
    issuer: 'https://id.trustaige.com',
    audience: '<your-client-id>',
    typ: 'verification+jwt',
  });
  if (payload.txn_hash !== change.txnHash) throw new Error('the proof is for a different action');
  if (!isDeepStrictEqual(payload.authorization_details, change.details)) throw new Error('the proof is for a different action');
  if (payload.jti !== change.authReqId) throw new Error('the proof answers a different request');
  if (!(await usedProofs.add(payload.jti))) throw new Error('the proof was already used');
  if (payload.sub !== change.sub) throw new Error('the proof was made by someone else');
  if ((strength[payload.confirmation.method] ?? 0) < (strength[change.required] ?? 1)) {
    throw new Error(`this change needs ${change.required}`);
  }
}
type proofClaims struct {
	AuthorizationDetails json.RawMessage `json:"authorization_details"`
	TxnHash              string          `json:"txn_hash"`
	Confirmation         struct {
		Method string `json:"method"`
	} `json:"confirmation"`
	jwt.RegisteredClaims
}

var strength = map[string]int{"same_device_passkey": 1, "phone_approval": 2}

func checkProof(ctx context.Context, jwks keyfunc.Keyfunc, proof string, change Change) error {
	claims := &proofClaims{}
	tok, err := jwt.ParseWithClaims(proof, claims, jwks.Keyfunc,
		jwt.WithIssuer("https://id.trustaige.com"),
		jwt.WithAudience("<your-client-id>"),
		jwt.WithExpirationRequired(),
		jwt.WithValidMethods([]string{"EdDSA", "ES256", "RS256", "PS256"}))
	if err != nil {
		return fmt.Errorf("the proof is not valid: %w", err)
	}
	var got, want any
	_ = json.Unmarshal(claims.AuthorizationDetails, &got)
	_ = json.Unmarshal(change.Details, &want)
	switch {
	case tok.Header["typ"] != "verification+jwt":
		return errors.New("not a Countersign proof")
	case claims.TxnHash != change.TxnHash || !reflect.DeepEqual(got, want):
		return errors.New("the proof is for a different action")
	case claims.ID != change.AuthReqID:
		return errors.New("the proof answers a different request")
	case !usedProofs.Add(ctx, claims.ID):
		return errors.New("the proof was already used")
	case claims.Subject != change.Sub:
		return errors.New("the proof was made by someone else")
	case strength[claims.Confirmation.Method] < strength[change.Required]:
		return fmt.Errorf("this change needs %s", change.Required)
	}
	return nil
}
import jwt

jwks = jwt.PyJWKClient("https://id.trustaige.com/.well-known/jwks.json")
STRENGTH = {"same_device_passkey": 1, "phone_approval": 2}

def check_proof(proof: str, change) -> None:
    if jwt.get_unverified_header(proof).get("typ") != "verification+jwt":
        raise ValueError("not a Countersign proof")
    key = jwks.get_signing_key_from_jwt(proof)
    claims = jwt.decode(
        proof, key.key,
        algorithms=["EdDSA", "ES256", "RS256", "PS256"],
        audience="<your-client-id>",
        issuer="https://id.trustaige.com",
        options={"require": ["exp", "jti", "sub"]},
    )
    if claims["txn_hash"] != change.txn_hash or claims["authorization_details"] != change.details:
        raise ValueError("the proof is for a different action")
    if claims["jti"] != change.auth_req_id:
        raise ValueError("the proof answers a different request")
    if not used_proofs.add(claims["jti"]):
        raise ValueError("the proof was already used")
    if claims["sub"] != change.sub:
        raise ValueError("the proof was made by someone else")
    if STRENGTH.get(claims["confirmation"]["method"], 0) < STRENGTH.get(change.required, 1):
        raise ValueError(f"this change needs {change.required}")

Two devices, or one

A phone approval proves the answer came from Trustaige Identity on the person’s enrolled phone, unlocked by their face or fingerprint for that one request. It is a second device only when your page is open somewhere else. Nothing in the request tells Trustaige where your page is open, so if a change must be approved on a separate device, refuse a phone approval when your own page is running on a phone.

Try it

Try it on yourself, on the app’s Countersign tab in the hub, runs every check above against a real proof and shows each one ticked. A trial’s proof is addressed to trustaige-countersign-trial, which no app accepts, so your own check 2 refuses it.