Three small backends for the Necessary SDKs — in Python, TypeScript and C# — written from the integration guide and the measurement contract alone. Each implements the guide's API (its section 4) and the checks its sections 1 to 3 describe, in the guide's order, with the section numbers in the code:
- Registration (section 1): an EC P-256 key whose point is on the curve; the key's thumbprint as the
installation's identity, so the same key gets back the installation it has; a known ID under another
key refused; the reinstall hint keyed before it is kept;
key_securityrecorded, never relied on; the step-up key taken only with a new installation, never the installation key itself — and the answer says whether it holds one (stepup_key_registered) and whether the one offered is it (stepup_key_matches). - Proof of possession (section 1): a key challenge that stores nothing — its payload carries an HMAC under a key of the backend's — answered once, consumed only after the signature verifies, for an installation session of ten minutes.
- The scan (section 2): a challenge asking on every scan for every measurement that would catch
tampering — the hooks, gadgets and changes to the app's code in memory among them — varying only the
emulator's (or simulator's) build values; and for every personal measurement (section 8), so that the
example app can show each one it enables — a production backend asks only for those it uses; the
report's ten checks in order; then
the report against the contract's schema; then against the contract's rules (
RULES.md), which judge it complete, partial (part of the app's code compared), incomplete (something not measured) or invalid (figures that cannot all be true); then a decision, indecide— where your business logic goes. This sample's own scores nothing: it allows a complete report and, by its choice, a partial one, asks for a scan again after an incomplete one, and holds an invalid one for review. The answer is{"integrity": {"report_id": …, "next": "allow"}}, or"scan_again", or"review"— what to do next, never the judgment or a score. - A scan for one action (section 2): named only under an account session; its report approves that
one request, once, sent back as
X-Integrity-Report— and only when the decision allowed it. - Request proofs (section 3): every field against the request received, the clock window, the signature, then the nonce stored once — a proof sent twice is refused.
- Step-up and the app PIN (section 3): the person's approval checked against the request and its
proof's nonce; a new step-up key taken by re-enrolment only with the person's password and the new
key's proof bound to the request proof's nonce, every check of the key before the password, and kept
for that account alone; the PIN set with the person's password and kept as a slow keyed verifier, its
attempts claimed before comparing, the last wrong one locking it, and the backend's own short-lived
proof for one request. Each PIN set is a new generation: a check settles, in one step, only against
the PIN it compared — one that finishes after a new PIN was set unlocks nothing and counts against
nothing — a check that could not run gives its attempt back (
503 pin_unavailable), and a proof made under an older PIN no longer serves.
They are samples: one process, everything in memory, secrets made at start, no rate limits and no account system beyond a demonstration sign-in. A production backend keeps its records in a database, its secrets in a vault, limits how often each call may be made, and decides with its own business logic.
This sample's own API
Beside the guide's calls, a few of the sample's own, to show the plugin's proofs in use — each under the session and request proof the guide describes:
| Call | Session | Needs |
|---|---|---|
POST /v1/demo/sign-in {"handle", "password"} → {"account_session"} |
the installation session | — |
POST /v1/demo/echo |
an account session | a request proof |
POST /v1/demo/transfer |
an account session | a scan made for this request (X-Integrity-Report) |
POST /v1/demo/sensitive |
an account session | the person's approval: step-up, the app PIN's proof, or both — each one sent must hold; with neither, 403 needs_approval |
With --test-controls, and only for requests carrying X-Test-Token: $SAMPLE_TEST_TOKEN (16
characters or more), POST /test/expire-sessions, POST /test/forget {"installation_id"} and
POST /test/refuse-next {"path", "status", "code", "details"} let a test reach each of the guide's
protocol errors; POST /test/judge {"report", "required_probes"} answers how the contract's rules
judge a report under a plan of the test's own ({"judged": …}, refused for one the schema or the
split-APK order refuses); and POST /test/pin-checks {"hold": true} holds each app PIN check once it has
claimed its attempt, until {"hold": false} releases them (with "fail": true, as if their verifier
broke) — answering {"waiting": n}, the checks held. A deployed sample runs without them.
Each sample logs one line per call — its method, path, status, any refusal's code and the check that failed — and never a body or a header, which hold PINs and tokens. The phone is told the code and what to do next, never which check failed: what gave a request away is what an attacker would fix (the guide, section 7).
Run
Unzip the download and run these from its backend folder; <contract> is
../contract/report-1/android-8-ios-5.
# Python 3.10 or later
pip install --require-hashes -r python/requirements.txt
python3 python/server.py --port 8787 --schema <contract>/report.schema.json
# TypeScript, on Node 22 or later
cd typescript && npm ci && npm run build
node dist/server.js --port 8787 --schema <contract>/report.schema.json
# C#, on .NET 10
cd csharp && dotnet restore --locked-mode && dotnet build -c Release --no-restore
dotnet bin/Release/net10.0/SampleBackend.dll --port 8787 --schema <contract>/report.schema.json
Download
Download the three servers (zip, 101 KB)
With the measurement contract they check reports against, the Apache License 2.0 and its NOTICE.
The code
Each server is one file; the download holds the files that build and run it.
Python · python/server.py · 1,047 lines
"""A sample backend for the necessary plugin, written from the integration
guide (doc/integration_guide.md) and the measurement contract alone.
Sample code: one process, everything in memory, its secrets made at start.
A real backend keeps its records in a database and its secrets in a vault.
The section numbers below are the guide's. Run:
python3 server.py --port 8787 --schema <contract>/report.schema.json
It uses the standard library's HTTP server, `cryptography` for ECDSA and
`jsonschema` for the contract's schema (requirements.txt pins both).
"""
import argparse
import base64
import os
import hashlib
import hmac
import json
import re
import secrets
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
import jsonschema
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
SESSION_SECONDS = 600 # sessions: ten minutes (section 1)
KEY_CHALLENGE_SECONDS = 120 # key challenges: two minutes (section 1)
SCAN_CHALLENGE_SECONDS = 60 # scan challenges: 60 seconds (section 2)
CLOCK_WINDOW_SECONDS = 120 # request and step-up proofs (section 3)
REPORT_CLOCK_SECONDS = 90 # a report's collected_at (section 2)
NONCE_KEEP_SECONDS = 600 # request-proof nonces, longer than the window
OPERATION_SCAN_SECONDS = 120 # a scan approves its request for a couple of minutes
PIN_PROOF_SECONDS = 120 # an app PIN proof: about two minutes
PIN_LENGTH = 6 # this sample's choice of PIN length
PIN_ATTEMPTS = 5 # this sample's limit of wrong PINs
MAX_REPORT_BYTES = 65536 # the contract's limit
MIN_COLLECTOR = {"android": 8, "ios": 5} # the contract this sample reads
VERIFIER_ROUNDS = 600_000 # PBKDF2-HMAC-SHA256, a slow verifier
# What this sample asks for in every scan: every measurement that would catch
# tampering, the hooks, gadgets and changes to the app's code in memory among
# them. One left to chance is one a tampered app waits out, scanning again
# until it is not drawn. Only what weighs little varies.
RELIED_ON = {
"android": ["app_identity", "debug_state", "tracer", "root_files", "root_shell",
"system_properties", "selinux", "mounts", "developer_settings",
# Hooks and gadgets by name, by port, by thread and by memory
# layout; and the code in memory compared with its file.
"runtime_maps", "frida_ports", "instrumentation_threads",
"exec_mappings", "code_integrity"],
"ios": ["app_identity", "code_signing", "debugger", "jailbreak_files", "sandbox",
# Injected libraries, and the app's code in memory compared with
# its file -- asked for, so that a report without it is refused.
"dyld_images", "environment", "code_integrity"],
}
VARIED = {
"android": ["emulator"],
"ios": ["simulator"],
}
# The personal measurements (the guide, section 8): this sample asks for every
# one in every scan, so that the example app can show each one it enables. A
# production backend asks only for those it uses.
PERSONAL = ["local_addresses", "accessibility", "notification_access", "other_access",
"screen_capture", "location", "mock_location"]
# --- Binary values travel as base64url without padding; hashes are hex SHA-256.
def b64url_decode(text):
if not isinstance(text, str):
raise ValueError("not base64url text")
return base64.b64decode(text + "=" * (-len(text) % 4), altchars=b"-_", validate=True)
def b64url_encode(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def sha256_hex(raw):
return hashlib.sha256(raw).hexdigest()
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
def iso(seconds):
return datetime.fromtimestamp(seconds, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# --- The contract's rules (RULES.md): what a report's measurements are worth
# before any business logic weighs them -- never a score. A report is judged
# over the measurements its challenge required: complete, partial (part of
# the app's code compared), incomplete (something not measured) or invalid
# (figures that cannot all be true).
QUALITIES = ("complete", "partial", "incomplete", "invalid") # best to worst
# Where code is always mapped, so that a comparison there finds some
# (RULES.md 4): on an iPhone only the app's own, the rest being unreadable.
REGIONS = {"android": ("core", "ext", "app"), "ios": ("app",)}
IOS_COVERAGE = ("app_images_expected", "app_images_compared", "app_images_unreadable",
"app_images_skipped", "app_expected_bytes", "app_compared_bytes",
"app_unreadable_bytes", "app_skipped_bytes", "app_complete",
"main_executable_compared")
def is_int(value):
return isinstance(value, int) and not isinstance(value, bool)
def judge(report, required, definitions):
"""The contract's judgment of a report whose shape the schema accepted:
one of QUALITIES. Split APKs out of order (RULES.md 6, the one rule a
schema cannot state) are refused rather than read."""
platform, results = report["platform"], report["probe_results"]
identity = results.get("app_identity")
splits = identity.get("split_apks") if isinstance(identity, dict) else None
if splits is not None:
names = [entry["name"] for entry in splits]
if names != sorted(set(names)):
raise Refusal(400, "report_not_accepted", "The report is not the contract's.",
{"check": "split_apks"})
found = ["complete"]
for name in required:
value = results.get(name)
definition = definitions.get("%s.%s" % (platform, name), {})
if not isinstance(value, dict) or value.get("status") != "ok":
found.append("incomplete") # failed or unsupported: not measured (2)
continue
# ok is a claim: a measurement is complete with every field it lists,
# and with nothing in unread (2).
if (any(value.get(field) is None for field in definition.get("x-complete-when-ok", ()))
or value.get("unread")):
found.append("incomplete")
# Failed reads, not clean values (7).
if ((name in ("runtime_maps", "exec_mappings") and value.get("maps_lines") == 0)
or (name == "instrumentation_threads" and value.get("thread_count") == 0)):
found.append("incomplete")
if name == "exec_mappings" and wx_sizes_without_mappings(value):
found.append("invalid")
if name == "code_integrity":
found.append(code_comparison(platform, value, definition))
# Contradictions across measurements (7).
capture, device = results.get("screen_capture"), results.get("device")
if (isinstance(capture, dict) and capture.get("status") == "ok"
and capture.get("recording_status") == "unsupported"
and (platform == "ios" or (isinstance(device, dict) and is_int(device.get("sdk_int"))
and device["sdk_int"] >= 35))):
found.append("invalid") # recording unsupported where it is measured
location, mock = results.get("location"), results.get("mock_location")
if (isinstance(location, dict) and isinstance(mock, dict)
and location.get("status") == "ok" and mock.get("status") == "ok"
and is_int(location.get("fix_time")) and is_int(mock.get("fix_time"))
and location["fix_time"] != mock["fix_time"]):
found.append("invalid") # two fixes presented as one (5)
return max(found, key=QUALITIES.index)
def wx_sizes_without_mappings(maps):
"""No writable-and-executable mapping, with the sizes of such mappings
listed (RULES.md 7)."""
classes = []
for part in str(maps.get("wx_size_classes") or "").split(","):
if part.strip():
size, _, count = (piece.strip() for piece in part.partition(":"))
if not (size.isdigit() and count.isdigit()):
classes = [] # not a list of sizes
break
classes.append(part)
extremes = maps.get("wx_smallest_bytes") or maps.get("wx_largest_bytes")
return maps.get("wx_mappings") == 0 and not maps.get("wx_bytes") and bool(classes or extremes)
def code_comparison(platform, code, definition):
"""The comparison of code in memory with its files (RULES.md 4): invalid
when its figures do not add up; incomplete when it did not run, lacks a
figure, or compared nothing where code always is; partial when it
compared part of the code."""
if not (android_coverage_adds_up(code) if platform == "android"
else ios_coverage_adds_up(code)):
return "invalid"
if code.get("checked") is not True or any(
code.get(field) is None for field in definition.get("x-complete-when-checked", ())):
return "incomplete"
if any(code.get("%s_compared_bytes" % region) == 0 for region in REGIONS[platform]):
return "incomplete"
if platform == "ios":
return "complete" if code["app_complete"] else "partial"
short = any(code["%s_compared_bytes" % region] < code["%s_expected_bytes" % region]
for region in REGIONS["android"])
return "partial" if short or (code.get("protect_restore_failures") or 0) > 0 else "complete"
def android_coverage_adds_up(code):
"""In each region the expected bytes are the compared, skipped and
unreadable ones together, and <region>_complete is true exactly when some
code was expected and all of it was compared."""
for region in REGIONS["android"]:
expected = code.get("%s_expected_bytes" % region)
if expected is None:
continue
parts = [code.get("%s_%s_bytes" % (region, kind))
for kind in ("compared", "skipped", "unreadable")]
if not all(is_int(part) for part in parts) or sum(parts) != expected:
return False
complete = code.get("%s_complete" % region)
if complete is not None and complete != (expected > 0 and parts[0] == expected):
return False
return True
def ios_coverage_adds_up(code):
"""Images and bytes both add up -- expected = compared + unreadable +
skipped -- and app_complete is true exactly when every image was
compared, the main executable among them; bytes are compared exactly
when images are, and each image not compared is named. A collector that
reports only part of it is judged by its fields instead."""
if any(code.get(field) is None for field in IOS_COVERAGE):
return True
figures = [code[field] for field in IOS_COVERAGE[:8]]
if not all(is_int(figure) for figure in figures):
return False
expected, compared, unreadable, skipped = figures[:4]
expected_bytes, compared_bytes, unreadable_bytes, skipped_bytes = figures[4:]
complete = expected > 0 and compared == expected
main, named = code["main_executable_compared"], code.get("app_images_not_compared")
return not (
expected != compared + unreadable + skipped
or expected_bytes != compared_bytes + unreadable_bytes + skipped_bytes
or code["app_complete"] is not complete
or (complete and (unreadable_bytes or skipped_bytes))
or (unreadable_bytes and not unreadable) or (skipped_bytes and not skipped)
or (compared_bytes > 0) != (compared > 0)
or (code.get("checked") is not None and code["checked"] is not (compared > 0))
or (main is True and compared == 0) or (complete and main is not True)
or (named is not None and (len(named) > unreadable + skipped
or (unreadable + skipped > 0 and not named))))
# Your business logic decides (the guide, section 7), and this is where it
# goes. This sample decides on the contract's judgment alone and scores
# nothing: a complete report lets the action go on, and so -- by this sample's
# choice -- does a partial one, whose comparison found nothing in the code it
# could read; an incomplete one asks for a scan again, since a failed read is
# no proof of an attack; and a report whose figures contradict each other is
# held for review. Only a report allowed here approves a scan made for one
# action.
NEXT = {"complete": "allow", "partial": "allow", "incomplete": "scan_again", "invalid": "review"}
def decide(judgment, report):
"""What to do next, for this report: allow, scan_again or review."""
return NEXT[judgment]
class Refusal(Exception):
"""An answer of {"error": {"code", "message", "details"}} (section 4)."""
def __init__(self, status, code, message, details=None):
super().__init__(message)
self.status, self.code, self.message, self.details = status, code, message, details
# --- Section 1: the installation key (the guide's reference code, section 5).
def public_key(jwk):
if not isinstance(jwk, dict) or jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
raise ValueError("only EC P-256 keys are accepted")
x, y = b64url_decode(jwk.get("x")), b64url_decode(jwk.get("y"))
if len(x) != 32 or len(y) != 32:
raise ValueError("P-256 coordinates are 32 bytes each")
# Raises ValueError for a point that is not on the curve.
return ec.EllipticCurvePublicNumbers(
int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1()).public_key()
def thumbprint(jwk):
x, y = b64url_decode(jwk["x"]), b64url_decode(jwk["y"])
return sha256_hex(canonical({"crv": "P-256", "kty": "EC",
"x": b64url_encode(x), "y": b64url_encode(y)}))
def verifies(jwk, payload, signature_b64):
"""Whether the signature verifies over these bytes, as they were signed."""
try:
public_key(jwk).verify(b64url_decode(signature_b64), payload, ec.ECDSA(hashes.SHA256()))
return True
except (InvalidSignature, ValueError):
return False
def slow_verifier(secret, subject, value, salt=None):
"""PBKDF2 over an HMAC of the subject and the value, keyed by a secret the
records never hold: a copy of the records checks nothing."""
salt = salt or secrets.token_bytes(16)
keyed = hmac.new(secret, ("%s:%s" % (subject, value)).encode("utf-8"), hashlib.sha256).digest()
return salt, hashlib.pbkdf2_hmac("sha256", keyed, salt, VERIFIER_ROUNDS)
class Backend:
def __init__(self, schema, test_token):
self.validator = jsonschema.Draft202012Validator(schema)
self.definitions = schema.get("$defs", {}) # the contract's x- annotations
self.test_token = test_token # test controls answer only this token
self.lock = threading.Lock()
# Secrets made at start: a restart forgets every session and challenge.
self.challenge_key = secrets.token_bytes(32)
self.hint_key = secrets.token_bytes(32)
self.pin_secret = secrets.token_bytes(32)
self.password_secret = secrets.token_bytes(32)
self.pin_proof_key = secrets.token_bytes(32)
self.installations = {} # installation_id -> record
self.by_thumbprint = {} # key thumbprint -> installation_id
self.used_key_challenges = set()
self.sessions = {} # token -> session
self.nonces = {} # SHA-256 of a request-proof nonce -> kept until
self.scan_challenges = {} # challenge_id -> challenge
self.reports = {} # report_id -> stored report
self.accounts = {} # handle -> account
self.pending = {} # test control: path -> the next request's refusal
self.pin_gate = None # test control: while set, PIN checks wait on it
self.pin_waiting = 0 # test control: the PIN checks waiting now
def now(self):
return int(time.time())
# --- Section 1: registration.
def register(self, body):
installation_id, platform = body.get("installation_id"), body.get("platform")
try:
uuid.UUID(str(installation_id))
jwk = body.get("public_key")
public_key(jwk)
except ValueError:
raise Refusal(400, "bad_request", "A UUID and an EC P-256 public key are required.",
{"field": "public_key"})
if platform not in ("android", "ios"):
raise Refusal(400, "bad_request", "The platform is android or ios.",
{"field": "platform"})
hint = body.get("reinstall_hint")
if hint is not None and not (
isinstance(hint, dict) and hint.get("kind") in ("android_id_sha256", "idfv_sha256")
and isinstance(hint.get("value"), str)
and re.fullmatch(r"[0-9a-f]{64}", hint["value"])):
raise Refusal(400, "bad_request", "The reinstall hint is malformed.",
{"field": "reinstall_hint"})
key = thumbprint(jwk)
step_up = self._step_up_key(body, key)
with self.lock:
held = self.by_thumbprint.get(key)
if held is not None:
# The thumbprint is the identity: the same key is the same
# installation, whatever ID it proposes. A step-up key offered
# now is not taken -- only re-enrolment replaces one -- so the
# answer says whether it is the one held, and the app asks for
# re-enrolment rather than sign with a key this backend lacks.
bound = self.installations[held]["step_up"]
return {"installation_id": held, "stepup_key_registered": bound is not None,
"stepup_key_matches": (None if step_up is None else bound is not None
and thumbprint(bound["jwk"]) == thumbprint(step_up["jwk"]))}
if installation_id in self.installations:
raise Refusal(409, "installation_id_taken",
"This installation ID belongs to another key.")
self.installations[installation_id] = {
"thumbprint": key, "jwk": jwk, "platform": platform,
# Keyed before it is kept: what the app says, never proof.
"hint": (hmac.new(self.hint_key, hint["value"].encode("ascii"),
hashlib.sha256).hexdigest() if hint else None),
# Recorded, never relied on: the phone cannot prove it.
"key_security": body.get("key_security"),
"step_up": step_up,
}
self.by_thumbprint[key] = installation_id
return {"installation_id": installation_id, "stepup_key_registered": step_up is not None}
def _step_up_key(self, body, installation_thumbprint):
"""Section 3, step-up: the key and how the phone says it is unlocked."""
jwk = body.get("stepup_public_key")
if jwk is None:
return None
auth = body.get("stepup_key_auth")
try:
public_key(jwk)
except ValueError:
raise Refusal(400, "bad_request", "The step-up key is not an EC P-256 key.",
{"field": "stepup_public_key"})
if thumbprint(jwk) == installation_thumbprint:
raise Refusal(400, "bad_request", "The step-up key must not be the installation key.",
{"field": "stepup_public_key"})
window = auth.get("window_seconds") if isinstance(auth, dict) else None
if not (isinstance(auth, dict) and auth.get("factor") in ("passcode", "biometric")
and isinstance(window, int) and not isinstance(window, bool)
and ((auth.get("mode") == "per_use" and window == 0)
or (auth.get("mode") == "windowed" and 1 <= window <= 3600))):
raise Refusal(400, "bad_request", "stepup_key_auth is malformed.",
{"field": "stepup_key_auth"})
return {"jwk": jwk, "factor": auth["factor"], "mode": auth["mode"],
"window_seconds": window}
# --- Section 1: proof of possession, with a challenge that stores nothing.
def key_challenge(self, body):
installation_id = body.get("installation_id")
with self.lock:
if installation_id not in self.installations:
raise Refusal(404, "installation_not_found", "No such installation.")
expires = self.now() + KEY_CHALLENGE_SECONDS
claims = {"challenge_id": str(uuid.uuid4()), "installation_id": installation_id,
"purpose": "prove_installation", "nonce": b64url_encode(secrets.token_bytes(32)),
"expires_at": expires}
claims["mac"] = b64url_encode(hmac.new(self.challenge_key, canonical(claims),
hashlib.sha256).digest())
return {"challenge_id": claims["challenge_id"],
"payload": b64url_encode(canonical(claims)), "expires_at": iso(expires)}
def verify(self, body):
installation_id, challenge_id = body.get("installation_id"), body.get("challenge_id")
try:
payload = b64url_decode(body.get("payload"))
claims = json.loads(payload.decode("utf-8"))
mac = b64url_decode(claims.pop("mac"))
except (ValueError, KeyError, AttributeError, UnicodeDecodeError):
raise Refusal(401, "proof_not_accepted", "This is not a challenge of ours.")
expected = hmac.new(self.challenge_key, canonical(claims), hashlib.sha256).digest()
if (not hmac.compare_digest(mac, expected) or claims.get("challenge_id") != challenge_id
or claims.get("installation_id") != installation_id
or claims.get("purpose") != "prove_installation"):
raise Refusal(401, "proof_not_accepted", "This is not a challenge of ours.")
if claims["expires_at"] < self.now():
raise Refusal(401, "challenge_expired", "The challenge has expired.")
with self.lock:
installation = self.installations.get(installation_id)
if installation is None:
raise Refusal(404, "installation_not_found", "No such installation.")
if not verifies(installation["jwk"], payload, body.get("signature")):
raise Refusal(401, "invalid_installation_signature", "The signature does not verify.")
with self.lock:
# Consumed only after the signature verifies, atomically.
if challenge_id in self.used_key_challenges:
raise Refusal(401, "challenge_used", "The challenge was answered already.")
self.used_key_challenges.add(challenge_id)
token = secrets.token_urlsafe(32)
self.sessions[token] = {"kind": "installation", "installation_id": installation_id,
"expires": self.now() + SESSION_SECONDS}
return {"device_token": token, "expires_at": iso(self.now() + SESSION_SECONDS)}
# --- Section 3: every request carries a request proof.
def authenticate(self, request, kinds):
"""The session and the request proof's nonce, or a refusal."""
token = request.bearer
with self.lock:
session = self.sessions.get(token) if token else None
installation = (self.installations.get(session["installation_id"])
if session else None)
if session is None or session["kind"] not in kinds:
raise Refusal(401, "not_signed_in", "This request needs a session of ours.")
if session["expires"] < self.now():
raise Refusal(401, "expired_token", "The session has expired.")
if installation is None:
raise Refusal(404, "installation_not_found", "No such installation.")
try:
raw = b64url_decode(request.headers.get("X-Access-Proof"))
proof = json.loads(raw.decode("utf-8"))
nonce = b64url_decode(proof["nonce"])
except (ValueError, KeyError, TypeError, UnicodeDecodeError):
raise Refusal(401, "proof_not_accepted", "The request proof is missing or malformed.")
expected = {
"access_token_sha256": sha256_hex(token.encode("utf-8")),
"body_sha256": sha256_hex(request.body),
"installation_id": session["installation_id"],
"method": request.method,
"path": request.path,
"query": request.query,
"version": 2,
}
for field, value in expected.items():
if proof.get(field) != value:
raise Refusal(401, "proof_not_accepted", "The request proof is for another request.",
{"check": field})
timestamp = proof.get("timestamp")
if (isinstance(timestamp, bool) or not isinstance(timestamp, int)
or abs(self.now() - timestamp) > CLOCK_WINDOW_SECONDS or len(nonce) != 32):
raise Refusal(401, "proof_not_accepted", "The request proof is out of time.",
{"check": "timestamp"})
if not verifies(installation["jwk"], raw, request.headers.get("X-Access-Signature")):
raise Refusal(401, "invalid_installation_signature", "The signature does not verify.")
key = sha256_hex(nonce)
with self.lock:
# Stored only after the signature verifies, under a unique key.
for kept in [k for k, until in self.nonces.items() if until < self.now()]:
del self.nonces[kept]
if key in self.nonces:
raise Refusal(401, "proof_reused", "This request was sent already.")
self.nonces[key] = self.now() + NONCE_KEEP_SECONDS
return session, installation, proof["nonce"]
# --- Section 2: the scan.
def scan_challenge(self, request, body):
session, installation, _ = self.authenticate(request, ("installation", "account"))
operation = body.get("operation")
digest = None
if operation is not None:
# A scan for one action is the account's, never the installation's.
if session["kind"] != "account" or not self._operation_valid(operation):
raise Refusal(400, "invalid_operation",
"An operation is named only under an account session, whole.")
digest = sha256_hex(canonical(operation))
platform = installation["platform"]
# Each of the others on about half the scans, unpredictably.
required = RELIED_ON[platform] + [
name for name in VARIED[platform] if secrets.randbelow(2)]
nonce = secrets.token_bytes(32)
challenge_id = str(uuid.uuid4())
expires = self.now() + SCAN_CHALLENGE_SECONDS
with self.lock:
self.scan_challenges[challenge_id] = {
"installation_id": session["installation_id"], "platform": platform,
"nonce_sha256": sha256_hex(nonce), "required": required, "expires": expires,
"used": False, "operation_sha256": digest,
"session": request.bearer if digest else None,
}
return {"challenge_id": challenge_id, "nonce": b64url_encode(nonce), "platform": platform,
"required_probes": required, "optional_probes": PERSONAL, "expires_at": iso(expires)}
@staticmethod
def _operation_valid(operation):
return (isinstance(operation, dict) and set(operation) == {"method", "path", "query",
"body_sha256"}
and operation["method"] in ("GET", "POST", "PUT", "PATCH", "DELETE")
and isinstance(operation["path"], str) and operation["path"].startswith("/")
and isinstance(operation["query"], str)
and isinstance(operation["body_sha256"], str)
and re.fullmatch(r"[0-9a-f]{64}", operation["body_sha256"]) is not None)
def report(self, request, body):
session, installation, _ = self.authenticate(request, ("installation", "account"))
try:
# 1. Decoded, at most the contract's size, a JSON object of version 1.
payload = b64url_decode(body.get("report_payload"))
if len(payload) > MAX_REPORT_BYTES:
raise ValueError("too large")
report = json.loads(payload.decode("utf-8"))
if not isinstance(report, dict) or report.get("version") != 1:
raise ValueError("version")
except (ValueError, UnicodeDecodeError) as error:
raise Refusal(400, "report_not_accepted", "The report is not one we read.",
{"check": str(error)})
# 2. Its installation is the session's.
if report.get("installation_id") != session["installation_id"]:
raise Refusal(400, "report_not_accepted", "The report names another installation.",
{"check": "installation_id"})
with self.lock:
# 3. Its challenge, held while it is checked: this installation's,
# for its platform.
challenge = self.scan_challenges.get(report.get("challenge_id"))
if (challenge is None or challenge["installation_id"] != session["installation_id"]
or report.get("platform") != challenge["platform"]):
raise Refusal(400, "report_not_accepted", "The report answers no challenge of ours.",
{"check": "challenge_id"})
# 4. Unused and in time.
if challenge["used"]:
raise Refusal(401, "integrity_challenge_replay", "The challenge was answered already.")
if challenge["expires"] < self.now():
raise Refusal(401, "integrity_challenge_expired", "The challenge has expired.")
# 5. The nonce it was given.
try:
nonce = b64url_decode(report.get("challenge_nonce"))
except ValueError:
nonce = b""
if not hmac.compare_digest(sha256_hex(nonce), challenge["nonce_sha256"]):
raise Refusal(400, "report_not_accepted", "The report carries another nonce.",
{"check": "challenge_nonce"})
# 6. The phone's clock.
collected = report.get("collected_at")
if (isinstance(collected, bool) or not isinstance(collected, int)
or abs(self.now() - collected) > REPORT_CLOCK_SECONDS):
raise Refusal(400, "integrity_report_stale", "The phone's clock is far off.")
# 7. A collector this backend reads.
version = report.get("collector_version")
if (isinstance(version, bool) or not isinstance(version, int)
or version < MIN_COLLECTOR[challenge["platform"]]):
raise Refusal(400, "report_not_accepted", "This collector is too old.",
{"check": "collector_version"})
# 8. Every measurement asked for.
results = report.get("probe_results")
missing = [p for p in challenge["required"]
if not isinstance(results, dict) or p not in results]
if missing:
raise Refusal(400, "integrity_probe_missing", "A measurement asked for is missing.",
{"missing_probes": missing})
# 9. The signature, over the decoded bytes.
if not verifies(installation["jwk"], payload, body.get("report_signature")):
raise Refusal(401, "invalid_installation_signature", "The signature does not verify.")
# 10. Used, in the same unit of work.
challenge["used"] = True
# Only then are the measurements read: first, whether they are the
# contract's.
self.check_shape(report)
# Then what they are worth, by the contract's rules; then the decision.
judgment = judge(report, challenge["required"], self.definitions)
decision = decide(judgment, report)
report_id = str(uuid.uuid4())
with self.lock:
self.reports[report_id] = {
"installation_id": session["installation_id"], "at": self.now(), "used": False,
"operation_sha256": challenge["operation_sha256"], "session": challenge["session"],
"report_sha256": sha256_hex(payload), "judgment": judgment, "next": decision,
}
# The answer says what to do next, never the judgment, a score or a
# reason (section 7).
return {"integrity": {"report_id": report_id, "next": decision}}
def check_shape(self, report):
"""The report against the contract's schema: its shape."""
problems = sorted(self.validator.iter_errors(report),
key=lambda e: list(e.absolute_path))
if problems:
raise Refusal(400, "report_not_accepted", "The report is not the contract's.",
{"check": "schema", "at": "/".join(map(str, problems[0].absolute_path))})
# --- Section 2: a scan for one action, used by the request it named.
def require_operation_scan(self, request, session):
report_id = request.headers.get("X-Integrity-Report")
here = sha256_hex(canonical({"method": request.method, "path": request.path,
"query": request.query,
"body_sha256": sha256_hex(request.body)}))
with self.lock:
stored = self.reports.get(report_id or "")
# A scan made for this request, under this session, lately -- and
# one the decision allowed.
if (stored is None or stored["used"] or stored["operation_sha256"] != here
or stored["session"] != request.bearer
or self.now() - stored["at"] > OPERATION_SCAN_SECONDS
or stored["next"] != "allow"):
raise Refusal(403, "needs_scan", "This request needs a scan made for it.")
stored["used"] = True
# --- Section 3: step-up.
def require_step_up(self, request, session, installation, nonce):
raw_b64 = request.headers.get("X-Step-Up-Proof")
step_up = installation.get("step_up")
if not raw_b64 or not request.headers.get("X-Step-Up-Signature") or step_up is None:
raise Refusal(403, "needs_step_up", "This request needs the person's approval.")
# A re-enrolled key approves for the account that re-enrolled it alone.
if step_up.get("account") not in (None, session["account"]):
raise Refusal(403, "step_up_not_accepted", "The step-up key is another account's.",
{"check": "account"})
try:
raw = b64url_decode(raw_b64)
proof = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
raw, proof = b"", {}
if not verifies(step_up["jwk"], raw, request.headers.get("X-Step-Up-Signature")):
raise Refusal(403, "step_up_not_accepted", "The approval does not verify.",
{"check": "signature"})
expected = {
"version": 2, "purpose": "stepup", "installation_id": session["installation_id"],
"access_token_sha256": sha256_hex(request.bearer.encode("utf-8")),
"method": request.method, "path": request.path, "query": request.query,
"body_sha256": sha256_hex(request.body), "nonce": nonce,
# The factor this sample requires is the one the key was made with.
"factor": step_up["factor"],
}
for field, value in expected.items():
if proof.get(field) != value:
raise Refusal(403, "step_up_not_accepted", "The approval is for another request.",
{"check": field})
timestamp = proof.get("timestamp")
if (isinstance(timestamp, bool) or not isinstance(timestamp, int)
or abs(self.now() - timestamp) > CLOCK_WINDOW_SECONDS):
raise Refusal(403, "step_up_not_accepted", "The approval is out of time.",
{"check": "timestamp"})
# --- Section 3: re-enrolment, a new step-up key for this account alone.
def reenrol_step_up(self, request, body):
session, installation, nonce = self.authenticate(request, ("account",))
step_up = self._step_up_key(body, installation["thumbprint"])
if step_up is None:
raise Refusal(400, "bad_request", "A new step-up key is required.",
{"field": "stepup_public_key"})
try:
raw = b64url_decode(body.get("stepup_key_proof"))
proof = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
raw, proof = b"", {}
# Every check of the new key before the password, so that this call
# cannot serve to guess it.
if not verifies(step_up["jwk"], raw, body.get("stepup_key_signature")):
raise Refusal(403, "step_up_not_accepted", "The new key's proof does not verify.",
{"check": "signature"})
expected = {"version": 1, "purpose": "stepup_reenrol",
"installation_id": session["installation_id"],
"stepup_key_thumbprint": thumbprint(step_up["jwk"]), "nonce": nonce}
for field, value in expected.items():
if not isinstance(proof, dict) or proof.get(field) != value:
raise Refusal(403, "step_up_not_accepted",
"The new key's proof is for another request.", {"check": field})
timestamp = proof.get("timestamp")
if (isinstance(timestamp, bool) or not isinstance(timestamp, int)
or abs(self.now() - timestamp) > CLOCK_WINDOW_SECONDS):
raise Refusal(403, "step_up_not_accepted", "The new key's proof is out of time.",
{"check": "timestamp"})
self._reauthenticate(self.accounts[session["account"]], session["account"],
body.get("password"))
with self.lock:
# Another account on the phone, which needs only the installation
# key, gains no step-up from it.
installation["step_up"] = dict(step_up, account=session["account"])
return {"stepup_key_registered": True}
# --- Section 3: the app PIN.
def set_app_pin(self, request, body):
session, _, _ = self.authenticate(request, ("account",))
account = self.accounts[session["account"]]
self._reauthenticate(account, session["account"], body.get("password"))
pin = body.get("pin")
if not (isinstance(pin, str) and len(pin) == PIN_LENGTH and pin.isascii() and pin.isdigit()):
raise Refusal(400, "bad_request", "The PIN is %d digits." % PIN_LENGTH, {"field": "pin"})
salt, verifier = slow_verifier(self.pin_secret, session["account"], pin)
with self.lock:
# A new PIN clears the wrong ones and any lock, and is a new
# generation: checks of the PIN it replaces settle against that
# one, and the proofs made under it no longer serve.
account.update(pin_salt=salt, pin_verifier=verifier, pin_failures=0, pin_locked=False,
pin_checking=0, pin_generation=account["pin_generation"] + 1)
return {"app_pin_set": True}
def prove_app_pin(self, request, body):
session, _, _ = self.authenticate(request, ("account",))
pin, target = body.get("pin"), body.get("request")
try:
complete = (isinstance(pin, str) and pin and self._operation_valid(
{k: target.get(k) for k in ("method", "path", "query", "body_sha256")})
and len(b64url_decode(target.get("nonce"))) == 32)
except (AttributeError, ValueError):
complete = False
if not complete:
# Refused before the PIN is checked.
raise Refusal(400, "bad_request", "Send the PIN and the request it unlocks.",
{"field": "request"})
account = self.accounts[session["account"]]
with self.lock:
if account.get("pin_verifier") is None:
raise Refusal(403, "app_pin_not_set", "No app PIN is set for this account.")
if account["pin_locked"]:
raise Refusal(403, "app_pin_locked", "The app PIN is locked; set a new one.")
if account["pin_failures"] + account["pin_checking"] >= PIN_ATTEMPTS:
# The attempts left are held by checks still running.
raise Refusal(429, "pin_busy", "The PIN is being checked; try again in a moment.",
{"retry_after_seconds": 1})
account["pin_checking"] += 1 # the attempt, claimed before comparing
generation = account["pin_generation"]
salt, verifier = account["pin_salt"], account["pin_verifier"]
try:
self._held_pin_check()
right = hmac.compare_digest(
slow_verifier(self.pin_secret, session["account"], pin, salt)[1], verifier)
except Exception:
right = None # the check could not run
with self.lock:
# Settled in one step, against the PIN it compared: a PIN set
# meanwhile is left alone -- its count, its lock, its checks --
# and a check that could not run gives its attempt back.
current = account["pin_generation"] == generation
if current:
account["pin_checking"] -= 1
if right:
account["pin_failures"] = 0
elif right is False:
account["pin_failures"] += 1
account["pin_locked"] = account["pin_failures"] >= PIN_ATTEMPTS
locked = account["pin_locked"]
if right is None:
raise Refusal(503, "pin_unavailable", "The app PIN cannot be checked now; try again.")
if not current:
# Neither a proof nor a count: the PIN it was right or wrong for
# is no longer the account's.
raise Refusal(403, "app_pin_invalid",
"The app PIN was changed while it was checked; enter it again.")
if not right:
if locked:
raise Refusal(403, "app_pin_locked", "The app PIN is locked; set a new one.")
raise Refusal(403, "app_pin_invalid", "The app PIN is wrong.")
expires = self.now() + PIN_PROOF_SECONDS
claims = canonical({"account": session["account"],
"installation_id": session["installation_id"],
"session_sha256": sha256_hex(request.bearer.encode("utf-8")),
"request": target, "expires_at": expires,
"pin_generation": generation})
mac = hmac.new(self.pin_proof_key, claims, hashlib.sha256).digest()
return {"app_pin_proof": "%s.%s" % (b64url_encode(claims), b64url_encode(mac)),
"expires_at": iso(expires)}
def require_app_pin(self, request, session, nonce):
try:
encoded, mac = request.headers.get("X-App-PIN-Proof", "").split(".")
claims = b64url_decode(encoded)
ours = hmac.compare_digest(
b64url_decode(mac), hmac.new(self.pin_proof_key, claims, hashlib.sha256).digest())
proof = json.loads(claims.decode("utf-8")) if ours else None
except (ValueError, UnicodeDecodeError):
proof = None
if proof is None:
raise Refusal(403, "needs_app_pin", "This request needs the app PIN.")
with self.lock:
generation = self.accounts[session["account"]]["pin_generation"]
if proof.get("pin_generation") != generation:
raise Refusal(403, "app_pin_not_accepted",
"The app PIN was changed since this proof was made; enter it again.")
expected = {"account": session["account"], "installation_id": session["installation_id"],
"session_sha256": sha256_hex(request.bearer.encode("utf-8")),
"request": {"method": request.method, "path": request.path,
"query": request.query, "body_sha256": sha256_hex(request.body),
"nonce": nonce}}
if proof.get("expires_at", 0) < self.now() or any(
proof.get(field) != value for field, value in expected.items()):
raise Refusal(403, "app_pin_not_accepted", "The PIN's proof is for another request.")
# --- This sample's own account API, in which the plugin's proofs are used.
def sign_in(self, request, body):
session, _, _ = self.authenticate(request, ("installation",))
handle, password = body.get("handle"), body.get("password")
if not (isinstance(handle, str) and handle and isinstance(password, str) and password):
raise Refusal(400, "bad_request", "A handle and a password are required.")
with self.lock:
account = self.accounts.get(handle)
if account is None:
salt, verifier = slow_verifier(self.password_secret, handle, password)
with self.lock:
account = self.accounts.setdefault(handle, {
"password_salt": salt, "password_verifier": verifier, "pin_salt": None,
"pin_verifier": None, "pin_failures": 0, "pin_locked": False,
"pin_checking": 0, "pin_generation": 0})
self._reauthenticate(account, handle, password)
token = secrets.token_urlsafe(32)
with self.lock:
# An account session for this installation, which proved its key.
self.sessions[token] = {"kind": "account", "installation_id": session["installation_id"],
"account": handle, "expires": self.now() + SESSION_SECONDS}
return {"account_session": token}
def _reauthenticate(self, account, handle, password):
_, verifier = slow_verifier(self.password_secret, handle, str(password),
account["password_salt"])
if not hmac.compare_digest(verifier, account["password_verifier"]):
raise Refusal(401, "reauthentication_failed", "The password is wrong.")
def demo(self, request, needs):
session, installation, nonce = self.authenticate(request, ("account",))
if "scan" in needs:
self.require_operation_scan(request, session)
if "approval" in needs:
# The person's approval, as the request offers it: step-up, the
# app PIN, or both -- and each one offered must hold.
step_up = bool(request.headers.get("X-Step-Up-Proof"))
app_pin = bool(request.headers.get("X-App-PIN-Proof"))
if not (step_up or app_pin):
raise Refusal(403, "needs_approval",
"This request needs the person's approval: step-up or the app PIN.")
if step_up:
self.require_step_up(request, session, installation, nonce)
if app_pin:
self.require_app_pin(request, session, nonce)
return {"done": request.path}
# --- Test controls, on only with --test-controls.
def _held_pin_check(self):
"""A PIN check waits here, its attempt claimed, while a test holds PIN
checks; released with fail, its verifier fails."""
with self.lock:
gate = self.pin_gate
if gate is None:
return
self.pin_waiting += 1
gate["released"].wait()
with self.lock:
self.pin_waiting -= 1
if gate["fail"]:
raise RuntimeError("the verifier failed, for a test")
def control(self, path, body):
with self.lock:
if path == "/test/expire-sessions":
for session in self.sessions.values():
session["expires"] = self.now() - 1
elif path == "/test/forget":
record = self.installations.pop(body.get("installation_id"), None)
if record is not None:
self.by_thumbprint.pop(record["thumbprint"], None)
elif path == "/test/refuse-next":
self.pending[body["path"]] = body
elif path == "/test/judge":
# The contract's judgment of a report under a plan of the
# test's own, as the report call would make it.
try:
self.check_shape(body["report"])
return {"judged": judge(body["report"], body["required_probes"],
self.definitions)}
except Refusal:
return {"judged": "refused"}
elif path == "/test/pin-checks":
# Hold PIN checks once they claim their attempt, or release
# those waiting; the answer counts those waiting.
if body.get("hold") is True and self.pin_gate is None:
self.pin_gate = {"released": threading.Event(), "fail": False}
elif body.get("hold") is False and self.pin_gate is not None:
self.pin_gate["fail"] = body.get("fail") is True
self.pin_gate["released"].set()
self.pin_gate = None
return {"ok": True, "waiting": self.pin_waiting}
else:
raise Refusal(404, "no_such_path", "No such control.")
return {"ok": True}
ROUTES = {
"/v1/installations/register": lambda b, r, body: b.register(body),
"/v1/installations/challenge": lambda b, r, body: b.key_challenge(body),
"/v1/installations/verify": lambda b, r, body: b.verify(body),
"/v1/integrity/challenge": lambda b, r, body: b.scan_challenge(r, body),
"/v1/integrity/report": lambda b, r, body: b.report(r, body),
"/v1/installations/stepup-key": lambda b, r, body: b.reenrol_step_up(r, body),
"/v1/account/app-pin": lambda b, r, body: b.set_app_pin(r, body),
"/v1/account/app-pin/proof": lambda b, r, body: b.prove_app_pin(r, body),
"/v1/demo/sign-in": lambda b, r, body: b.sign_in(r, body),
"/v1/demo/echo": lambda b, r, body: b.demo(r, ()),
"/v1/demo/transfer": lambda b, r, body: b.demo(r, ("scan",)),
"/v1/demo/sensitive": lambda b, r, body: b.demo(r, ("approval",)),
}
class Request:
def __init__(self, handler, body):
target = urlsplit(handler.path)
self.method = handler.command
self.path, self.query = target.path, target.query
self.headers = handler.headers
self.body = body
auth = handler.headers.get("Authorization", "")
self.bearer = auth[7:] if auth.startswith("Bearer ") else None
def handler_for(backend):
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # never a request's body or headers: they hold PINs
pass
def answer(self, status, value, failed=""):
# One line per call: the method, the path, the status, any refusal's
# code and the check that failed.
error = value.get("error") if isinstance(value, dict) else None
print(("%s %s %d %s %s" % (self.command, urlsplit(self.path).path, status,
error["code"] if error else "", failed)).rstrip(),
flush=True)
data = json.dumps(value).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if urlsplit(self.path).path == "/health":
self.answer(200, {"ok": True})
else:
self.answer(404, {"error": {"code": "no_such_path", "message": "No such path."}})
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
if length > 1 << 20:
self.answer(413, {"error": {"code": "too_large", "message": "Too large."}})
return
raw = self.rfile.read(length)
request = Request(self, raw)
try:
try:
body = json.loads(raw.decode("utf-8")) if raw else {}
except ValueError:
raise Refusal(400, "bad_request", "The body is not JSON.")
if not isinstance(body, dict):
raise Refusal(400, "bad_request", "The body is not a JSON object.")
if (request.path.startswith("/test/") and backend.test_token
and hmac.compare_digest(self.headers.get("X-Test-Token", "").encode(),
backend.test_token.encode())):
self.answer(200, backend.control(request.path, body))
return
with backend.lock:
pending = backend.pending.pop(request.path, None)
if pending is not None:
raise Refusal(pending.get("status", 400), pending["code"], "Refused for a test.",
pending.get("details"))
route = ROUTES.get(request.path)
if route is None:
raise Refusal(404, "no_such_path", "No such path.")
self.answer(200, route(backend, request, body))
except Refusal as refusal:
# Which check failed goes to the log, never to the phone, which
# is told what to do next (section 7): what gave a request away
# is what an attacker would fix.
details = dict(refusal.details or {})
failed = " ".join("%s=%s" % (key, details.pop(key))
for key in ("check", "at") if key in details)
error = {"code": refusal.code, "message": refusal.message}
if details:
error["details"] = details
self.answer(refusal.status, {"error": error}, failed)
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--port", type=int, default=8787)
parser.add_argument("--schema", required=True, help="the contract's report.schema.json")
parser.add_argument("--test-controls", action="store_true",
help="answer /test/* (expiring the sessions, forgetting an "
"installation, refusing the next request) to requests carrying "
"X-Test-Token: $SAMPLE_TEST_TOKEN -- for tests only")
args = parser.parse_args()
token = os.environ.get("SAMPLE_TEST_TOKEN", "") if args.test_controls else None
if token is not None and len(token) < 16:
parser.error("--test-controls needs SAMPLE_TEST_TOKEN, 16 characters or more")
with open(args.schema, encoding="utf-8") as handle:
backend = Backend(json.load(handle), token)
server = ThreadingHTTPServer(("127.0.0.1", args.port), handler_for(backend))
print("listening on http://127.0.0.1:%d" % args.port, flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
TypeScript · typescript/src/server.ts · 1,097 lines
// A sample backend for the necessary plugin, written from the integration
// guide (doc/integration_guide.md) and the measurement contract alone.
//
// Sample code: one process, everything in memory, its secrets made at start.
// A real backend keeps its records in a database and its secrets in a vault.
// The section numbers below are the guide's. Run:
//
// node dist/server.js --port 8787 --schema <contract>/report.schema.json
//
// Node's own HTTP server and crypto, and ajv for the contract's schema.
import Ajv2020 from "ajv/dist/2020";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as http from "node:http";
import { promisify } from "node:util";
const pbkdf2 = promisify(crypto.pbkdf2);
const SESSION_SECONDS = 600; // sessions: ten minutes (section 1)
const KEY_CHALLENGE_SECONDS = 120; // key challenges: two minutes (section 1)
const SCAN_CHALLENGE_SECONDS = 60; // scan challenges: 60 seconds (section 2)
const CLOCK_WINDOW_SECONDS = 120; // request and step-up proofs (section 3)
const REPORT_CLOCK_SECONDS = 90; // a report's collected_at (section 2)
const NONCE_KEEP_SECONDS = 600; // request-proof nonces, longer than the window
const OPERATION_SCAN_SECONDS = 120; // a scan approves its request for a couple of minutes
const PIN_PROOF_SECONDS = 120; // an app PIN proof: about two minutes
const PIN_LENGTH = 6; // this sample's choice of PIN length
const PIN_ATTEMPTS = 5; // this sample's limit of wrong PINs
const MAX_REPORT_BYTES = 65536; // the contract's limit
const MIN_COLLECTOR: Record<string, number> = { android: 8, ios: 5 };
const VERIFIER_ROUNDS = 600_000; // PBKDF2-HMAC-SHA256, a slow verifier
// What this sample asks for in every scan: every measurement that would catch tampering, the hooks,
// gadgets and changes to the app's code in memory among them. One left to chance is one a tampered
// app waits out, scanning again until it is not drawn. Only what weighs little varies.
const RELIED_ON: Record<string, string[]> = {
android: ["app_identity", "debug_state", "tracer", "root_files", "root_shell", "system_properties",
"selinux", "mounts", "developer_settings",
// Hooks and gadgets by name, by port, by thread and by memory layout; and the code in memory
// compared with its file.
"runtime_maps", "frida_ports", "instrumentation_threads", "exec_mappings", "code_integrity"],
ios: ["app_identity", "code_signing", "debugger", "jailbreak_files", "sandbox",
// Injected libraries, and the app's code in memory compared with its file -- asked for, so
// that a report without it is refused.
"dyld_images", "environment", "code_integrity"],
};
const VARIED: Record<string, string[]> = {
android: ["emulator"],
ios: ["simulator"],
};
// The personal measurements (the guide, section 8): this sample asks for every one in every scan, so
// that the example app can show each one it enables. A production backend asks only for those it uses.
const PERSONAL = ["local_addresses", "accessibility", "notification_access", "other_access",
"screen_capture", "location", "mock_location"];
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type JsonObject = { [key: string]: Json };
// --- Binary values travel as base64url without padding; hashes are hex SHA-256.
function b64urlDecode(text: unknown): Buffer {
if (typeof text !== "string" || !/^[A-Za-z0-9_-]*$/.test(text)) {
throw new Error("not base64url text");
}
return Buffer.from(text, "base64url");
}
const b64urlEncode = (raw: Buffer): string => raw.toString("base64url");
const sha256Hex = (raw: Buffer | string): string => crypto.createHash("sha256").update(raw).digest("hex");
const hmac = (key: Buffer, data: Buffer | string): Buffer => crypto.createHmac("sha256", key).update(data).digest();
const now = (): number => Math.floor(Date.now() / 1000);
const iso = (seconds: number): string => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
function sorted(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sorted);
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.keys(value as object).sort()
.map((key) => [key, sorted((value as Record<string, unknown>)[key])]));
}
return value;
}
const canonical = (value: unknown): Buffer => Buffer.from(JSON.stringify(sorted(value)), "utf8");
const isInt = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value);
const same = (a: Buffer, b: Buffer): boolean => a.length === b.length && crypto.timingSafeEqual(a, b);
/** An answer of {"error": {"code", "message", "details"}} (section 4). */
class Refusal extends Error {
constructor(readonly status: number, readonly code: string, message: string,
readonly details?: JsonObject) {
super(message);
}
}
// --- The contract's rules (RULES.md): what a report's measurements are worth before any business
// logic weighs them -- never a score. A report is judged over the measurements its challenge
// required: complete, partial (part of the app's code compared), incomplete (something not measured)
// or invalid (figures that cannot all be true).
type Quality = "complete" | "partial" | "incomplete" | "invalid";
const QUALITIES: Quality[] = ["complete", "partial", "incomplete", "invalid"]; // best to worst
// Where code is always mapped, so that a comparison there finds some (RULES.md 4): on an iPhone only
// the app's own, the rest being unreadable.
const REGIONS: Record<string, string[]> = { android: ["core", "ext", "app"], ios: ["app"] };
const IOS_COVERAGE = ["app_images_expected", "app_images_compared", "app_images_unreadable",
"app_images_skipped", "app_expected_bytes", "app_compared_bytes", "app_unreadable_bytes",
"app_skipped_bytes", "app_complete", "main_executable_compared"];
type Definitions = Record<string, { "x-complete-when-ok"?: string[]; "x-complete-when-checked"?: string[] }>;
const isObject = (value: unknown): value is JsonObject =>
value !== null && typeof value === "object" && !Array.isArray(value);
const absent = (value: unknown): boolean => value === undefined || value === null;
/** The contract's judgment of a report whose shape the schema accepted. Split APKs out of order
* (RULES.md 6, the one rule a schema cannot state) are refused rather than read. */
function judge(report: JsonObject, required: string[], definitions: Definitions): Quality {
const platform = report.platform as string;
const results = report.probe_results as JsonObject;
const identity = results.app_identity;
const splits = isObject(identity) ? identity.split_apks : undefined;
if (Array.isArray(splits)) {
const names = splits.map((entry) => (entry as JsonObject).name as string);
// Sorted by name, by code unit as the strings compare, none twice.
if (names.some((name, at) => at > 0 && !(names[at - 1] < name))) {
throw new Refusal(400, "report_not_accepted", "The report is not the contract's.", { check: "split_apks" });
}
}
const found: Quality[] = ["complete"];
for (const name of required) {
const value = results[name];
const definition = definitions[`${platform}.${name}`] ?? {};
if (!isObject(value) || value.status !== "ok") {
found.push("incomplete"); // failed or unsupported: not measured (2)
continue;
}
// ok is a claim: a measurement is complete with every field it lists, and with nothing in
// unread (2).
const unread = value.unread;
if ((definition["x-complete-when-ok"] ?? []).some((field) => absent(value[field]))
|| (isObject(unread) && Object.keys(unread).length > 0)) {
found.push("incomplete");
}
// Failed reads, not clean values (7).
if ((["runtime_maps", "exec_mappings"].includes(name) && value.maps_lines === 0)
|| (name === "instrumentation_threads" && value.thread_count === 0)) {
found.push("incomplete");
}
if (name === "exec_mappings" && wxSizesWithoutMappings(value)) found.push("invalid");
if (name === "code_integrity") found.push(codeComparison(platform, value, definition));
}
// Contradictions across measurements (7).
const capture = results.screen_capture;
const device = results.device;
if (isObject(capture) && capture.status === "ok" && capture.recording_status === "unsupported"
&& (platform === "ios" || (isObject(device) && isInt(device.sdk_int) && device.sdk_int >= 35))) {
found.push("invalid"); // recording unsupported where it is measured
}
const location = results.location;
const mock = results.mock_location;
if (isObject(location) && isObject(mock) && location.status === "ok" && mock.status === "ok"
&& isInt(location.fix_time) && isInt(mock.fix_time) && location.fix_time !== mock.fix_time) {
found.push("invalid"); // two fixes presented as one (5)
}
return found.reduce((worst, quality) =>
QUALITIES.indexOf(quality) > QUALITIES.indexOf(worst) ? quality : worst);
}
/** No writable-and-executable mapping, with the sizes of such mappings listed (RULES.md 7). */
function wxSizesWithoutMappings(maps: JsonObject): boolean {
let classes = 0;
for (const part of String(maps.wx_size_classes ?? "").split(",")) {
if (!part.trim()) continue;
const at = part.indexOf(":");
const size = (at < 0 ? part : part.slice(0, at)).trim();
const count = (at < 0 ? "" : part.slice(at + 1)).trim();
if (!/^[0-9]+$/.test(size) || !/^[0-9]+$/.test(count)) {
classes = 0; // not a list of sizes
break;
}
classes += 1;
}
const extremes = Boolean(maps.wx_smallest_bytes) || Boolean(maps.wx_largest_bytes);
return maps.wx_mappings === 0 && !maps.wx_bytes && (classes > 0 || extremes);
}
/** The comparison of code in memory with its files (RULES.md 4): invalid when its figures do not add
* up; incomplete when it did not run, lacks a figure, or compared nothing where code always is;
* partial when it compared part of the code. */
function codeComparison(platform: string, code: JsonObject, definition: Definitions[string]): Quality {
if (!(platform === "android" ? androidCoverageAddsUp(code) : iosCoverageAddsUp(code))) return "invalid";
if (code.checked !== true || (definition["x-complete-when-checked"] ?? []).some((field) => absent(code[field]))) {
return "incomplete";
}
if (REGIONS[platform].some((region) => code[`${region}_compared_bytes`] === 0)) return "incomplete";
if (platform === "ios") return code.app_complete === true ? "complete" : "partial";
const short = REGIONS.android.some((region) =>
(code[`${region}_compared_bytes`] as number) < (code[`${region}_expected_bytes`] as number));
return short || ((code.protect_restore_failures as number | undefined) ?? 0) > 0 ? "partial" : "complete";
}
/** In each region the expected bytes are the compared, skipped and unreadable ones together, and
* <region>_complete is true exactly when some code was expected and all of it was compared. */
function androidCoverageAddsUp(code: JsonObject): boolean {
for (const region of REGIONS.android) {
const expected = code[`${region}_expected_bytes`];
if (absent(expected)) continue;
const parts = ["compared", "skipped", "unreadable"].map((kind) => code[`${region}_${kind}_bytes`]);
if (!parts.every(isInt) || (parts as number[]).reduce((a, b) => a + b, 0) !== expected) return false;
const complete = code[`${region}_complete`];
if (!absent(complete) && complete !== ((expected as number) > 0 && parts[0] === expected)) return false;
}
return true;
}
/** Images and bytes both add up -- expected = compared + unreadable + skipped -- and app_complete is
* true exactly when every image was compared, the main executable among them; bytes are compared
* exactly when images are, and each image not compared is named. A collector that reports only part
* of it is judged by its fields instead. */
function iosCoverageAddsUp(code: JsonObject): boolean {
if (IOS_COVERAGE.some((field) => absent(code[field]))) return true;
const figures = IOS_COVERAGE.slice(0, 8).map((field) => code[field]);
if (!figures.every(isInt)) return false;
const [expected, compared, unreadable, skipped, expectedBytes, comparedBytes, unreadableBytes,
skippedBytes] = figures as number[];
const complete = expected > 0 && compared === expected;
const main = code.main_executable_compared;
const named = code.app_images_not_compared;
const namedCount = isObject(named) ? Object.keys(named).length : 0;
return !(
expected !== compared + unreadable + skipped
|| expectedBytes !== comparedBytes + unreadableBytes + skippedBytes
|| code.app_complete !== complete
|| (complete && (unreadableBytes > 0 || skippedBytes > 0))
|| (unreadableBytes > 0 && unreadable === 0) || (skippedBytes > 0 && skipped === 0)
|| (comparedBytes > 0) !== (compared > 0)
|| (!absent(code.checked) && code.checked !== (compared > 0))
|| (main === true && compared === 0) || (complete && main !== true)
|| (!absent(named) && (namedCount > unreadable + skipped || (unreadable + skipped > 0 && namedCount === 0))));
}
// Your business logic decides (the guide, section 7), and this is where it goes. This sample decides
// on the contract's judgment alone and scores nothing: a complete report lets the action go on, and
// so -- by this sample's choice -- does a partial one, whose comparison found nothing in the code it
// could read; an incomplete one asks for a scan again, since a failed read is no proof of an attack;
// and a report whose figures contradict each other is held for review. Only a report allowed here
// approves a scan made for one action.
const NEXT: Record<Quality, string> = { complete: "allow", partial: "allow", incomplete: "scan_again", invalid: "review" };
/** What to do next, for this report: allow, scan_again or review. */
function decide(judgment: Quality, report: JsonObject): string {
void report; // your own rules read the measurements here
return NEXT[judgment];
}
// --- Section 1: the installation key.
function publicKey(jwk: unknown): crypto.KeyObject {
const key = jwk as Record<string, unknown> | null;
if (key === null || typeof key !== "object" || key.kty !== "EC" || key.crv !== "P-256") {
throw new Error("only EC P-256 keys are accepted");
}
const x = b64urlDecode(key.x);
const y = b64urlDecode(key.y);
if (x.length !== 32 || y.length !== 32) throw new Error("P-256 coordinates are 32 bytes each");
// Throws for a point that is not on the curve.
return crypto.createPublicKey({
key: { kty: "EC", crv: "P-256", x: b64urlEncode(x), y: b64urlEncode(y) }, format: "jwk",
});
}
function thumbprint(jwk: JsonObject): string {
return sha256Hex(canonical({
crv: "P-256", kty: "EC", x: b64urlEncode(b64urlDecode(jwk.x)), y: b64urlEncode(b64urlDecode(jwk.y)),
}));
}
/** Whether the signature verifies over these bytes, as they were signed. */
function verifies(jwk: JsonObject, payload: Buffer, signature: unknown): boolean {
try {
return crypto.verify("sha256", payload, publicKey(jwk), b64urlDecode(signature));
} catch {
return false;
}
}
/** PBKDF2 over an HMAC of the subject and the value, keyed by a secret the
* records never hold: a copy of the records checks nothing. */
async function slowVerifier(secret: Buffer, subject: string, value: string,
salt: Buffer = crypto.randomBytes(16)): Promise<[Buffer, Buffer]> {
const keyed = hmac(secret, `${subject}:${value}`);
return [salt, await pbkdf2(keyed, salt, VERIFIER_ROUNDS, 32, "sha256")];
}
interface Installation {
thumbprint: string;
jwk: JsonObject;
platform: string;
hint: string | null;
keySecurity: Json;
// A re-enrolled key carries the account that re-enrolled it.
stepUp: { jwk: JsonObject; factor: string; mode: string; windowSeconds: number; account?: string } | null;
}
interface Session { kind: "installation" | "account"; installationId: string; account?: string; expires: number }
interface Account {
passwordSalt: Buffer; passwordVerifier: Buffer;
pinSalt: Buffer | null; pinVerifier: Buffer | null;
pinFailures: number; pinLocked: boolean; pinChecking: number;
// Each PIN set is a new generation; a check settles against the one it compared.
pinGeneration: number;
}
class Request {
readonly path: string;
readonly query: string;
readonly bearer: string | null;
constructor(readonly method: string, url: string, readonly headers: http.IncomingHttpHeaders,
readonly body: Buffer) {
const at = url.indexOf("?");
this.path = at < 0 ? url : url.slice(0, at);
this.query = at < 0 ? "" : url.slice(at + 1);
const auth = headers.authorization ?? "";
this.bearer = auth.startsWith("Bearer ") ? auth.slice(7) : null;
}
header(name: string): string | undefined {
const value = this.headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
}
}
const operationValid = (operation: unknown): boolean => {
const op = operation as Record<string, unknown> | null;
return op !== null && typeof op === "object" && !Array.isArray(op)
&& Object.keys(op).sort().join(",") === "body_sha256,method,path,query"
&& ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(op.method as string)
&& typeof op.path === "string" && op.path.startsWith("/") && typeof op.query === "string"
&& typeof op.body_sha256 === "string" && /^[0-9a-f]{64}$/.test(op.body_sha256);
};
class Backend {
// Secrets made at start: a restart forgets every session and challenge.
private readonly challengeKey = crypto.randomBytes(32);
private readonly hintKey = crypto.randomBytes(32);
private readonly pinSecret = crypto.randomBytes(32);
private readonly passwordSecret = crypto.randomBytes(32);
private readonly pinProofKey = crypto.randomBytes(32);
private readonly installations = new Map<string, Installation>();
private readonly byThumbprint = new Map<string, string>();
private readonly usedKeyChallenges = new Set<string>();
private readonly sessions = new Map<string, Session>();
private readonly nonces = new Map<string, number>();
private readonly scanChallenges = new Map<string, {
installationId: string; platform: string; nonceSha256: string; required: string[];
expires: number; used: boolean; operationSha256: string | null; session: string | null;
}>();
private readonly reports = new Map<string, {
installationId: string; at: number; used: boolean; operationSha256: string | null;
session: string | null; judgment: Quality; next: string;
}>();
private readonly accounts = new Map<string, Account>();
readonly pending = new Map<string, JsonObject>(); // test control: the next request's refusal
// Test control: while set, PIN checks wait on it; released with fail, their verifier fails.
private pinGate: { released: Promise<boolean>; release: (fail: boolean) => void } | null = null;
private pinWaiting = 0; // test control: the PIN checks waiting now
constructor(private readonly validate: ReturnType<Ajv2020["compile"]>,
private readonly definitions: Definitions, // the contract's x- annotations
readonly testToken: string | null) {} // test controls answer only this token
// --- Section 1: registration.
register(body: JsonObject): JsonObject {
const installationId = body.installation_id;
const platform = body.platform;
let jwk: JsonObject;
try {
if (typeof installationId !== "string"
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(installationId)) {
throw new Error("not a UUID");
}
publicKey(body.public_key);
jwk = body.public_key as JsonObject;
} catch {
throw new Refusal(400, "bad_request", "A UUID and an EC P-256 public key are required.",
{ field: "public_key" });
}
if (platform !== "android" && platform !== "ios") {
throw new Refusal(400, "bad_request", "The platform is android or ios.", { field: "platform" });
}
const hint = body.reinstall_hint as JsonObject | null | undefined;
if (hint != null && !(typeof hint === "object" && ["android_id_sha256", "idfv_sha256"].includes(hint.kind as string)
&& typeof hint.value === "string" && /^[0-9a-f]{64}$/.test(hint.value))) {
throw new Refusal(400, "bad_request", "The reinstall hint is malformed.", { field: "reinstall_hint" });
}
const key = thumbprint(jwk);
const stepUp = this.stepUpKey(body, key);
const held = this.byThumbprint.get(key);
if (held !== undefined) {
// The thumbprint is the identity: the same key is the same installation,
// whatever ID it proposes. A step-up key offered now is not taken --
// only re-enrolment replaces one -- so the answer says whether it is the
// one held, and the app asks for re-enrolment rather than sign with a
// key this backend lacks.
const bound = (this.installations.get(held) as Installation).stepUp;
return {
installation_id: held, stepup_key_registered: bound !== null,
stepup_key_matches: stepUp === null ? null
: bound !== null && thumbprint(bound.jwk) === thumbprint(stepUp.jwk),
};
}
if (this.installations.has(installationId)) {
throw new Refusal(409, "installation_id_taken", "This installation ID belongs to another key.");
}
this.installations.set(installationId, {
thumbprint: key, jwk, platform,
// Keyed before it is kept: what the app says, never proof.
hint: hint ? hmac(this.hintKey, hint.value as string).toString("hex") : null,
// Recorded, never relied on: the phone cannot prove it.
keySecurity: body.key_security ?? null,
stepUp,
});
this.byThumbprint.set(key, installationId);
return { installation_id: installationId, stepup_key_registered: stepUp !== null };
}
/** Section 3, step-up: the key and how the phone says it is unlocked. */
private stepUpKey(body: JsonObject, installationThumbprint: string): Installation["stepUp"] {
const jwk = body.stepup_public_key;
if (jwk === undefined || jwk === null) return null;
try {
publicKey(jwk);
} catch {
throw new Refusal(400, "bad_request", "The step-up key is not an EC P-256 key.",
{ field: "stepup_public_key" });
}
if (thumbprint(jwk as JsonObject) === installationThumbprint) {
throw new Refusal(400, "bad_request", "The step-up key must not be the installation key.",
{ field: "stepup_public_key" });
}
const auth = body.stepup_key_auth as JsonObject | null;
const window = auth?.window_seconds;
if (!(auth && typeof auth === "object" && ["passcode", "biometric"].includes(auth.factor as string)
&& isInt(window) && ((auth.mode === "per_use" && window === 0)
|| (auth.mode === "windowed" && window >= 1 && window <= 3600)))) {
throw new Refusal(400, "bad_request", "stepup_key_auth is malformed.", { field: "stepup_key_auth" });
}
return { jwk: jwk as JsonObject, factor: auth.factor as string, mode: auth.mode as string, windowSeconds: window };
}
// --- Section 1: proof of possession, with a challenge that stores nothing.
keyChallenge(body: JsonObject): JsonObject {
const installationId = body.installation_id;
if (typeof installationId !== "string" || !this.installations.has(installationId)) {
throw new Refusal(404, "installation_not_found", "No such installation.");
}
const expires = now() + KEY_CHALLENGE_SECONDS;
const claims: JsonObject = {
challenge_id: crypto.randomUUID(), installation_id: installationId, purpose: "prove_installation",
nonce: b64urlEncode(crypto.randomBytes(32)), expires_at: expires,
};
claims.mac = b64urlEncode(hmac(this.challengeKey, canonical(claims)));
return { challenge_id: claims.challenge_id, payload: b64urlEncode(canonical(claims)), expires_at: iso(expires) };
}
verify(body: JsonObject): JsonObject {
const installationId = body.installation_id;
const challengeId = body.challenge_id;
let payload: Buffer;
let claims: JsonObject;
let mac: Buffer;
try {
payload = b64urlDecode(body.payload);
claims = JSON.parse(payload.toString("utf8"));
mac = b64urlDecode(claims.mac);
delete claims.mac;
} catch {
throw new Refusal(401, "proof_not_accepted", "This is not a challenge of ours.");
}
if (!same(mac, hmac(this.challengeKey, canonical(claims))) || claims.challenge_id !== challengeId
|| claims.installation_id !== installationId || claims.purpose !== "prove_installation") {
throw new Refusal(401, "proof_not_accepted", "This is not a challenge of ours.");
}
if ((claims.expires_at as number) < now()) {
throw new Refusal(401, "challenge_expired", "The challenge has expired.");
}
const installation = this.installations.get(installationId as string);
if (installation === undefined) throw new Refusal(404, "installation_not_found", "No such installation.");
if (!verifies(installation.jwk, payload, body.signature)) {
throw new Refusal(401, "invalid_installation_signature", "The signature does not verify.");
}
// Consumed only after the signature verifies; one event loop makes it atomic.
if (this.usedKeyChallenges.has(challengeId as string)) {
throw new Refusal(401, "challenge_used", "The challenge was answered already.");
}
this.usedKeyChallenges.add(challengeId as string);
const token = crypto.randomBytes(32).toString("base64url");
this.sessions.set(token, { kind: "installation", installationId: installationId as string, expires: now() + SESSION_SECONDS });
return { device_token: token, expires_at: iso(now() + SESSION_SECONDS) };
}
// --- Section 3: every request carries a request proof.
authenticate(request: Request, kinds: string[]): [Session, Installation, string] {
const token = request.bearer;
const session = token ? this.sessions.get(token) : undefined;
if (session === undefined || !kinds.includes(session.kind)) {
throw new Refusal(401, "not_signed_in", "This request needs a session of ours.");
}
if (session.expires < now()) throw new Refusal(401, "expired_token", "The session has expired.");
const installation = this.installations.get(session.installationId);
if (installation === undefined) throw new Refusal(404, "installation_not_found", "No such installation.");
let raw: Buffer;
let proof: JsonObject;
let nonce: Buffer;
try {
raw = b64urlDecode(request.header("X-Access-Proof"));
proof = JSON.parse(raw.toString("utf8"));
nonce = b64urlDecode(proof.nonce);
} catch {
throw new Refusal(401, "proof_not_accepted", "The request proof is missing or malformed.");
}
const expected: JsonObject = {
access_token_sha256: sha256Hex(token as string), body_sha256: sha256Hex(request.body),
installation_id: session.installationId, method: request.method, path: request.path,
query: request.query, version: 2,
};
for (const [field, value] of Object.entries(expected)) {
if (proof[field] !== value) {
throw new Refusal(401, "proof_not_accepted", "The request proof is for another request.", { check: field });
}
}
const timestamp = proof.timestamp;
if (!isInt(timestamp) || Math.abs(now() - timestamp) > CLOCK_WINDOW_SECONDS || nonce.length !== 32) {
throw new Refusal(401, "proof_not_accepted", "The request proof is out of time.", { check: "timestamp" });
}
if (!verifies(installation.jwk, raw, request.header("X-Access-Signature"))) {
throw new Refusal(401, "invalid_installation_signature", "The signature does not verify.");
}
// Stored only after the signature verifies, under a unique key.
for (const [kept, until] of this.nonces) if (until < now()) this.nonces.delete(kept);
const key = sha256Hex(nonce);
if (this.nonces.has(key)) throw new Refusal(401, "proof_reused", "This request was sent already.");
this.nonces.set(key, now() + NONCE_KEEP_SECONDS);
return [session, installation, proof.nonce as string];
}
// --- Section 2: the scan.
scanChallenge(request: Request, body: JsonObject): JsonObject {
const [session, installation] = this.authenticate(request, ["installation", "account"]);
const operation = body.operation;
let digest: string | null = null;
if (operation !== undefined && operation !== null) {
// A scan for one action is the account's, never the installation's.
if (session.kind !== "account" || !operationValid(operation)) {
throw new Refusal(400, "invalid_operation",
"An operation is named only under an account session, whole.");
}
digest = sha256Hex(canonical(operation));
}
const platform = installation.platform;
// Each of the others on about half the scans, unpredictably.
const required = [...RELIED_ON[platform], ...VARIED[platform].filter(() => crypto.randomInt(2) === 1)];
const nonce = crypto.randomBytes(32);
const challengeId = crypto.randomUUID();
const expires = now() + SCAN_CHALLENGE_SECONDS;
this.scanChallenges.set(challengeId, {
installationId: session.installationId, platform, nonceSha256: sha256Hex(nonce), required,
expires, used: false, operationSha256: digest, session: digest ? request.bearer : null,
});
return {
challenge_id: challengeId, nonce: b64urlEncode(nonce), platform, required_probes: required,
optional_probes: [...PERSONAL], expires_at: iso(expires),
};
}
report(request: Request, body: JsonObject): JsonObject {
const [session, installation] = this.authenticate(request, ["installation", "account"]);
// 1. Decoded, at most the contract's size, a JSON object of version 1.
let payload: Buffer;
let report: JsonObject;
try {
payload = b64urlDecode(body.report_payload);
if (payload.length > MAX_REPORT_BYTES) throw new Error("too large");
report = JSON.parse(payload.toString("utf8"));
if (report === null || typeof report !== "object" || Array.isArray(report) || report.version !== 1) {
throw new Error("version");
}
} catch (error) {
throw new Refusal(400, "report_not_accepted", "The report is not one we read.",
{ check: (error as Error).message });
}
// 2. Its installation is the session's.
if (report.installation_id !== session.installationId) {
throw new Refusal(400, "report_not_accepted", "The report names another installation.",
{ check: "installation_id" });
}
// 3. Its challenge: this installation's, for its platform.
const challenge = this.scanChallenges.get(report.challenge_id as string);
if (challenge === undefined || challenge.installationId !== session.installationId
|| report.platform !== challenge.platform) {
throw new Refusal(400, "report_not_accepted", "The report answers no challenge of ours.",
{ check: "challenge_id" });
}
// 4. Unused and in time.
if (challenge.used) throw new Refusal(401, "integrity_challenge_replay", "The challenge was answered already.");
if (challenge.expires < now()) throw new Refusal(401, "integrity_challenge_expired", "The challenge has expired.");
// 5. The nonce it was given.
let nonce: Buffer = Buffer.alloc(0);
try {
nonce = b64urlDecode(report.challenge_nonce);
} catch { /* an empty nonce, which matches nothing */ }
if (!same(Buffer.from(sha256Hex(nonce)), Buffer.from(challenge.nonceSha256))) {
throw new Refusal(400, "report_not_accepted", "The report carries another nonce.",
{ check: "challenge_nonce" });
}
// 6. The phone's clock.
const collected = report.collected_at;
if (!isInt(collected) || Math.abs(now() - collected) > REPORT_CLOCK_SECONDS) {
throw new Refusal(400, "integrity_report_stale", "The phone's clock is far off.");
}
// 7. A collector this backend reads.
const version = report.collector_version;
if (!isInt(version) || version < MIN_COLLECTOR[challenge.platform]) {
throw new Refusal(400, "report_not_accepted", "This collector is too old.", { check: "collector_version" });
}
// 8. Every measurement asked for.
const results = report.probe_results;
const missing = challenge.required.filter((probe) =>
results === null || typeof results !== "object" || Array.isArray(results) || !(probe in results));
if (missing.length > 0) {
throw new Refusal(400, "integrity_probe_missing", "A measurement asked for is missing.",
{ missing_probes: missing });
}
// 9. The signature, over the decoded bytes.
if (!verifies(installation.jwk, payload, body.report_signature)) {
throw new Refusal(401, "invalid_installation_signature", "The signature does not verify.");
}
// 10. Used; one event loop makes the checks and the marking one unit.
challenge.used = true;
// Only then are the measurements read: first, whether they are the contract's.
this.checkShape(report);
// Then what they are worth, by the contract's rules; then the decision.
const judgment = judge(report, challenge.required, this.definitions);
const decision = decide(judgment, report);
const reportId = crypto.randomUUID();
this.reports.set(reportId, {
installationId: session.installationId, at: now(), used: false,
operationSha256: challenge.operationSha256, session: challenge.session, judgment, next: decision,
});
// The answer says what to do next, never the judgment, a score or a reason (section 7).
return { integrity: { report_id: reportId, next: decision } };
}
/** The report against the contract's schema: its shape. */
checkShape(report: JsonObject): void {
if (!this.validate(report)) {
throw new Refusal(400, "report_not_accepted", "The report is not the contract's.",
{ check: "schema", at: this.validate.errors?.[0]?.instancePath ?? "" });
}
}
// --- Section 2: a scan for one action, used by the request it named.
requireOperationScan(request: Request): void {
const stored = this.reports.get(request.header("X-Integrity-Report") ?? "");
const here = sha256Hex(canonical({
method: request.method, path: request.path, query: request.query, body_sha256: sha256Hex(request.body),
}));
// A scan made for this request, under this session, lately -- and one the decision allowed.
if (stored === undefined || stored.used || stored.operationSha256 !== here
|| stored.session !== request.bearer || now() - stored.at > OPERATION_SCAN_SECONDS
|| stored.next !== "allow") {
throw new Refusal(403, "needs_scan", "This request needs a scan made for it.");
}
stored.used = true;
}
// --- Section 3: step-up.
requireStepUp(request: Request, session: Session, installation: Installation, nonce: string): void {
const stepUp = installation.stepUp;
const proofText = request.header("X-Step-Up-Proof");
const signature = request.header("X-Step-Up-Signature");
if (!proofText || !signature || stepUp === null) {
throw new Refusal(403, "needs_step_up", "This request needs the person's approval.");
}
// A re-enrolled key approves for the account that re-enrolled it alone.
if (stepUp.account !== undefined && stepUp.account !== session.account) {
throw new Refusal(403, "step_up_not_accepted", "The step-up key is another account's.", { check: "account" });
}
let raw: Buffer = Buffer.alloc(0);
let proof: JsonObject = {};
try {
raw = b64urlDecode(proofText);
proof = JSON.parse(raw.toString("utf8"));
} catch { /* fails the signature below */ }
if (!verifies(stepUp.jwk, raw, signature)) {
throw new Refusal(403, "step_up_not_accepted", "The approval does not verify.", { check: "signature" });
}
const expected: JsonObject = {
version: 2, purpose: "stepup", installation_id: session.installationId,
access_token_sha256: sha256Hex(request.bearer as string), method: request.method, path: request.path,
query: request.query, body_sha256: sha256Hex(request.body), nonce,
// The factor this sample requires is the one the key was made with.
factor: stepUp.factor,
};
for (const [field, value] of Object.entries(expected)) {
if (proof[field] !== value) {
throw new Refusal(403, "step_up_not_accepted", "The approval is for another request.", { check: field });
}
}
if (!isInt(proof.timestamp) || Math.abs(now() - proof.timestamp) > CLOCK_WINDOW_SECONDS) {
throw new Refusal(403, "step_up_not_accepted", "The approval is out of time.", { check: "timestamp" });
}
}
// --- Section 3: re-enrolment, a new step-up key for this account alone.
async reenrolStepUp(request: Request, body: JsonObject): Promise<JsonObject> {
const [session, installation, nonce] = this.authenticate(request, ["account"]);
const stepUp = this.stepUpKey(body, installation.thumbprint);
if (stepUp === null) {
throw new Refusal(400, "bad_request", "A new step-up key is required.", { field: "stepup_public_key" });
}
let raw: Buffer = Buffer.alloc(0);
let proof: JsonObject = {};
try {
raw = b64urlDecode(body.stepup_key_proof as string);
proof = JSON.parse(raw.toString("utf8"));
} catch { /* fails the signature below */ }
// Every check of the new key before the password, so that this call cannot serve to guess it.
if (!verifies(stepUp.jwk, raw, body.stepup_key_signature)) {
throw new Refusal(403, "step_up_not_accepted", "The new key's proof does not verify.", { check: "signature" });
}
const expected: JsonObject = {
version: 1, purpose: "stepup_reenrol", installation_id: session.installationId,
stepup_key_thumbprint: thumbprint(stepUp.jwk), nonce,
};
for (const [field, value] of Object.entries(expected)) {
if (proof === null || typeof proof !== "object" || proof[field] !== value) {
throw new Refusal(403, "step_up_not_accepted", "The new key's proof is for another request.",
{ check: field });
}
}
if (!isInt(proof.timestamp) || Math.abs(now() - proof.timestamp) > CLOCK_WINDOW_SECONDS) {
throw new Refusal(403, "step_up_not_accepted", "The new key's proof is out of time.", { check: "timestamp" });
}
const account = this.accounts.get(session.account as string) as Account;
await this.reauthenticate(account, session.account as string, body.password);
// Another account on the phone, which needs only the installation key, gains no step-up from it.
installation.stepUp = { ...stepUp, account: session.account as string };
return { stepup_key_registered: true };
}
// --- Section 3: the app PIN.
async setAppPin(request: Request, body: JsonObject): Promise<JsonObject> {
const [session] = this.authenticate(request, ["account"]);
const account = this.accounts.get(session.account as string) as Account;
await this.reauthenticate(account, session.account as string, body.password);
const pin = body.pin;
if (!(typeof pin === "string" && pin.length === PIN_LENGTH && /^[0-9]+$/.test(pin))) {
throw new Refusal(400, "bad_request", `The PIN is ${PIN_LENGTH} digits.`, { field: "pin" });
}
const [salt, verifier] = await slowVerifier(this.pinSecret, session.account as string, pin);
// A new PIN clears the wrong ones and any lock, and is a new generation: checks of the PIN it
// replaces settle against that one, and the proofs made under it no longer serve.
Object.assign(account, {
pinSalt: salt, pinVerifier: verifier, pinFailures: 0, pinLocked: false, pinChecking: 0,
pinGeneration: account.pinGeneration + 1,
});
return { app_pin_set: true };
}
async proveAppPin(request: Request, body: JsonObject): Promise<JsonObject> {
const [session] = this.authenticate(request, ["account"]);
const pin = body.pin;
const target = body.request as JsonObject | null;
let complete = false;
try {
complete = typeof pin === "string" && pin.length > 0 && target !== null && typeof target === "object"
&& operationValid({ method: target.method, path: target.path, query: target.query, body_sha256: target.body_sha256 })
&& b64urlDecode(target.nonce).length === 32;
} catch { /* incomplete */ }
if (!complete) {
// Refused before the PIN is checked.
throw new Refusal(400, "bad_request", "Send the PIN and the request it unlocks.", { field: "request" });
}
const account = this.accounts.get(session.account as string) as Account;
if (account.pinVerifier === null || account.pinSalt === null) {
throw new Refusal(403, "app_pin_not_set", "No app PIN is set for this account.");
}
if (account.pinLocked) throw new Refusal(403, "app_pin_locked", "The app PIN is locked; set a new one.");
if (account.pinFailures + account.pinChecking >= PIN_ATTEMPTS) {
// The attempts left are held by checks still running.
throw new Refusal(429, "pin_busy", "The PIN is being checked; try again in a moment.",
{ retry_after_seconds: 1 });
}
account.pinChecking += 1; // the attempt, claimed before comparing
const generation = account.pinGeneration;
const salt = account.pinSalt;
const verifier = account.pinVerifier;
let right: boolean | null;
try {
await this.heldPinCheck();
right = same((await slowVerifier(this.pinSecret, session.account as string, pin as string, salt))[1], verifier);
} catch {
right = null; // the check could not run
}
// Settled in one step -- nothing awaited from here on -- against the PIN it compared: a PIN set
// meanwhile is left alone -- its count, its lock, its checks -- and a check that could not run
// gives its attempt back.
const current = account.pinGeneration === generation;
if (current) {
account.pinChecking -= 1;
if (right === true) {
account.pinFailures = 0;
} else if (right === false) {
account.pinFailures += 1;
account.pinLocked = account.pinFailures >= PIN_ATTEMPTS;
}
}
if (right === null) throw new Refusal(503, "pin_unavailable", "The app PIN cannot be checked now; try again.");
if (!current) {
// Neither a proof nor a count: the PIN it was right or wrong for is no longer the account's.
throw new Refusal(403, "app_pin_invalid", "The app PIN was changed while it was checked; enter it again.");
}
if (!right) {
if (account.pinLocked) throw new Refusal(403, "app_pin_locked", "The app PIN is locked; set a new one.");
throw new Refusal(403, "app_pin_invalid", "The app PIN is wrong.");
}
const expires = now() + PIN_PROOF_SECONDS;
const claims = canonical({
account: session.account as string, installation_id: session.installationId,
session_sha256: sha256Hex(request.bearer as string), request: target, expires_at: expires,
pin_generation: generation,
});
return {
app_pin_proof: `${b64urlEncode(claims)}.${b64urlEncode(hmac(this.pinProofKey, claims))}`,
expires_at: iso(expires),
};
}
requireAppPin(request: Request, session: Session, nonce: string): void {
let proof: JsonObject | null = null;
try {
const [encoded, mac] = (request.header("X-App-PIN-Proof") ?? "").split(".");
const claims = b64urlDecode(encoded);
if (same(b64urlDecode(mac), hmac(this.pinProofKey, claims))) proof = JSON.parse(claims.toString("utf8"));
} catch { /* not a proof of ours */ }
if (proof === null) throw new Refusal(403, "needs_app_pin", "This request needs the app PIN.");
const generation = (this.accounts.get(session.account as string) as Account).pinGeneration;
if (proof.pin_generation !== generation) {
throw new Refusal(403, "app_pin_not_accepted", "The app PIN was changed since this proof was made; enter it again.");
}
const expected = canonical({
account: session.account as string, installation_id: session.installationId,
session_sha256: sha256Hex(request.bearer as string),
request: {
method: request.method, path: request.path, query: request.query,
body_sha256: sha256Hex(request.body), nonce,
},
pin_generation: generation,
});
const { expires_at: expires, ...bound } = proof;
if (!isInt(expires) || expires < now() || !canonical(bound).equals(expected)) {
throw new Refusal(403, "app_pin_not_accepted", "The PIN's proof is for another request.");
}
}
// --- This sample's own account API, in which the plugin's proofs are used.
async signIn(request: Request, body: JsonObject): Promise<JsonObject> {
const [session] = this.authenticate(request, ["installation"]);
const handle = body.handle;
const password = body.password;
if (typeof handle !== "string" || !handle || typeof password !== "string" || !password) {
throw new Refusal(400, "bad_request", "A handle and a password are required.");
}
let account = this.accounts.get(handle);
if (account === undefined) {
const [salt, verifier] = await slowVerifier(this.passwordSecret, handle, password);
account = this.accounts.get(handle) ?? {
passwordSalt: salt, passwordVerifier: verifier, pinSalt: null, pinVerifier: null,
pinFailures: 0, pinLocked: false, pinChecking: 0, pinGeneration: 0,
};
this.accounts.set(handle, account);
}
await this.reauthenticate(account, handle, password);
const token = crypto.randomBytes(32).toString("base64url");
// An account session for this installation, which proved its key.
this.sessions.set(token, {
kind: "account", installationId: session.installationId, account: handle, expires: now() + SESSION_SECONDS,
});
return { account_session: token };
}
private async reauthenticate(account: Account, handle: string, password: unknown): Promise<void> {
const [, verifier] = await slowVerifier(this.passwordSecret, handle, String(password), account.passwordSalt);
if (!same(verifier, account.passwordVerifier)) {
throw new Refusal(401, "reauthentication_failed", "The password is wrong.");
}
}
demo(request: Request, needs: string[]): JsonObject {
const [session, installation, nonce] = this.authenticate(request, ["account"]);
if (needs.includes("scan")) this.requireOperationScan(request);
if (needs.includes("approval")) {
// The person's approval, as the request offers it: step-up, the app PIN, or both -- and each
// one offered must hold.
const stepUp = Boolean(request.header("X-Step-Up-Proof"));
const appPin = Boolean(request.header("X-App-PIN-Proof"));
if (!stepUp && !appPin) {
throw new Refusal(403, "needs_approval",
"This request needs the person's approval: step-up or the app PIN.");
}
if (stepUp) this.requireStepUp(request, session, installation, nonce);
if (appPin) this.requireAppPin(request, session, nonce);
}
return { done: request.path };
}
// --- Test controls, on only with --test-controls.
/** A PIN check waits here, its attempt claimed, while a test holds PIN checks; released with fail,
* its verifier fails. */
private async heldPinCheck(): Promise<void> {
const gate = this.pinGate;
if (gate === null) return;
this.pinWaiting += 1;
const fail = await gate.released;
this.pinWaiting -= 1;
if (fail) throw new Error("the verifier failed, for a test");
}
control(path: string, body: JsonObject): JsonObject {
if (path === "/test/expire-sessions") {
for (const session of this.sessions.values()) session.expires = now() - 1;
} else if (path === "/test/forget") {
const record = this.installations.get(body.installation_id as string);
if (record !== undefined) {
this.installations.delete(body.installation_id as string);
this.byThumbprint.delete(record.thumbprint);
}
} else if (path === "/test/refuse-next") {
this.pending.set(body.path as string, body);
} else if (path === "/test/judge") {
// The contract's judgment of a report under a plan of the test's own, as the report call would
// make it.
try {
this.checkShape(body.report as JsonObject);
return { judged: judge(body.report as JsonObject, body.required_probes as string[], this.definitions) };
} catch (error) {
if (!(error instanceof Refusal)) throw error;
return { judged: "refused" };
}
} else if (path === "/test/pin-checks") {
// Hold PIN checks once they claim their attempt, or release those waiting; the answer counts
// those waiting.
if (body.hold === true && this.pinGate === null) {
let release: (fail: boolean) => void = () => undefined;
const released = new Promise<boolean>((resolve) => { release = resolve; });
this.pinGate = { released, release };
} else if (body.hold === false && this.pinGate !== null) {
this.pinGate.release(body.fail === true);
this.pinGate = null;
}
return { ok: true, waiting: this.pinWaiting };
} else {
throw new Refusal(404, "no_such_path", "No such control.");
}
return { ok: true };
}
}
type Route = (backend: Backend, request: Request, body: JsonObject) => JsonObject | Promise<JsonObject>;
const ROUTES: Record<string, Route> = {
"/v1/installations/register": (b, r, body) => b.register(body),
"/v1/installations/challenge": (b, r, body) => b.keyChallenge(body),
"/v1/installations/verify": (b, r, body) => b.verify(body),
"/v1/integrity/challenge": (b, r, body) => b.scanChallenge(r, body),
"/v1/integrity/report": (b, r, body) => b.report(r, body),
"/v1/installations/stepup-key": (b, r, body) => b.reenrolStepUp(r, body),
"/v1/account/app-pin": (b, r, body) => b.setAppPin(r, body),
"/v1/account/app-pin/proof": (b, r, body) => b.proveAppPin(r, body),
"/v1/demo/sign-in": (b, r, body) => b.signIn(r, body),
"/v1/demo/echo": (b, r) => b.demo(r, []),
"/v1/demo/transfer": (b, r) => b.demo(r, ["scan"]),
"/v1/demo/sensitive": (b, r) => b.demo(r, ["approval"]),
};
function answer(response: http.ServerResponse, status: number, value: unknown, request?: Request,
failed = ""): void {
// One line per call: the method, the path, the status, any refusal's code and the check that
// failed; never a body or a header -- they hold PINs.
const error = (value as { error?: { code?: string } } | null)?.error;
console.log(`${request?.method ?? "-"} ${request?.path ?? "-"} ${status} ${error?.code ?? ""} ${failed}`.trimEnd());
const data = Buffer.from(JSON.stringify(value), "utf8");
response.writeHead(status, { "Content-Type": "application/json", "Content-Length": data.length });
response.end(data);
}
async function handle(backend: Backend, request: Request): Promise<[number, unknown, string?]> {
if (request.method === "GET") {
return request.path === "/health" ? [200, { ok: true }]
: [404, { error: { code: "no_such_path", message: "No such path." } }];
}
try {
let body: JsonObject;
try {
body = request.body.length ? JSON.parse(request.body.toString("utf8")) : {};
} catch {
throw new Refusal(400, "bad_request", "The body is not JSON.");
}
if (body === null || typeof body !== "object" || Array.isArray(body)) {
throw new Refusal(400, "bad_request", "The body is not a JSON object.");
}
const token = Buffer.from(request.header("X-Test-Token") ?? "");
if (request.path.startsWith("/test/") && backend.testToken !== null
&& same(token, Buffer.from(backend.testToken))) {
return [200, backend.control(request.path, body)];
}
const pending = backend.pending.get(request.path);
if (pending !== undefined) {
backend.pending.delete(request.path);
throw new Refusal((pending.status as number) ?? 400, pending.code as string, "Refused for a test.",
(pending.details as JsonObject) ?? undefined);
}
const route = ROUTES[request.path];
if (route === undefined) throw new Refusal(404, "no_such_path", "No such path.");
return [200, await route(backend, request, body)];
} catch (error) {
if (!(error instanceof Refusal)) throw error;
// Which check failed goes to the log, never to the phone, which is told what to do next
// (section 7): what gave a request away is what an attacker would fix.
const { check, at, ...details } = error.details ?? {};
const failed = Object.entries({ check, at }).filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${value}`).join(" ");
return [error.status, {
error: { code: error.code, message: error.message, ...(Object.keys(details).length ? { details } : {}) },
}, failed];
}
}
function main(): void {
const args = process.argv.slice(2);
const option = (name: string): string | undefined => {
const at = args.indexOf(name);
return at >= 0 ? args[at + 1] : undefined;
};
const schemaPath = option("--schema");
if (!schemaPath) {
console.error("usage: node dist/server.js --schema <contract>/report.schema.json [--port 8787] [--test-controls]");
process.exit(2);
}
const port = Number(option("--port") ?? 8787);
// Test controls answer only requests carrying X-Test-Token: $SAMPLE_TEST_TOKEN.
const testToken = args.includes("--test-controls") ? process.env.SAMPLE_TEST_TOKEN ?? "" : null;
if (testToken !== null && testToken.length < 16) {
console.error("--test-controls needs SAMPLE_TEST_TOKEN, 16 characters or more");
process.exit(2);
}
// The contract's own keywords (x-*) annotate; they constrain nothing.
const ajv = new Ajv2020({ strict: false });
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
const backend = new Backend(ajv.compile(schema), schema.$defs ?? {}, testToken);
const server = http.createServer((incoming, response) => {
const chunks: Buffer[] = [];
let size = 0;
incoming.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size <= 1 << 20) chunks.push(chunk);
});
incoming.on("end", () => {
if (size > 1 << 20) {
answer(response, 413, { error: { code: "too_large", message: "Too large." } });
return;
}
const request = new Request(incoming.method ?? "GET", incoming.url ?? "/", incoming.headers, Buffer.concat(chunks));
handle(backend, request).then(([status, value, failed]) => answer(response, status, value, request, failed), () =>
answer(response, 500, { error: { code: "server_error", message: "The sample failed." } }, request));
});
});
// Logs one line per call, never a body or a header: they hold PINs.
server.listen(port, "127.0.0.1", () => console.log(`listening on http://127.0.0.1:${port}`));
}
main();
C# · csharp/Program.cs · 1,552 lines
// A sample backend for the necessary plugin, written from the integration
// guide (doc/integration_guide.md) and the measurement contract alone.
//
// Sample code: one process, everything in memory, its secrets made at start.
// A real backend keeps its records in a database and its secrets in a vault.
// The section numbers below are the guide's. Run:
//
// dotnet bin/Release/net10.0/SampleBackend.dll --port 8787 --schema <contract>/report.schema.json
//
// ASP.NET Core's minimal API and .NET's own cryptography, and JsonSchema.Net
// for the contract's schema.
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Json.Schema;
using Microsoft.AspNetCore.Http.Features;
namespace Necessary.SampleBackend;
/// <summary>An answer of {"error": {"code", "message", "details"}} (section 4).</summary>
public sealed class Refusal(int status, string code, string message, JsonObject? details = null)
: Exception(message)
{
public int Status { get; } = status;
public string Code { get; } = code;
public JsonObject? Details { get; } = details;
}
public static class Wire
{
// Binary values travel as base64url without padding; hashes are hex SHA-256.
private static readonly Regex Base64Url = new("^[A-Za-z0-9_-]*$", RegexOptions.Compiled);
public static byte[] Decode(string? text)
{
if (text is null || !Base64Url.IsMatch(text))
{
throw new FormatException("not base64url text");
}
return System.Buffers.Text.Base64Url.DecodeFromChars(text);
}
public static string Encode(byte[] raw) => System.Buffers.Text.Base64Url.EncodeToString(raw);
public static string Sha256Hex(byte[] raw) => Convert.ToHexStringLower(SHA256.HashData(raw));
public static string Sha256Hex(string text) => Sha256Hex(Encoding.UTF8.GetBytes(text));
public static byte[] Hmac(byte[] key, byte[] data) => HMACSHA256.HashData(key, data);
public static long Now() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public static string Iso(long seconds) =>
DateTimeOffset.FromUnixTimeSeconds(seconds).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'");
/// <summary>JSON with its members sorted, compact: the same bytes for the same value.</summary>
public static byte[] Canonical(JsonNode? value) => Encoding.UTF8.GetBytes(Sorted(value)?.ToJsonString() ?? "null");
private static JsonNode? Sorted(JsonNode? value) => value switch
{
JsonObject o => new JsonObject(o.OrderBy(p => p.Key, StringComparer.Ordinal)
.Select(p => KeyValuePair.Create(p.Key, Sorted(p.Value)))),
JsonArray a => new JsonArray(a.Select(Sorted).ToArray()),
null => null,
_ => value.DeepClone(),
};
public static bool Same(byte[] a, byte[] b) => CryptographicOperations.FixedTimeEquals(a, b);
public static string? Text(JsonNode? node) =>
node is JsonValue v && v.TryGetValue(out string? s) ? s : null;
public static long? Integer(JsonNode? node) =>
node is JsonValue v && v.GetValueKind() == JsonValueKind.Number && v.TryGetValue(out long n) ? n : null;
}
// --- The contract's rules (RULES.md): what a report's measurements are worth before any business
// logic weighs them -- never a score. A report is judged over the measurements its challenge required:
// complete, partial (part of the app's code compared), incomplete (something not measured) or invalid
// (figures that cannot all be true).
public static class Contract
{
public static readonly string[] Qualities = ["complete", "partial", "incomplete", "invalid"]; // best to worst
// Where code is always mapped, so that a comparison there finds some (RULES.md 4): on an iPhone only
// the app's own, the rest being unreadable.
private static readonly Dictionary<string, string[]> Regions = new()
{
["android"] = ["core", "ext", "app"],
["ios"] = ["app"],
};
private static readonly string[] IosCoverage =
["app_images_expected", "app_images_compared", "app_images_unreadable", "app_images_skipped",
"app_expected_bytes", "app_compared_bytes", "app_unreadable_bytes", "app_skipped_bytes",
"app_complete", "main_executable_compared"];
private static bool Absent(JsonNode? node) => node is null || node.GetValueKind() == JsonValueKind.Null;
private static bool? Flag(JsonNode? node) =>
node is JsonValue v && v.GetValueKind() is JsonValueKind.True or JsonValueKind.False ? v.GetValue<bool>() : null;
private static IEnumerable<string> Listed(JsonObject definition, string key) =>
definition[key] is JsonArray fields ? fields.Select(f => Wire.Text(f) ?? "") : [];
/// <summary>The contract's judgment of a report whose shape the schema accepted. Split APKs out of
/// order (RULES.md 6, the one rule a schema cannot state) are refused rather than read.</summary>
public static string Judge(JsonObject report, IEnumerable<string> required, JsonObject definitions)
{
var platform = Wire.Text(report["platform"]) ?? "";
var results = report["probe_results"] as JsonObject ?? new JsonObject();
if ((results["app_identity"] as JsonObject)?["split_apks"] is JsonArray splits)
{
var names = splits.Select(entry => Wire.Text(entry?["name"]) ?? "").ToArray();
// Sorted by name, ordinally, none twice.
if (names.Where((name, at) => at > 0 && string.CompareOrdinal(names[at - 1], name) >= 0).Any())
{
throw new Refusal(400, "report_not_accepted", "The report is not the contract's.",
new JsonObject { ["check"] = "split_apks" });
}
}
var found = new List<string> { "complete" };
foreach (var name in required)
{
var definition = definitions[$"{platform}.{name}"] as JsonObject ?? new JsonObject();
if (results[name] is not JsonObject value || Wire.Text(value["status"]) != "ok")
{
found.Add("incomplete"); // failed or unsupported: not measured (2)
continue;
}
// ok is a claim: a measurement is complete with every field it lists, and with nothing in
// unread (2).
if (Listed(definition, "x-complete-when-ok").Any(field => Absent(value[field]))
|| (value["unread"] is JsonObject unread && unread.Count > 0))
{
found.Add("incomplete");
}
// Failed reads, not clean values (7).
if ((name is "runtime_maps" or "exec_mappings" && Wire.Integer(value["maps_lines"]) == 0)
|| (name == "instrumentation_threads" && Wire.Integer(value["thread_count"]) == 0))
{
found.Add("incomplete");
}
if (name == "exec_mappings" && WxSizesWithoutMappings(value))
{
found.Add("invalid");
}
if (name == "code_integrity")
{
found.Add(CodeComparison(platform, value, definition));
}
}
// Contradictions across measurements (7).
if (results["screen_capture"] is JsonObject capture && Wire.Text(capture["status"]) == "ok"
&& Wire.Text(capture["recording_status"]) == "unsupported"
&& (platform == "ios" || (results["device"] is JsonObject device && Wire.Integer(device["sdk_int"]) >= 35)))
{
found.Add("invalid"); // recording unsupported where it is measured
}
if (results["location"] is JsonObject location && results["mock_location"] is JsonObject mock
&& Wire.Text(location["status"]) == "ok" && Wire.Text(mock["status"]) == "ok"
&& Wire.Integer(location["fix_time"]) is { } locationTime && Wire.Integer(mock["fix_time"]) is { } mockTime
&& locationTime != mockTime)
{
found.Add("invalid"); // two fixes presented as one (5)
}
return found.MaxBy(quality => Array.IndexOf(Qualities, quality))!;
}
/// <summary>No writable-and-executable mapping, with the sizes of such mappings listed (RULES.md 7).</summary>
private static bool WxSizesWithoutMappings(JsonObject maps)
{
var classes = 0;
foreach (var part in (Wire.Text(maps["wx_size_classes"]) ?? "").Split(','))
{
if (string.IsNullOrWhiteSpace(part))
{
continue;
}
var at = part.IndexOf(':');
var size = (at < 0 ? part : part[..at]).Trim();
var count = (at < 0 ? "" : part[(at + 1)..]).Trim();
if (size.Length == 0 || count.Length == 0 || !size.All(char.IsAsciiDigit) || !count.All(char.IsAsciiDigit))
{
classes = 0; // not a list of sizes
break;
}
classes += 1;
}
var extremes = (Wire.Integer(maps["wx_smallest_bytes"]) ?? 0) != 0 || (Wire.Integer(maps["wx_largest_bytes"]) ?? 0) != 0;
return Wire.Integer(maps["wx_mappings"]) == 0 && (Wire.Integer(maps["wx_bytes"]) ?? 0) == 0
&& (classes > 0 || extremes);
}
/// <summary>The comparison of code in memory with its files (RULES.md 4): invalid when its figures
/// do not add up; incomplete when it did not run, lacks a figure, or compared nothing where code
/// always is; partial when it compared part of the code.</summary>
private static string CodeComparison(string platform, JsonObject code, JsonObject definition)
{
if (!(platform == "android" ? AndroidCoverageAddsUp(code) : IosCoverageAddsUp(code)))
{
return "invalid";
}
if (Flag(code["checked"]) != true || Listed(definition, "x-complete-when-checked").Any(field => Absent(code[field])))
{
return "incomplete";
}
if (Regions[platform].Any(region => Wire.Integer(code[$"{region}_compared_bytes"]) == 0))
{
return "incomplete";
}
if (platform == "ios")
{
return Flag(code["app_complete"]) == true ? "complete" : "partial";
}
var shortOf = Regions["android"].Any(region =>
Wire.Integer(code[$"{region}_compared_bytes"]) < Wire.Integer(code[$"{region}_expected_bytes"]));
return shortOf || (Wire.Integer(code["protect_restore_failures"]) ?? 0) > 0 ? "partial" : "complete";
}
/// <summary>In each region the expected bytes are the compared, skipped and unreadable ones together,
/// and <region>_complete is true exactly when some code was expected and all of it was
/// compared.</summary>
private static bool AndroidCoverageAddsUp(JsonObject code)
{
foreach (var region in Regions["android"])
{
if (Absent(code[$"{region}_expected_bytes"]))
{
continue;
}
var expected = Wire.Integer(code[$"{region}_expected_bytes"]);
var parts = new[] { "compared", "skipped", "unreadable" }.Select(kind => Wire.Integer(code[$"{region}_{kind}_bytes"])).ToArray();
if (expected is null || parts.Any(part => part is null) || parts.Sum() != expected)
{
return false;
}
if (Flag(code[$"{region}_complete"]) is { } complete && complete != (expected > 0 && parts[0] == expected))
{
return false;
}
}
return true;
}
/// <summary>Images and bytes both add up -- expected = compared + unreadable + skipped -- and
/// app_complete is true exactly when every image was compared, the main executable among them; bytes
/// are compared exactly when images are, and each image not compared is named. A collector that
/// reports only part of it is judged by its fields instead.</summary>
private static bool IosCoverageAddsUp(JsonObject code)
{
if (IosCoverage.Any(field => Absent(code[field])))
{
return true;
}
var figures = IosCoverage.Take(8).Select(field => Wire.Integer(code[field])).ToArray();
if (figures.Any(figure => figure is null))
{
return false;
}
long expected = figures[0]!.Value, compared = figures[1]!.Value, unreadable = figures[2]!.Value, skipped = figures[3]!.Value;
long expectedBytes = figures[4]!.Value, comparedBytes = figures[5]!.Value, unreadableBytes = figures[6]!.Value,
skippedBytes = figures[7]!.Value;
var complete = expected > 0 && compared == expected;
var main = Flag(code["main_executable_compared"]);
var named = code["app_images_not_compared"];
var namedCount = named is JsonObject list ? list.Count : 0;
var checkedFlag = Flag(code["checked"]);
return !(
expected != compared + unreadable + skipped
|| expectedBytes != comparedBytes + unreadableBytes + skippedBytes
|| Flag(code["app_complete"]) != complete
|| (complete && (unreadableBytes > 0 || skippedBytes > 0))
|| (unreadableBytes > 0 && unreadable == 0) || (skippedBytes > 0 && skipped == 0)
|| (comparedBytes > 0) != (compared > 0)
|| (!Absent(code["checked"]) && checkedFlag != (compared > 0))
|| (main == true && compared == 0) || (complete && main != true)
|| (!Absent(named) && (namedCount > unreadable + skipped || (unreadable + skipped > 0 && namedCount == 0))));
}
// Your business logic decides (the guide, section 7), and this is where it goes. This sample decides
// on the contract's judgment alone and scores nothing: a complete report lets the action go on, and
// so -- by this sample's choice -- does a partial one, whose comparison found nothing in the code it
// could read; an incomplete one asks for a scan again, since a failed read is no proof of an attack;
// and a report whose figures contradict each other is held for review. Only a report allowed here
// approves a scan made for one action.
private static readonly Dictionary<string, string> Next = new()
{
["complete"] = "allow", ["partial"] = "allow", ["incomplete"] = "scan_again", ["invalid"] = "review",
};
/// <summary>What to do next, for this report: allow, scan_again or review.</summary>
public static string Decide(string judgment, JsonObject report) => Next[judgment];
}
/// <summary>A step-up key; a re-enrolled one carries the account that re-enrolled it.</summary>
public sealed record StepUpKey(JsonObject Jwk, string Factor, string Mode, long WindowSeconds, string? Account = null);
public sealed record Installation(string Thumbprint, JsonObject Jwk, string Platform, string? Hint,
JsonNode? KeySecurity, StepUpKey? StepUp);
public sealed class Session
{
public required string Kind { get; init; }
public required string InstallationId { get; init; }
public string? Account { get; init; }
public long Expires { get; set; }
}
public sealed class Account
{
public required byte[] PasswordSalt { get; init; }
public required byte[] PasswordVerifier { get; init; }
public byte[]? PinSalt { get; set; }
public byte[]? PinVerifier { get; set; }
public int PinFailures { get; set; }
public bool PinLocked { get; set; }
public int PinChecking { get; set; }
// Each PIN set is a new generation; a check settles against the one it compared.
public int PinGeneration { get; set; }
}
public sealed class ScanChallenge
{
public required string InstallationId { get; init; }
public required string Platform { get; init; }
public required string NonceSha256 { get; init; }
public required string[] Required { get; init; }
public required long Expires { get; init; }
public string? OperationSha256 { get; init; }
public string? Session { get; init; }
public bool Used { get; set; }
}
public sealed class StoredReport
{
public required string InstallationId { get; init; }
public required long At { get; init; }
public string? OperationSha256 { get; init; }
public string? Session { get; init; }
public required string Judgment { get; init; }
public required string Next { get; init; }
public bool Used { get; set; }
}
public sealed class Call(string method, string path, string query, IHeaderDictionary headers, byte[] body)
{
public string Method { get; } = method;
public string Path { get; } = path;
public string Query { get; } = query;
public byte[] Body { get; } = body;
public string? Bearer { get; } =
headers.Authorization.ToString() is var auth && auth.StartsWith("Bearer ", StringComparison.Ordinal)
? auth[7..] : null;
public string? Header(string name) => headers.TryGetValue(name, out var value) ? value.ToString() : null;
}
public sealed class Backend(JsonSchema schema, JsonObject definitions, string? testToken)
{
private const long SessionSeconds = 600; // sessions: ten minutes (section 1)
private const long KeyChallengeSeconds = 120; // key challenges: two minutes (section 1)
private const long ScanChallengeSeconds = 60; // scan challenges: 60 seconds (section 2)
private const long ClockWindowSeconds = 120; // request and step-up proofs (section 3)
private const long ReportClockSeconds = 90; // a report's collected_at (section 2)
private const long NonceKeepSeconds = 600; // request-proof nonces, longer than the window
private const long OperationScanSeconds = 120; // a scan approves its request for a couple of minutes
private const long PinProofSeconds = 120; // an app PIN proof: about two minutes
private const int PinLength = 6; // this sample's choice of PIN length
private const int PinAttempts = 5; // this sample's limit of wrong PINs
private const int MaxReportBytes = 65536; // the contract's limit
private const int VerifierRounds = 600_000; // PBKDF2-HMAC-SHA256, a slow verifier
private static readonly Dictionary<string, int> MinCollector = new() { ["android"] = 8, ["ios"] = 5 };
// What this sample asks for in every scan: every measurement that would catch tampering, the
// hooks, gadgets and changes to the app's code in memory among them. One left to chance is one a
// tampered app waits out, scanning again until it is not drawn. Only what weighs little varies.
private static readonly Dictionary<string, string[]> ReliedOn = new()
{
["android"] = ["app_identity", "debug_state", "tracer", "root_files", "root_shell", "system_properties",
"selinux", "mounts", "developer_settings",
// Hooks and gadgets by name, by port, by thread and by memory layout; and the code in
// memory compared with its file.
"runtime_maps", "frida_ports", "instrumentation_threads", "exec_mappings", "code_integrity"],
["ios"] = ["app_identity", "code_signing", "debugger", "jailbreak_files", "sandbox",
// Injected libraries, and the app's code in memory compared with its file -- asked for,
// so that a report without it is refused.
"dyld_images", "environment", "code_integrity"],
};
private static readonly Dictionary<string, string[]> Varied = new()
{
["android"] = ["emulator"],
["ios"] = ["simulator"],
};
// The personal measurements (the guide, section 8): this sample asks for every one in every scan,
// so that the example app can show each one it enables. A production backend asks only for those
// it uses.
private static readonly string[] Personal =
["local_addresses", "accessibility", "notification_access", "other_access", "screen_capture",
"location", "mock_location"];
private static readonly Regex Uuid =
new("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", RegexOptions.IgnoreCase);
private static readonly Regex Hex64 = new("^[0-9a-f]{64}$");
// Secrets made at start: a restart forgets every session and challenge.
private readonly byte[] _challengeKey = RandomNumberGenerator.GetBytes(32);
private readonly byte[] _hintKey = RandomNumberGenerator.GetBytes(32);
private readonly byte[] _pinSecret = RandomNumberGenerator.GetBytes(32);
private readonly byte[] _passwordSecret = RandomNumberGenerator.GetBytes(32);
private readonly byte[] _pinProofKey = RandomNumberGenerator.GetBytes(32);
private readonly Lock _lock = new();
private readonly Dictionary<string, Installation> _installations = new();
private readonly Dictionary<string, string> _byThumbprint = new();
private readonly HashSet<string> _usedKeyChallenges = new();
private readonly Dictionary<string, Session> _sessions = new();
private readonly Dictionary<string, long> _nonces = new();
private readonly Dictionary<string, ScanChallenge> _scanChallenges = new();
private readonly Dictionary<string, StoredReport> _reports = new();
private readonly Dictionary<string, Account> _accounts = new();
private readonly Dictionary<string, JsonObject> _pending = new(); // test control
// Test control: while set, PIN checks wait on it; released with fail, their verifier fails.
private TaskCompletionSource<bool>? _pinGate;
private int _pinWaiting; // test control: the PIN checks waiting now
/// <summary>Test controls answer only requests carrying this token.</summary>
public bool TestTokenMatches(string? given) =>
testToken is not null && given is not null
&& Wire.Same(Encoding.UTF8.GetBytes(given), Encoding.UTF8.GetBytes(testToken));
// --- Section 1: the installation key.
private static ECDsa PublicKey(JsonNode? jwk)
{
if (jwk is not JsonObject key || Wire.Text(key["kty"]) != "EC" || Wire.Text(key["crv"]) != "P-256")
{
throw new FormatException("only EC P-256 keys are accepted");
}
var x = Wire.Decode(Wire.Text(key["x"]));
var y = Wire.Decode(Wire.Text(key["y"]));
if (x.Length != 32 || y.Length != 32)
{
throw new FormatException("P-256 coordinates are 32 bytes each");
}
// Throws for a point that is not on the curve.
return ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = x, Y = y } });
}
private static string Thumbprint(JsonObject jwk) => Wire.Sha256Hex(Wire.Canonical(new JsonObject
{
["crv"] = "P-256", ["kty"] = "EC",
["x"] = Wire.Encode(Wire.Decode(Wire.Text(jwk["x"]))), ["y"] = Wire.Encode(Wire.Decode(Wire.Text(jwk["y"]))),
}));
/// <summary>Whether the signature verifies over these bytes, as they were signed.</summary>
private static bool Verifies(JsonObject jwk, byte[] payload, string? signature)
{
try
{
using var key = PublicKey(jwk);
return key.VerifyData(payload, Wire.Decode(signature), HashAlgorithmName.SHA256,
DSASignatureFormat.Rfc3279DerSequence);
}
catch (Exception e) when (e is FormatException or CryptographicException)
{
return false;
}
}
/// <summary>PBKDF2 over an HMAC of the subject and the value, keyed by a secret the
/// records never hold: a copy of the records checks nothing.</summary>
private static (byte[] Salt, byte[] Verifier) SlowVerifier(byte[] secret, string subject, string value,
byte[]? salt = null)
{
salt ??= RandomNumberGenerator.GetBytes(16);
var keyed = Wire.Hmac(secret, Encoding.UTF8.GetBytes($"{subject}:{value}"));
return (salt, Rfc2898DeriveBytes.Pbkdf2(keyed, salt, VerifierRounds, HashAlgorithmName.SHA256, 32));
}
private static Task<(byte[] Salt, byte[] Verifier)> SlowVerifierAsync(byte[] secret, string subject,
string value, byte[]? salt = null) => Task.Run(() => SlowVerifier(secret, subject, value, salt));
// --- Section 1: registration.
public JsonObject Register(JsonObject body)
{
var installationId = Wire.Text(body["installation_id"]);
var platform = Wire.Text(body["platform"]);
JsonObject jwk;
try
{
if (installationId is null || !Uuid.IsMatch(installationId))
{
throw new FormatException("not a UUID");
}
PublicKey(body["public_key"]).Dispose();
jwk = (JsonObject)body["public_key"]!;
}
catch (Exception e) when (e is FormatException or CryptographicException or InvalidCastException)
{
throw new Refusal(400, "bad_request", "A UUID and an EC P-256 public key are required.",
new JsonObject { ["field"] = "public_key" });
}
if (platform is not ("android" or "ios"))
{
throw new Refusal(400, "bad_request", "The platform is android or ios.", new JsonObject { ["field"] = "platform" });
}
var hint = body["reinstall_hint"];
if (hint is not null && !(hint is JsonObject h && Wire.Text(h["kind"]) is "android_id_sha256" or "idfv_sha256"
&& Wire.Text(hint["value"]) is { } value && Hex64.IsMatch(value)))
{
throw new Refusal(400, "bad_request", "The reinstall hint is malformed.", new JsonObject { ["field"] = "reinstall_hint" });
}
var key = Thumbprint(jwk);
var stepUp = StepUpKeyOf(body, key);
lock (_lock)
{
if (_byThumbprint.TryGetValue(key, out var held))
{
// The thumbprint is the identity: the same key is the same installation,
// whatever ID it proposes. A step-up key offered now is not taken -- only
// re-enrolment replaces one -- so the answer says whether it is the one held,
// and the app asks for re-enrolment rather than sign with a key this backend lacks.
var bound = _installations[held].StepUp;
return new JsonObject
{
["installation_id"] = held, ["stepup_key_registered"] = bound is not null,
["stepup_key_matches"] = stepUp is null ? null
: bound is not null && Thumbprint(bound.Jwk) == Thumbprint(stepUp.Jwk),
};
}
if (_installations.ContainsKey(installationId))
{
throw new Refusal(409, "installation_id_taken", "This installation ID belongs to another key.");
}
_installations[installationId] = new Installation(key, (JsonObject)jwk.DeepClone(), platform,
// Keyed before it is kept: what the app says, never proof.
hint is null ? null : Convert.ToHexStringLower(Wire.Hmac(_hintKey, Encoding.ASCII.GetBytes(Wire.Text(hint["value"])!))),
// Recorded, never relied on: the phone cannot prove it.
body["key_security"]?.DeepClone(), stepUp);
_byThumbprint[key] = installationId;
}
return new JsonObject { ["installation_id"] = installationId, ["stepup_key_registered"] = stepUp is not null };
}
/// <summary>Section 3, step-up: the key and how the phone says it is unlocked.</summary>
private static StepUpKey? StepUpKeyOf(JsonObject body, string installationThumbprint)
{
if (body["stepup_public_key"] is not { } node)
{
return null;
}
try
{
PublicKey(node).Dispose();
}
catch (Exception e) when (e is FormatException or CryptographicException)
{
throw new Refusal(400, "bad_request", "The step-up key is not an EC P-256 key.",
new JsonObject { ["field"] = "stepup_public_key" });
}
var jwk = (JsonObject)node;
if (Thumbprint(jwk) == installationThumbprint)
{
throw new Refusal(400, "bad_request", "The step-up key must not be the installation key.",
new JsonObject { ["field"] = "stepup_public_key" });
}
var auth = body["stepup_key_auth"] as JsonObject;
var factor = Wire.Text(auth?["factor"]);
var mode = Wire.Text(auth?["mode"]);
var window = Wire.Integer(auth?["window_seconds"]);
if (factor is not ("passcode" or "biometric") || window is null
|| !((mode == "per_use" && window == 0) || (mode == "windowed" && window is >= 1 and <= 3600)))
{
throw new Refusal(400, "bad_request", "stepup_key_auth is malformed.", new JsonObject { ["field"] = "stepup_key_auth" });
}
return new StepUpKey((JsonObject)jwk.DeepClone(), factor, mode!, window.Value);
}
// --- Section 1: proof of possession, with a challenge that stores nothing.
public JsonObject KeyChallenge(JsonObject body)
{
var installationId = Wire.Text(body["installation_id"]);
lock (_lock)
{
if (installationId is null || !_installations.ContainsKey(installationId))
{
throw new Refusal(404, "installation_not_found", "No such installation.");
}
}
var expires = Wire.Now() + KeyChallengeSeconds;
var claims = new JsonObject
{
["challenge_id"] = Guid.NewGuid().ToString(), ["installation_id"] = installationId,
["purpose"] = "prove_installation", ["nonce"] = Wire.Encode(RandomNumberGenerator.GetBytes(32)),
["expires_at"] = expires,
};
claims["mac"] = Wire.Encode(Wire.Hmac(_challengeKey, Wire.Canonical(claims)));
return new JsonObject
{
["challenge_id"] = Wire.Text(claims["challenge_id"]), ["payload"] = Wire.Encode(Wire.Canonical(claims)),
["expires_at"] = Wire.Iso(expires),
};
}
public JsonObject Verify(JsonObject body)
{
var installationId = Wire.Text(body["installation_id"]);
var challengeId = Wire.Text(body["challenge_id"]);
byte[] payload;
JsonObject claims;
byte[] mac;
try
{
payload = Wire.Decode(Wire.Text(body["payload"]));
claims = JsonNode.Parse(payload) as JsonObject ?? throw new FormatException("not an object");
mac = Wire.Decode(Wire.Text(claims["mac"]));
claims.Remove("mac");
}
catch (Exception e) when (e is FormatException or JsonException)
{
throw new Refusal(401, "proof_not_accepted", "This is not a challenge of ours.");
}
if (!Wire.Same(mac, Wire.Hmac(_challengeKey, Wire.Canonical(claims)))
|| Wire.Text(claims["challenge_id"]) != challengeId || Wire.Text(claims["installation_id"]) != installationId
|| Wire.Text(claims["purpose"]) != "prove_installation")
{
throw new Refusal(401, "proof_not_accepted", "This is not a challenge of ours.");
}
if (Wire.Integer(claims["expires_at"]) < Wire.Now())
{
throw new Refusal(401, "challenge_expired", "The challenge has expired.");
}
Installation? installation;
lock (_lock)
{
_installations.TryGetValue(installationId!, out installation);
}
if (installation is null)
{
throw new Refusal(404, "installation_not_found", "No such installation.");
}
if (!Verifies(installation.Jwk, payload, Wire.Text(body["signature"])))
{
throw new Refusal(401, "invalid_installation_signature", "The signature does not verify.");
}
var token = Wire.Encode(RandomNumberGenerator.GetBytes(32));
lock (_lock)
{
// Consumed only after the signature verifies, atomically.
if (!_usedKeyChallenges.Add(challengeId!))
{
throw new Refusal(401, "challenge_used", "The challenge was answered already.");
}
_sessions[token] = new Session { Kind = "installation", InstallationId = installationId!, Expires = Wire.Now() + SessionSeconds };
}
return new JsonObject { ["device_token"] = token, ["expires_at"] = Wire.Iso(Wire.Now() + SessionSeconds) };
}
// --- Section 3: every request carries a request proof.
public (Session Session, Installation Installation, string Nonce) Authenticate(Call call, params string[] kinds)
{
Session? session;
Installation? installation = null;
lock (_lock)
{
_sessions.TryGetValue(call.Bearer ?? "", out session);
if (session is not null)
{
_installations.TryGetValue(session.InstallationId, out installation);
}
}
if (session is null || !kinds.Contains(session.Kind))
{
throw new Refusal(401, "not_signed_in", "This request needs a session of ours.");
}
if (session.Expires < Wire.Now())
{
throw new Refusal(401, "expired_token", "The session has expired.");
}
if (installation is null)
{
throw new Refusal(404, "installation_not_found", "No such installation.");
}
byte[] raw;
JsonObject proof;
byte[] nonce;
try
{
raw = Wire.Decode(call.Header("X-Access-Proof"));
proof = JsonNode.Parse(raw) as JsonObject ?? throw new FormatException("not an object");
nonce = Wire.Decode(Wire.Text(proof["nonce"]));
}
catch (Exception e) when (e is FormatException or JsonException)
{
throw new Refusal(401, "proof_not_accepted", "The request proof is missing or malformed.");
}
var expected = new (string Field, JsonNode Value)[]
{
("access_token_sha256", Wire.Sha256Hex(call.Bearer!)), ("body_sha256", Wire.Sha256Hex(call.Body)),
("installation_id", session.InstallationId), ("method", call.Method), ("path", call.Path),
("query", call.Query), ("version", 2),
};
foreach (var (field, value) in expected)
{
if (!JsonNode.DeepEquals(proof[field], value))
{
throw new Refusal(401, "proof_not_accepted", "The request proof is for another request.",
new JsonObject { ["check"] = field });
}
}
var timestamp = Wire.Integer(proof["timestamp"]);
if (timestamp is null || Math.Abs(Wire.Now() - timestamp.Value) > ClockWindowSeconds || nonce.Length != 32)
{
throw new Refusal(401, "proof_not_accepted", "The request proof is out of time.",
new JsonObject { ["check"] = "timestamp" });
}
if (!Verifies(installation.Jwk, raw, call.Header("X-Access-Signature")))
{
throw new Refusal(401, "invalid_installation_signature", "The signature does not verify.");
}
var key = Wire.Sha256Hex(nonce);
lock (_lock)
{
// Stored only after the signature verifies, under a unique key.
foreach (var kept in _nonces.Where(n => n.Value < Wire.Now()).Select(n => n.Key).ToList())
{
_nonces.Remove(kept);
}
if (!_nonces.TryAdd(key, Wire.Now() + NonceKeepSeconds))
{
throw new Refusal(401, "proof_reused", "This request was sent already.");
}
}
return (session, installation, Wire.Text(proof["nonce"])!);
}
// --- Section 2: the scan.
private static bool OperationValid(JsonNode? operation) =>
operation is JsonObject op
&& op.Select(p => p.Key).Order(StringComparer.Ordinal).SequenceEqual(["body_sha256", "method", "path", "query"])
&& Wire.Text(op["method"]) is "GET" or "POST" or "PUT" or "PATCH" or "DELETE"
&& Wire.Text(op["path"]) is { } path && path.StartsWith('/')
&& Wire.Text(op["query"]) is not null
&& Wire.Text(op["body_sha256"]) is { } digest && Hex64.IsMatch(digest);
public JsonObject ScanChallengeFor(Call call, JsonObject body)
{
var (session, installation, _) = Authenticate(call, "installation", "account");
var operation = body["operation"];
string? digest = null;
if (operation is not null)
{
// A scan for one action is the account's, never the installation's.
if (session.Kind != "account" || !OperationValid(operation))
{
throw new Refusal(400, "invalid_operation", "An operation is named only under an account session, whole.");
}
digest = Wire.Sha256Hex(Wire.Canonical(operation));
}
var platform = installation.Platform;
// Each of the others on about half the scans, unpredictably.
var required = ReliedOn[platform]
.Concat(Varied[platform].Where(_ => RandomNumberGenerator.GetInt32(2) == 1)).ToArray();
var nonce = RandomNumberGenerator.GetBytes(32);
var challengeId = Guid.NewGuid().ToString();
var expires = Wire.Now() + ScanChallengeSeconds;
lock (_lock)
{
_scanChallenges[challengeId] = new ScanChallenge
{
InstallationId = session.InstallationId, Platform = platform, NonceSha256 = Wire.Sha256Hex(nonce),
Required = required, Expires = expires, OperationSha256 = digest,
Session = digest is null ? null : call.Bearer,
};
}
return new JsonObject
{
["challenge_id"] = challengeId, ["nonce"] = Wire.Encode(nonce), ["platform"] = platform,
["required_probes"] = new JsonArray(required.Select(p => (JsonNode)p).ToArray()),
["optional_probes"] = new JsonArray(Personal.Select(p => (JsonNode)p).ToArray()),
["expires_at"] = Wire.Iso(expires),
};
}
public JsonObject Report(Call call, JsonObject body)
{
var (session, installation, _) = Authenticate(call, "installation", "account");
// 1. Decoded, at most the contract's size, a JSON object of version 1.
byte[] payload;
JsonObject report;
try
{
payload = Wire.Decode(Wire.Text(body["report_payload"]));
if (payload.Length > MaxReportBytes)
{
throw new FormatException("too large");
}
report = JsonNode.Parse(payload) as JsonObject ?? throw new FormatException("version");
if (Wire.Integer(report["version"]) != 1)
{
throw new FormatException("version");
}
}
catch (Exception e) when (e is FormatException or JsonException)
{
throw new Refusal(400, "report_not_accepted", "The report is not one we read.",
new JsonObject { ["check"] = e is JsonException ? "json" : e.Message });
}
// 2. Its installation is the session's.
if (Wire.Text(report["installation_id"]) != session.InstallationId)
{
throw new Refusal(400, "report_not_accepted", "The report names another installation.",
new JsonObject { ["check"] = "installation_id" });
}
ScanChallenge? challenge;
lock (_lock)
{
// 3. Its challenge, held while it is checked: this installation's, for its platform.
_scanChallenges.TryGetValue(Wire.Text(report["challenge_id"]) ?? "", out challenge);
if (challenge is null || challenge.InstallationId != session.InstallationId
|| Wire.Text(report["platform"]) != challenge.Platform)
{
throw new Refusal(400, "report_not_accepted", "The report answers no challenge of ours.",
new JsonObject { ["check"] = "challenge_id" });
}
// 4. Unused and in time.
if (challenge.Used)
{
throw new Refusal(401, "integrity_challenge_replay", "The challenge was answered already.");
}
if (challenge.Expires < Wire.Now())
{
throw new Refusal(401, "integrity_challenge_expired", "The challenge has expired.");
}
// 5. The nonce it was given.
byte[] nonce;
try
{
nonce = Wire.Decode(Wire.Text(report["challenge_nonce"]));
}
catch (FormatException)
{
nonce = [];
}
if (!Wire.Same(Encoding.ASCII.GetBytes(Wire.Sha256Hex(nonce)), Encoding.ASCII.GetBytes(challenge.NonceSha256)))
{
throw new Refusal(400, "report_not_accepted", "The report carries another nonce.",
new JsonObject { ["check"] = "challenge_nonce" });
}
// 6. The phone's clock.
var collected = Wire.Integer(report["collected_at"]);
if (collected is null || Math.Abs(Wire.Now() - collected.Value) > ReportClockSeconds)
{
throw new Refusal(400, "integrity_report_stale", "The phone's clock is far off.");
}
// 7. A collector this backend reads.
var version = Wire.Integer(report["collector_version"]);
if (version is null || version < MinCollector[challenge.Platform])
{
throw new Refusal(400, "report_not_accepted", "This collector is too old.",
new JsonObject { ["check"] = "collector_version" });
}
// 8. Every measurement asked for.
var results = report["probe_results"] as JsonObject;
var missing = challenge.Required.Where(p => results is null || !results.ContainsKey(p)).ToArray();
if (missing.Length > 0)
{
throw new Refusal(400, "integrity_probe_missing", "A measurement asked for is missing.",
new JsonObject { ["missing_probes"] = new JsonArray(missing.Select(p => (JsonNode)p).ToArray()) });
}
// 9. The signature, over the decoded bytes.
if (!Verifies(installation.Jwk, payload, Wire.Text(body["report_signature"])))
{
throw new Refusal(401, "invalid_installation_signature", "The signature does not verify.");
}
// 10. Used, in the same unit of work.
challenge.Used = true;
}
// Only then are the measurements read: first, whether they are the contract's.
CheckShape(report);
// Then what they are worth, by the contract's rules; then the decision.
var judgment = Contract.Judge(report, challenge.Required, definitions);
var decision = Contract.Decide(judgment, report);
var reportId = Guid.NewGuid().ToString();
lock (_lock)
{
_reports[reportId] = new StoredReport
{
InstallationId = session.InstallationId, At = Wire.Now(),
OperationSha256 = challenge.OperationSha256, Session = challenge.Session,
Judgment = judgment, Next = decision,
};
}
// The answer says what to do next, never the judgment, a score or a reason (section 7).
return new JsonObject { ["integrity"] = new JsonObject { ["report_id"] = reportId, ["next"] = decision } };
}
/// <summary>The report against the contract's schema: its shape.</summary>
private void CheckShape(JsonObject report)
{
var evaluation = schema.Evaluate(JsonDocument.Parse(report.ToJsonString()).RootElement,
new EvaluationOptions { OutputFormat = OutputFormat.List });
if (!evaluation.IsValid)
{
var at = evaluation.Details?.FirstOrDefault(d => !d.IsValid)?.InstanceLocation.ToString() ?? "";
throw new Refusal(400, "report_not_accepted", "The report is not the contract's.",
new JsonObject { ["check"] = "schema", ["at"] = at });
}
}
// --- Section 2: a scan for one action, used by the request it named.
private void RequireOperationScan(Call call)
{
var here = Wire.Sha256Hex(Wire.Canonical(new JsonObject
{
["method"] = call.Method, ["path"] = call.Path, ["query"] = call.Query,
["body_sha256"] = Wire.Sha256Hex(call.Body),
}));
lock (_lock)
{
// A scan made for this request, under this session, lately -- and one the decision allowed.
if (!_reports.TryGetValue(call.Header("X-Integrity-Report") ?? "", out var stored) || stored.Used
|| stored.OperationSha256 != here || stored.Session != call.Bearer
|| Wire.Now() - stored.At > OperationScanSeconds || stored.Next != "allow")
{
throw new Refusal(403, "needs_scan", "This request needs a scan made for it.");
}
stored.Used = true;
}
}
// --- Section 3: step-up.
private static void RequireStepUp(Call call, Session session, Installation installation, string nonce)
{
var proofText = call.Header("X-Step-Up-Proof");
var signature = call.Header("X-Step-Up-Signature");
if (string.IsNullOrEmpty(proofText) || string.IsNullOrEmpty(signature) || installation.StepUp is not { } stepUp)
{
throw new Refusal(403, "needs_step_up", "This request needs the person's approval.");
}
// A re-enrolled key approves for the account that re-enrolled it alone.
if (stepUp.Account is not null && stepUp.Account != session.Account)
{
throw new Refusal(403, "step_up_not_accepted", "The step-up key is another account's.",
new JsonObject { ["check"] = "account" });
}
byte[] raw = [];
JsonObject proof = new();
try
{
raw = Wire.Decode(proofText);
proof = JsonNode.Parse(raw) as JsonObject ?? new JsonObject();
}
catch (Exception e) when (e is FormatException or JsonException)
{
// Fails the signature below.
}
if (!Verifies(stepUp.Jwk, raw, signature))
{
throw new Refusal(403, "step_up_not_accepted", "The approval does not verify.",
new JsonObject { ["check"] = "signature" });
}
var expected = new (string Field, JsonNode Value)[]
{
("version", 2), ("purpose", "stepup"), ("installation_id", session.InstallationId),
("access_token_sha256", Wire.Sha256Hex(call.Bearer!)), ("method", call.Method), ("path", call.Path),
("query", call.Query), ("body_sha256", Wire.Sha256Hex(call.Body)), ("nonce", nonce),
// The factor this sample requires is the one the key was made with.
("factor", stepUp.Factor),
};
foreach (var (field, value) in expected)
{
if (!JsonNode.DeepEquals(proof[field], value))
{
throw new Refusal(403, "step_up_not_accepted", "The approval is for another request.",
new JsonObject { ["check"] = field });
}
}
var timestamp = Wire.Integer(proof["timestamp"]);
if (timestamp is null || Math.Abs(Wire.Now() - timestamp.Value) > ClockWindowSeconds)
{
throw new Refusal(403, "step_up_not_accepted", "The approval is out of time.",
new JsonObject { ["check"] = "timestamp" });
}
}
// --- Section 3: the app PIN.
private Account AccountOf(Session session)
{
lock (_lock)
{
return _accounts[session.Account!];
}
}
// --- Section 3: re-enrolment, a new step-up key for this account alone.
public async Task<JsonObject> ReenrolStepUp(Call call, JsonObject body)
{
var (session, installation, nonce) = Authenticate(call, "account");
var stepUp = StepUpKeyOf(body, installation.Thumbprint)
?? throw new Refusal(400, "bad_request", "A new step-up key is required.",
new JsonObject { ["field"] = "stepup_public_key" });
byte[] raw = [];
JsonObject proof = new();
try
{
raw = Wire.Decode(Wire.Text(body["stepup_key_proof"]) ?? "");
proof = JsonNode.Parse(raw) as JsonObject ?? new JsonObject();
}
catch (Exception e) when (e is FormatException or JsonException)
{
// Fails the signature below.
}
// Every check of the new key before the password, so that this call cannot serve to guess it.
if (!Verifies(stepUp.Jwk, raw, Wire.Text(body["stepup_key_signature"])))
{
throw new Refusal(403, "step_up_not_accepted", "The new key's proof does not verify.",
new JsonObject { ["check"] = "signature" });
}
var expected = new (string Field, JsonNode Value)[]
{
("version", 1), ("purpose", "stepup_reenrol"), ("installation_id", session.InstallationId),
("stepup_key_thumbprint", Thumbprint(stepUp.Jwk)), ("nonce", nonce),
};
foreach (var (field, value) in expected)
{
if (!JsonNode.DeepEquals(proof[field], value))
{
throw new Refusal(403, "step_up_not_accepted", "The new key's proof is for another request.",
new JsonObject { ["check"] = field });
}
}
var timestamp = Wire.Integer(proof["timestamp"]);
if (timestamp is null || Math.Abs(Wire.Now() - timestamp.Value) > ClockWindowSeconds)
{
throw new Refusal(403, "step_up_not_accepted", "The new key's proof is out of time.",
new JsonObject { ["check"] = "timestamp" });
}
await Reauthenticate(AccountOf(session), session.Account!, Wire.Text(body["password"]));
lock (_lock)
{
// Another account on the phone, which needs only the installation key, gains no step-up from it.
_installations[session.InstallationId] = installation with { StepUp = stepUp with { Account = session.Account } };
}
return new JsonObject { ["stepup_key_registered"] = true };
}
public async Task<JsonObject> SetAppPin(Call call, JsonObject body)
{
var (session, _, _) = Authenticate(call, "account");
var account = AccountOf(session);
await Reauthenticate(account, session.Account!, Wire.Text(body["password"]));
var pin = Wire.Text(body["pin"]);
if (pin is null || pin.Length != PinLength || !pin.All(char.IsAsciiDigit))
{
throw new Refusal(400, "bad_request", $"The PIN is {PinLength} digits.", new JsonObject { ["field"] = "pin" });
}
var (salt, verifier) = await SlowVerifierAsync(_pinSecret, session.Account!, pin);
lock (_lock)
{
// A new PIN clears the wrong ones and any lock, and is a new generation: checks of the
// PIN it replaces settle against that one, and the proofs made under it no longer serve.
account.PinSalt = salt;
account.PinVerifier = verifier;
account.PinFailures = 0;
account.PinLocked = false;
account.PinChecking = 0;
account.PinGeneration += 1;
}
return new JsonObject { ["app_pin_set"] = true };
}
public async Task<JsonObject> ProveAppPin(Call call, JsonObject body)
{
var (session, _, _) = Authenticate(call, "account");
var pin = Wire.Text(body["pin"]);
var target = body["request"] as JsonObject;
bool complete;
try
{
complete = !string.IsNullOrEmpty(pin) && target is not null
&& OperationValid(new JsonObject
{
["method"] = target["method"]?.DeepClone(), ["path"] = target["path"]?.DeepClone(),
["query"] = target["query"]?.DeepClone(), ["body_sha256"] = target["body_sha256"]?.DeepClone(),
})
&& Wire.Decode(Wire.Text(target["nonce"])).Length == 32;
}
catch (FormatException)
{
complete = false;
}
if (!complete)
{
// Refused before the PIN is checked.
throw new Refusal(400, "bad_request", "Send the PIN and the request it unlocks.", new JsonObject { ["field"] = "request" });
}
var account = AccountOf(session);
byte[] salt, verifier;
int generation;
lock (_lock)
{
if (account.PinVerifier is null || account.PinSalt is null)
{
throw new Refusal(403, "app_pin_not_set", "No app PIN is set for this account.");
}
if (account.PinLocked)
{
throw new Refusal(403, "app_pin_locked", "The app PIN is locked; set a new one.");
}
if (account.PinFailures + account.PinChecking >= PinAttempts)
{
// The attempts left are held by checks still running.
throw new Refusal(429, "pin_busy", "The PIN is being checked; try again in a moment.",
new JsonObject { ["retry_after_seconds"] = 1 });
}
account.PinChecking += 1; // the attempt, claimed before comparing
generation = account.PinGeneration;
salt = account.PinSalt;
verifier = account.PinVerifier;
}
bool? right;
try
{
await HeldPinCheck();
right = Wire.Same((await SlowVerifierAsync(_pinSecret, session.Account!, pin!, salt)).Verifier, verifier);
}
catch (Exception)
{
right = null; // the check could not run
}
bool current, locked;
lock (_lock)
{
// Settled in one step -- the attempt given back and the PIN's count changed under one
// lock, so that no other check takes the attempt between -- against the PIN it compared:
// a PIN set meanwhile is left alone -- its count, its lock, its checks -- and a check
// that could not run gives its attempt back.
current = account.PinGeneration == generation;
if (current)
{
account.PinChecking -= 1;
if (right == true)
{
account.PinFailures = 0;
}
else if (right == false)
{
account.PinFailures += 1;
account.PinLocked = account.PinFailures >= PinAttempts;
}
}
locked = account.PinLocked;
}
if (right is null)
{
throw new Refusal(503, "pin_unavailable", "The app PIN cannot be checked now; try again.");
}
if (!current)
{
// Neither a proof nor a count: the PIN it was right or wrong for is no longer the account's.
throw new Refusal(403, "app_pin_invalid", "The app PIN was changed while it was checked; enter it again.");
}
if (right == false)
{
throw locked
? new Refusal(403, "app_pin_locked", "The app PIN is locked; set a new one.")
: new Refusal(403, "app_pin_invalid", "The app PIN is wrong.");
}
var expires = Wire.Now() + PinProofSeconds;
var claims = Wire.Canonical(new JsonObject
{
["account"] = session.Account, ["installation_id"] = session.InstallationId,
["session_sha256"] = Wire.Sha256Hex(call.Bearer!), ["request"] = target!.DeepClone(), ["expires_at"] = expires,
["pin_generation"] = generation,
});
return new JsonObject
{
["app_pin_proof"] = $"{Wire.Encode(claims)}.{Wire.Encode(Wire.Hmac(_pinProofKey, claims))}",
["expires_at"] = Wire.Iso(expires),
};
}
private void RequireAppPin(Call call, Session session, string nonce)
{
JsonObject? proof = null;
try
{
var parts = (call.Header("X-App-PIN-Proof") ?? "").Split('.');
if (parts.Length == 2)
{
var claims = Wire.Decode(parts[0]);
if (Wire.Same(Wire.Decode(parts[1]), Wire.Hmac(_pinProofKey, claims)))
{
proof = JsonNode.Parse(claims) as JsonObject;
}
}
}
catch (Exception e) when (e is FormatException or JsonException)
{
proof = null;
}
if (proof is null)
{
throw new Refusal(403, "needs_app_pin", "This request needs the app PIN.");
}
int generation;
lock (_lock)
{
generation = _accounts[session.Account!].PinGeneration;
}
if (Wire.Integer(proof["pin_generation"]) != generation)
{
throw new Refusal(403, "app_pin_not_accepted", "The app PIN was changed since this proof was made; enter it again.");
}
var expires = Wire.Integer(proof["expires_at"]);
proof.Remove("expires_at");
var expected = new JsonObject
{
["account"] = session.Account, ["installation_id"] = session.InstallationId,
["session_sha256"] = Wire.Sha256Hex(call.Bearer!),
["request"] = new JsonObject
{
["method"] = call.Method, ["path"] = call.Path, ["query"] = call.Query,
["body_sha256"] = Wire.Sha256Hex(call.Body), ["nonce"] = nonce,
},
["pin_generation"] = generation,
};
if (expires is null || expires < Wire.Now() || !Wire.Canonical(proof).SequenceEqual(Wire.Canonical(expected)))
{
throw new Refusal(403, "app_pin_not_accepted", "The PIN's proof is for another request.");
}
}
// --- This sample's own account API, in which the plugin's proofs are used.
public async Task<JsonObject> SignIn(Call call, JsonObject body)
{
var (session, _, _) = Authenticate(call, "installation");
var handle = Wire.Text(body["handle"]);
var password = Wire.Text(body["password"]);
if (string.IsNullOrEmpty(handle) || string.IsNullOrEmpty(password))
{
throw new Refusal(400, "bad_request", "A handle and a password are required.");
}
Account? account;
lock (_lock)
{
_accounts.TryGetValue(handle, out account);
}
if (account is null)
{
var (salt, verifier) = await SlowVerifierAsync(_passwordSecret, handle, password);
lock (_lock)
{
if (!_accounts.TryGetValue(handle, out account))
{
account = new Account { PasswordSalt = salt, PasswordVerifier = verifier };
_accounts[handle] = account;
}
}
}
await Reauthenticate(account, handle, password);
var token = Wire.Encode(RandomNumberGenerator.GetBytes(32));
lock (_lock)
{
// An account session for this installation, which proved its key.
_sessions[token] = new Session
{
Kind = "account", InstallationId = session.InstallationId, Account = handle, Expires = Wire.Now() + SessionSeconds,
};
}
return new JsonObject { ["account_session"] = token };
}
private async Task Reauthenticate(Account account, string handle, string? password)
{
var (_, verifier) = await SlowVerifierAsync(_passwordSecret, handle, password ?? "", account.PasswordSalt);
if (!Wire.Same(verifier, account.PasswordVerifier))
{
throw new Refusal(401, "reauthentication_failed", "The password is wrong.");
}
}
public JsonObject Demo(Call call, params string[] needs)
{
var (session, installation, nonce) = Authenticate(call, "account");
if (needs.Contains("scan"))
{
RequireOperationScan(call);
}
if (needs.Contains("approval"))
{
// The person's approval, as the request offers it: step-up, the app PIN, or both -- and
// each one offered must hold.
var stepUp = !string.IsNullOrEmpty(call.Header("X-Step-Up-Proof"));
var appPin = !string.IsNullOrEmpty(call.Header("X-App-PIN-Proof"));
if (!stepUp && !appPin)
{
throw new Refusal(403, "needs_approval",
"This request needs the person's approval: step-up or the app PIN.");
}
if (stepUp)
{
RequireStepUp(call, session, installation, nonce);
}
if (appPin)
{
RequireAppPin(call, session, nonce);
}
}
return new JsonObject { ["done"] = call.Path };
}
// --- Test controls, on only with --test-controls.
/// <summary>A PIN check waits here, its attempt claimed, while a test holds PIN checks; released
/// with fail, its verifier fails.</summary>
private async Task HeldPinCheck()
{
TaskCompletionSource<bool>? gate;
lock (_lock)
{
gate = _pinGate;
if (gate is null)
{
return;
}
_pinWaiting += 1;
}
var fail = await gate.Task;
lock (_lock)
{
_pinWaiting -= 1;
}
if (fail)
{
throw new InvalidOperationException("the verifier failed, for a test");
}
}
public JsonObject Control(string path, JsonObject body)
{
lock (_lock)
{
switch (path)
{
case "/test/expire-sessions":
foreach (var session in _sessions.Values)
{
session.Expires = Wire.Now() - 1;
}
break;
case "/test/forget":
var id = Wire.Text(body["installation_id"]) ?? "";
if (_installations.Remove(id, out var record))
{
_byThumbprint.Remove(record.Thumbprint);
}
break;
case "/test/refuse-next":
_pending[Wire.Text(body["path"]) ?? ""] = (JsonObject)body.DeepClone();
break;
case "/test/judge":
// The contract's judgment of a report under a plan of the test's own, as the report
// call would make it.
try
{
var report = (JsonObject)body["report"]!;
CheckShape(report);
var required = ((JsonArray)body["required_probes"]!).Select(p => Wire.Text(p) ?? "");
return new JsonObject { ["judged"] = Contract.Judge(report, required, definitions) };
}
catch (Refusal)
{
return new JsonObject { ["judged"] = "refused" };
}
case "/test/pin-checks":
// Hold PIN checks once they claim their attempt, or release those waiting; the
// answer counts those waiting.
var hold = body["hold"] is JsonValue held && held.TryGetValue(out bool on) ? on : (bool?)null;
if (hold == true && _pinGate is null)
{
_pinGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
}
else if (hold == false && _pinGate is not null)
{
_pinGate.SetResult(body["fail"] is JsonValue fail && fail.TryGetValue(out bool failed) && failed);
_pinGate = null;
}
return new JsonObject { ["ok"] = true, ["waiting"] = _pinWaiting };
default:
throw new Refusal(404, "no_such_path", "No such control.");
}
}
return new JsonObject { ["ok"] = true };
}
public JsonObject? TakePending(string path)
{
lock (_lock)
{
return _pending.Remove(path, out var pending) ? pending : null;
}
}
}
public static class Program
{
public static void Main(string[] args)
{
string? Option(string name) => Array.IndexOf(args, name) is var at and >= 0 && at + 1 < args.Length ? args[at + 1] : null;
var schemaPath = Option("--schema");
if (schemaPath is null)
{
Console.Error.WriteLine("usage: SampleBackend --schema <contract>/report.schema.json [--port 8787] [--test-controls]");
Environment.Exit(2);
}
var port = int.Parse(Option("--port") ?? "8787");
// Test controls answer only requests carrying X-Test-Token: $SAMPLE_TEST_TOKEN.
var testToken = args.Contains("--test-controls")
? Environment.GetEnvironmentVariable("SAMPLE_TEST_TOKEN") ?? "" : null;
if (testToken is not null && testToken.Length < 16)
{
Console.Error.WriteLine("--test-controls needs SAMPLE_TEST_TOKEN, 16 characters or more");
Environment.Exit(2);
}
var schemaText = File.ReadAllText(schemaPath);
var definitions = JsonNode.Parse(schemaText)?["$defs"] as JsonObject ?? new JsonObject(); // the x- annotations
var backend = new Backend(JsonSchema.FromText(schemaText), definitions, testToken);
var builder = WebApplication.CreateSlimBuilder();
// ASP.NET Core logs nothing; the handler logs one line per call, never a body or a header.
builder.Logging.ClearProviders();
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
var app = builder.Build();
app.Run(async context => await Handle(backend, context));
Console.WriteLine($"listening on http://127.0.0.1:{port}");
app.Run();
}
private static readonly Dictionary<string, Func<Backend, Call, JsonObject, Task<JsonObject>>> Routes = new()
{
["/v1/installations/register"] = (b, c, body) => Task.FromResult(b.Register(body)),
["/v1/installations/challenge"] = (b, c, body) => Task.FromResult(b.KeyChallenge(body)),
["/v1/installations/verify"] = (b, c, body) => Task.FromResult(b.Verify(body)),
["/v1/integrity/challenge"] = (b, c, body) => Task.FromResult(b.ScanChallengeFor(c, body)),
["/v1/integrity/report"] = (b, c, body) => Task.FromResult(b.Report(c, body)),
["/v1/installations/stepup-key"] = (b, c, body) => b.ReenrolStepUp(c, body),
["/v1/account/app-pin"] = (b, c, body) => b.SetAppPin(c, body),
["/v1/account/app-pin/proof"] = (b, c, body) => b.ProveAppPin(c, body),
["/v1/demo/sign-in"] = (b, c, body) => b.SignIn(c, body),
["/v1/demo/echo"] = (b, c, body) => Task.FromResult(b.Demo(c)),
["/v1/demo/transfer"] = (b, c, body) => Task.FromResult(b.Demo(c, "scan")),
["/v1/demo/sensitive"] = (b, c, body) => Task.FromResult(b.Demo(c, "approval")),
};
private static async Task Handle(Backend backend, HttpContext context)
{
// The request target exactly as sent: the proofs sign it so.
var target = context.Features.Get<IHttpRequestFeature>()?.RawTarget ?? context.Request.Path.Value ?? "/";
var at = target.IndexOf('?');
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer);
var call = new Call(context.Request.Method, at < 0 ? target : target[..at], at < 0 ? "" : target[(at + 1)..],
context.Request.Headers, buffer.ToArray());
int status = 200;
JsonNode answer;
var failed = "";
try
{
if (call.Method == "GET" && call.Path == "/health")
{
answer = new JsonObject { ["ok"] = true };
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(answer.ToJsonString());
return;
}
if (call.Method != "POST")
{
throw new Refusal(404, "no_such_path", "No such path.");
}
JsonObject body;
try
{
body = call.Body.Length == 0 ? new JsonObject()
: JsonNode.Parse(call.Body) as JsonObject ?? throw new Refusal(400, "bad_request", "The body is not a JSON object.");
}
catch (JsonException)
{
throw new Refusal(400, "bad_request", "The body is not JSON.");
}
if (call.Path.StartsWith("/test/", StringComparison.Ordinal) && backend.TestTokenMatches(call.Header("X-Test-Token")))
{
answer = backend.Control(call.Path, body);
}
else if (backend.TakePending(call.Path) is { } pending)
{
throw new Refusal((int)(Wire.Integer(pending["status"]) ?? 400), Wire.Text(pending["code"]) ?? "refused",
"Refused for a test.", pending["details"]?.DeepClone() as JsonObject);
}
else if (Routes.TryGetValue(call.Path, out var route))
{
answer = await route(backend, call, body);
}
else
{
throw new Refusal(404, "no_such_path", "No such path.");
}
}
catch (Refusal refusal)
{
status = refusal.Status;
var error = new JsonObject { ["code"] = refusal.Code, ["message"] = refusal.Message };
// Which check failed goes to the log, never to the phone, which is told what to do next
// (section 7): what gave a request away is what an attacker would fix.
var details = refusal.Details?.DeepClone() as JsonObject ?? new JsonObject();
failed = string.Join(" ", new[] { "check", "at" }
.Where(key => details.ContainsKey(key))
.Select(key => $"{key}={(details[key] is JsonValue value ? value.ToString() : "")}"));
details.Remove("check");
details.Remove("at");
if (details.Count > 0)
{
error["details"] = details;
}
answer = new JsonObject { ["error"] = error };
}
// One line per call: the method, the path, the status, any refusal's code and the check
// that failed; never a body or a header -- they hold PINs.
Console.WriteLine($"{call.Method} {call.Path} {status} {Wire.Text(answer["error"]?["code"]) ?? ""} {failed}".TrimEnd());
context.Response.StatusCode = status;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(answer.ToJsonString());
}
}