AID-H-013.003: Black-Box Behavioral Ownership Fingerprinting

Version 1.20261015 · Actionable sub-technique

Use a secret, signature-verified and independently calibrated structured-response challenge population to test a compatible suspect endpoint when investigators cannot inspect model bytes. The complete population produces a policy-bound statistical review trigger or no-trigger finding; neither outcome is an ownership verdict or a substitute for forensic and legal review.

Pillars: model

Lifecycle: building, validation, operation

Scope boundary

Owns secret challenge execution, null calibration, complete population reconciliation, statistical scoring, and replayable black-box ownership review evidence for a compatible suspect endpoint. A trigger is not an ownership verdict.

AID-H-013.002: AID-H-013.003 produces a bounded statistical review trigger from endpoint behavior; AID-H-013.002 proves exact artifact or canonical tensor-state match from authorized suspect bytes. Behavioral similarity cannot be reported as byte or tensor identity.

AID-DV-004: AID-H-013.003 uses a preregistered statistical challenge population to test an ownership hypothesis; AID-DV-004 uses deception-specific markers and beacons to expose misuse or leakage. Behavioral fingerprinting and trap activation have different null models and investigation claims.

Warning

Statistical Review Signal, Not Standalone Ownership Proof

Behavior can converge across unrelated models and can change through fine-tuning, distillation, system prompts, routing, sampling, or adaptive countermeasures. Use a preregistered secret population, independent null calibration, policy-bound thresholds, and independent replay. Treat a trigger only as a reason for bounded forensic review.

Implementation guidance

AID-H-013.003-G001

Use a secret, signed and independently calibrated structured-response challenge population to produce a statistically bounded black-box ownership review trigger.

Use black-box fingerprint verification only when the model owner pre-registered a secret challenge population for the exact released model, the suspect endpoint exposes a deterministic structured response class, and the investigation has authorization to query it. A signature-verified policy binds the endpoint, mTLS trust material, registry and scorer schema, exact challenge population, retry/error budget, independent null-model calibration receipt, and statistical review thresholds. Free-form marker matching and fixed universal sample counts are not accepted.

Runnable calibrated black-box verifier
# File: model_ip/blackbox_fingerprint_verifier.py
from __future__ import annotations

from contextlib import ExitStack
import hashlib
import json
import math
import os
import ssl
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path

import httpx
from scipy.stats import binomtest

def _required_positive_seconds(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number of seconds")
    return value

OWNERSHIP_CHALLENGE_BUNDLE_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H013_OWNERSHIP_CHALLENGE_BUNDLE_VERIFY_TIMEOUT_SECONDS")



class InsufficientData(RuntimeError):
    pass


class ContractViolation(RuntimeError):
    pass


def sha256(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


def verified_json(
    path: Path, signature: Path, public_key: Path
) -> tuple[dict, str]:
    raw = path.read_bytes()
    sig = signature.read_bytes()
    if not raw or not sig:
        raise ValueError("signed JSON or signature is empty")
    with tempfile.TemporaryDirectory(prefix="ownership-verify-") as directory:
        root = Path(directory)
        os.chmod(root, 0o700)
        snapshot = root / "artifact.json"
        signature_snapshot = root / "artifact.sig"
        snapshot.write_bytes(raw)
        signature_snapshot.write_bytes(sig)
        os.chmod(snapshot, 0o400)
        os.chmod(signature_snapshot, 0o400)
        subprocess.run([
            "cosign", "verify-blob", "--key", str(public_key),
            "--bundle", str(signature_snapshot), str(snapshot),
        ], check=True, capture_output=True, text=True, timeout=OWNERSHIP_CHALLENGE_BUNDLE_VERIFY_TIMEOUT_SECONDS)
        if snapshot.read_bytes() != raw:
            raise ValueError("verified artifact changed")
    return json.loads(raw.decode("utf-8", errors="strict")), sha256(raw)


def load_contract(
    policy_path: Path,
    policy_signature: Path,
    policy_key: Path,
    registry_path: Path,
    registry_signature: Path,
    registry_key: Path,
    calibration_path: Path,
    calibration_signature: Path,
    calibration_key: Path,
) -> tuple[dict, dict, dict, str, str]:
    policy, policy_sha256 = verified_json(
        policy_path, policy_signature, policy_key
    )
    policy_fields = {
        "schema_version", "policy_version", "suspect_endpoint",
        "expected_model_route", "registry_sha256",
        "calibration_receipt_sha256", "ca_bundle_sha256",
        "client_certificate_sha256", "not_before", "not_after",
        "request_timeout_seconds", "response_max_bytes", "retry_attempts",
        "minimum_challenges", "maximum_p_value",
        "minimum_match_rate",
    }
    if not isinstance(policy, dict) or set(policy) != policy_fields:
        raise ValueError("ownership policy schema differs")
    if policy["schema_version"] != "aidefend.blackbox-ownership-policy.v1":
        raise ValueError("unsupported ownership policy")
    now = datetime.now(timezone.utc)
    not_before = datetime.fromisoformat(
        policy["not_before"].replace("Z", "+00:00")
    )
    not_after = datetime.fromisoformat(
        policy["not_after"].replace("Z", "+00:00")
    )
    if not not_before <= now <= not_after:
        raise InsufficientData("ownership policy is outside its time window")

    registry, registry_sha256 = verified_json(
        registry_path, registry_signature, registry_key
    )
    if registry_sha256 != policy["registry_sha256"]:
        raise ContractViolation("challenge registry digest differs")
    if (
        not isinstance(registry, dict)
        or set(registry) != {
            "schema_version", "reference_model_sha256", "challenges"
        }
        or registry["schema_version"]
        != "aidefend.blackbox-challenge-registry.v1"
    ):
        raise InsufficientData("challenge registry schema differs")
    challenges = registry["challenges"]
    if not isinstance(challenges, list) or len(challenges) < int(
        policy["minimum_challenges"]
    ):
        raise InsufficientData("challenge population is too small")
    ids = [row.get("id") for row in challenges if isinstance(row, dict)]
    if len(ids) != len(challenges) or len(set(ids)) != len(ids):
        raise InsufficientData("challenge IDs are missing or duplicated")
    for row in challenges:
        if set(row) != {"id", "request_body", "expected_response_class"}:
            raise InsufficientData("challenge schema differs")
        if not isinstance(row["request_body"], dict):
            raise InsufficientData("challenge request body must be an object")

    calibration, calibration_sha256 = verified_json(
        calibration_path, calibration_signature, calibration_key
    )
    if calibration_sha256 != policy["calibration_receipt_sha256"]:
        raise ContractViolation("calibration receipt digest differs")
    required_calibration = {
        "schema_version", "status", "registry_sha256",
        "reference_population_sha256", "null_match_probability",
        "challenge_count",
    }
    if (
        not isinstance(calibration, dict)
        or set(calibration) != required_calibration
        or calibration["schema_version"]
        != "aidefend.blackbox-null-calibration.v1"
        or calibration["status"] != "PASS"
        or calibration["registry_sha256"] != registry_sha256
        or int(calibration["challenge_count"]) != len(challenges)
    ):
        raise InsufficientData("calibration receipt is incomplete or unbound")
    null_probability = float(calibration["null_match_probability"])
    if not 0.0 < null_probability < 1.0:
        raise ValueError("null match probability is invalid")
    return policy, registry, calibration, policy_sha256, calibration_sha256


def run_verification(
    ca_bundle: Path,
    client_certificate: Path,
    client_private_key: Path,
    **contract_paths,
) -> dict:
    policy, registry, calibration, policy_sha256, calibration_sha256 = (
        load_contract(**contract_paths)
    )
    if sha256(ca_bundle.read_bytes()) != policy["ca_bundle_sha256"]:
        raise ContractViolation("mTLS CA bundle digest differs")
    if (
        sha256(client_certificate.read_bytes())
        != policy["client_certificate_sha256"]
    ):
        raise ContractViolation("mTLS client certificate digest differs")
    endpoint = httpx.URL(policy["suspect_endpoint"])
    if (
        endpoint.scheme != "https"
        or endpoint.username
        or endpoint.password
        or endpoint.fragment
    ):
        raise ContractViolation("suspect endpoint must be an absolute HTTPS URL")

    timeout = float(policy["request_timeout_seconds"])
    retries = int(policy["retry_attempts"])
    response_max_bytes = policy["response_max_bytes"]
    if (
        not timeout > 0.0 or retries < 0
        or isinstance(response_max_bytes, bool)
        or not isinstance(response_max_bytes, int)
        or response_max_bytes <= 0
    ):
        raise ValueError("timeout or retry policy is invalid")
    tls_context = ssl.create_default_context(cafile=str(ca_bundle))
    tls_context.minimum_version = ssl.TLSVersion.TLSv1_2
    tls_context.load_cert_chain(
        certfile=str(client_certificate), keyfile=str(client_private_key)
    )
    rows = []
    failures = []
    with httpx.Client(
        verify=tls_context,
        timeout=timeout,
        follow_redirects=False,
        trust_env=False,
    ) as client:
        for challenge in registry["challenges"]:
            response_raw = None
            payload = None
            last_error = None
            for _ in range(retries + 1):
                try:
                    with client.stream(
                        "POST", endpoint,
                        json=challenge["request_body"],
                        headers={
                            "X-AIDEFEND-Challenge-ID": challenge["id"],
                            "Cache-Control": "no-store",
                            "Accept": "application/json",
                        },
                    ) as candidate:
                        candidate.raise_for_status()
                        raw = bytearray()
                        for chunk in candidate.iter_bytes():
                            raw.extend(chunk)
                            if len(raw) > response_max_bytes:
                                raise ContractViolation("endpoint response exceeds signed byte cap")
                    response_raw = bytes(raw)
                    payload = json.loads(response_raw)
                    break
                except httpx.HTTPError as exc:
                    last_error = type(exc).__name__
            if response_raw is None or payload is None:
                failures.append({
                    "challenge_id": challenge["id"],
                    "error": last_error or "request_failed",
                })
                continue
            if (
                not isinstance(payload, dict)
                or set(payload) != {
                    "request_id", "response_class", "model_route"
                }
                or not isinstance(payload["request_id"], str)
                or not payload["request_id"]
                or not isinstance(payload["response_class"], str)
                or payload["model_route"] != policy["expected_model_route"]
            ):
                raise ContractViolation("structured response contract differs")
            matched = (
                payload["response_class"]
                == challenge["expected_response_class"]
            )
            rows.append({
                "challenge_id": challenge["id"],
                "matched": matched,
                "request_id_sha256": sha256(
                    payload["request_id"].encode()
                ),
                "response_sha256": sha256(response_raw),
            })

    if failures or len(rows) != len(registry["challenges"]):
        raise InsufficientData("challenge population is incomplete")
    hits = sum(row["matched"] for row in rows)
    count = len(rows)
    match_rate = hits / count
    p_value = float(binomtest(
        hits, count,
        p=float(calibration["null_match_probability"]),
        alternative="greater",
    ).pvalue)
    maximum_p_value = float(policy["maximum_p_value"])
    minimum_match_rate = float(policy["minimum_match_rate"])
    if (
        not math.isfinite(p_value)
        or not math.isfinite(match_rate)
        or not math.isfinite(maximum_p_value)
        or not math.isfinite(minimum_match_rate)
        or not 0.0 <= maximum_p_value <= 1.0
        or not 0.0 <= minimum_match_rate <= 1.0
    ):
        raise ValueError("statistic or policy threshold is non-finite or invalid")
    review_trigger = (
        p_value <= maximum_p_value
        and match_rate >= minimum_match_rate
    )
    return {
        "schema_version": "aidefend.blackbox-ownership-evidence.v1",
        "status": "PASS",
        "finding": "REVIEW_TRIGGER" if review_trigger else "NO_TRIGGER",
        "policy_version": policy["policy_version"],
        "policy_sha256": policy_sha256,
        "registry_sha256": policy["registry_sha256"],
        "calibration_receipt_sha256": calibration_sha256,
        "reference_model_sha256": registry["reference_model_sha256"],
        "suspect_endpoint_sha256": sha256(str(endpoint).encode()),
        "challenge_count": count,
        "match_count": hits,
        "match_rate": match_rate,
        "p_value": p_value,
        "null_match_probability": calibration["null_match_probability"],
        "results": rows,
    }


def run_with_status(**kwargs) -> dict:
    try:
        return run_verification(**kwargs)
    except ContractViolation as exc:
        return {"status": "FAIL", "reason": str(exc)}
    except InsufficientData as exc:
        return {"status": "INSUFFICIENT_DATA", "reason": str(exc)}
    except Exception as exc:
        return {"status": "ERROR", "reason": type(exc).__name__}
Dependency, evidence, independent replay, and outcomes

The null calibration receipt is a required upstream artifact produced by an evaluator that queried the complete policy-selected population of unrelated reference endpoints. It binds the registry digest, reference-population digest, challenge count, null match probability, evaluator identity, and raw result digest. The local boundary re-verifies the receipt and never inherits its PASS. Retain the signed policy, registry and calibration receipts, verification records, exact CA/client-certificate digests, endpoint and model-route identity, retry policy, complete challenge-ID population, hashed request IDs and responses, match vector, p-value, finding, immutable verifier image, and independent replay receipt in append-only storage. Keep challenge bodies and expected classes in a separate restricted secret store.

Action: Execute the complete secret challenge population over authenticated transport, compute the policy-bound calibrated binomial result, preserve only bounded evidence, and open forensic review only when the independently replayed finding is REVIEW_TRIGGER.

Open-source tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-013.003: Black-Box Behavioral Ownership Fingerprinting | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version