# MUD egress · verify · attribute

**Six copy-paste recipes that give every industrial asset (a PLC, an RTU, an IED, an HMI, an OPC UA server) an identity it can prove, an egress you can govern to one line, and an operator you can name behind a rotating remote-access session. Each runs today against the keyless surface anyone can reach.**

They share one primitive: a routable IPv6 `/128` that an asset *derives* from a key it already holds, such as the public half of its [OPC UA](https://reference.opcfoundation.org/Core/Part6/) application-instance certificate, an [802.1AR IDevID](https://1.ieee802.org/security/802-1ar/), a TPM, or a secure element. The `/128` is DNSSEC-signed and [DANE-pinned](/docs/dane) to that same key, so any peer, integrator or regulator can verify it and nobody can forge it, with no commercial CA and no shared TrustList. Everything below is built from shipped parts: the asset `/128`, keyless [verify](/docs/verify), the attribution graph, egress governance (`policy`/`firewall`/`budget`), `lookups`, and one-call [revoke](/docs/control-plane). Where a step is a pattern you assemble rather than a single command, or a connector still on the roadmap, it says so in plain words.

> **Two tiers, by design (Postel's law: liberal in what we accept).** The *keyless half* ([verify](/docs/verify), reverse-DNS, [RDAP](/docs/rdap), and a read of *your own* identity's `lookups`) needs **no API key**: any counterparty runs it with stock `dig`/`curl`. The *control half* (provision, `policy`, `firewall`, `budget`, `revoke`) needs *your* key on `POST https://graph.whisper.security/api/query`. Both halves appear below, marked.
>
> **What's shipped, stated honestly.** The asset `/128` derivation (public key + `device_id` = the OPC UA `ApplicationUri` or an asset serial), the keyless verify / RDAP surface, the attribution graph, egress governance, `op:lookups`, and one-call revoke are **live**. Two caveats these recipes are written around: there is no first-class `--applicationuri` / `--mud` CLI flag yet, so asset provisioning is shown via the control-plane API (which *is* live); and the STIX · TAXII / ISAC *export* connector named at the end is roadmap: the **Splunk**, Microsoft Sentinel and OpenCTI connectors ship today. Whisper anchors the **IP / DNS / transport boundary**: it governs *who may reach and speak to* an asset and never reaches into the Modbus, DNP3 or PROFINET wire. Each recipe names exactly where it stops.

## The shared primitive: the address *is* the asset

An industrial asset already carries a hardware key and a globally-unique name. In OPC UA the **ApplicationUri** is that name: Part 4 §7 defines it as the "globally unique identifier for the application instance," and Part 6 §6.2.2 requires the application-instance certificate SAN to carry *exactly one* `uniformResourceIdentifier` equal to it (a session fails with `BadCertificateUriInvalid` if the two disagree). So OPC UA already binds a key-derived name to the asset's key. But trust is a *local* per-site TrustList, and commercial/public CAs are explicitly discouraged, so no peer operator, integrator or regulator can check your asset without joining that TrustList. Whisper takes only the **public** SubjectPublicKeyInfo (SPKI) of that same key and, with the ApplicationUri as the domain separator, *deterministically derives* a `/128` under `2a04:2a01::/32` (`AS219419`). The private key never leaves the asset.

The derivation has four properties that make it an identity rather than just an address:

- **Deterministic & idempotent.** The same key + ApplicationUri always yields the same `/128`. Re-flashing a replacement controller for the same asset returns the same address; there is no registry to keep in sync.
- **Tenant-bound & fleet-unlinkable.** The derivation folds in your operator tenant, so the same asset under two operators yields two *unrelated* `/128`s. An outsider cannot link a unit across the vendor/integrator boundary by suffix, and there is no enumeration oracle.
- **Forge-proof.** The `/128` is [DANE-EE `3 1 1`](/docs/dane)-pinned to that asset's own key and has DNSSEC-signed reverse DNS. A cloned device with a different key derives a different address and can't present the pinned key.
- **Revocable cross-org.** One control-plane call tears the `/128`, its PTR, and its DANE pin down everywhere at DNS-TTL speed: the shared off-switch a local per-site CRL never had.

Because the domain separator is the ApplicationUri, **the ApplicationUri alone yields nothing**: it is a structured identifier that flows through every OPC UA deployment and is not a secret, yet you cannot go ApplicationUri → `/128` without the key, there is no enumerable directory, and RDAP and reverse-DNS return the registry object, never the asset's live whereabouts. Once an asset holds one, every check in the keyless recipes below runs with no account: just DNS and TLS any counterparty already has. Even a dumb `Modbus/TCP:502` PLC behind a protocol gateway gets a verifiable network identity, a PTR and an RDAP object for the first time.

## Recipe 1: Bind an asset to the OPC UA ApplicationUri it already carries

> **Shipped & live.** Deriving an asset `/128` from the device key it already holds, keyed by its OPC UA `ApplicationUri` (or asset serial), is in production today. Provision one with the control-plane call below, then drop the address straight into your asset register / OPC UA `EndpointDescription`: no re-keying, no new CA.

Pass the asset's base64 SPKI as `identity_public_key` and its `ApplicationUri` as `device_id`: that URI is the exact one already bound into the certificate SAN. Re-running with the same key + ApplicationUri returns the *same* `/128`. The heredoc keeps the single-quoted Cypher literals intact, so this runs as-is:

```bash
# Bind an asset: device_id = its OPC UA ApplicationUri (the urn: already in its cert SAN).
# Idempotent: same key + ApplicationUri returns the SAME /128, no registry to sync.
curl -s https://graph.whisper.security/api/query \
  -H "X-API-Key: whisper_live_xxx" \
  -H 'content-type: application/json' \
  --data @- <<'JSON'
{"query":"CALL whisper.agents({op:'connect', args:{tier:'wireguard', identity_public_key:'<base64 SPKI of the asset key>', device_id:'urn:example-plant:line2:PLC7:server'}}) YIELD op, ok, status, result, error RETURN op, ok, status, result, error"}
JSON
```

```bash
# response
{ "op": "connect", "ok": true, "status": "created",
  "result": {
    "address":   "2a04:2a01:7a2::4840",
    "fqdn":      "opcua-plc7.asset.<tenant>.agents.whisper.online",
    "wireguard": { /* peer, keys, allowed-ips: source-bind the asset's egress to its /128 */ }
  } }
```

Confirm the address and its forward-confirmed name, then place the `/128` wherever your inventory expects it: a CMDB row, the OPC UA `EndpointDescription`, an IEC 62443 asset register:

```bash
# The address is the asset: forward-confirmed reverse DNS names it (keyless):
dig +short -x 2a04:2a01:7a2::4840
opcua-plc7.asset.<tenant>.agents.whisper.online.
```

*Figure: The OPC UA ApplicationUri is already a globally-unique name bound into the asset's certificate, a good key-derived identity trapped in a private, per-site TrustList. Whisper binds it to a routable, publicly verifiable `/128` and gives it the cross-org off-switch a local CRL never had.*

> The call is **liberal in what it accepts, strict in what it commits**: the same asset key with a *different* `device_id` on your tenant is a clear `409`, not a silent overwrite; a non-string `device_id` is a `400` that tells you exactly what was wrong, never an opaque 500. The `device_id` is generic: pass an OPC UA ApplicationUri, an 802.1AR IDevID serial, or a bare nameplate serial, whatever native identifier the asset carries. A dedicated `--applicationuri` / `--mud` CLI flag is on the roadmap; today, provisioning is the control-plane call above (which is live). The shipped CLI verbs are `whisper verify --trustless`, `whisper create --register`, `whisper kill --revoke`, `whisper policy`, and `whisper logs`. See [CLI & one-command](/docs/cli).

## Recipe 2: Prove an asset's identity, keyless

OPC UA's own certificate proves the asset: *to a party that already holds it in a local TrustList.* Across the conduit (to a peer operator, an integrator, a vendor's remote-maintenance service, or a regulator), no one can chain your per-site trust to a public anchor. The `/128` closes that: any party re-derives and checks the identity trustlessly against the IANA DNSSEC root, with no Whisper API and no shared TrustList trusted as an authority. No key, no account, no login.

> **Boundary.** This **complements** the OPC UA application-instance certificate and its local TrustList; it does **not** replace them and issues **no** OPC UA certificates. It adds the *publicly*-verifiable check (the one an integrator, a peer operator, or an auditor can run without joining your TrustList) at exactly the cross-org conduit the per-site PKI can't cross.

The CLI walks the full proof chain in one line; the same check runs either side of a conduit:

```bash
# Trustless: re-derived against the IANA DNSSEC root; no Whisper API, no TrustList trusted.
whisper verify --trustless opcua-plc7.asset.<tenant>.agents.whisper.online

dnssec   pass   DNSSEC-root   AAAA, PTR and TLSA(3 1 1) all DNSSEC-validated to the IANA root
dane     pass   DNSSEC-root   served leaf SPKI-SHA256 == TLSA pin (the asset's own OPC UA cert key)
ledger   pass   DNSSEC-root   transparency-log entry present, signature verifies
CRYPTOGRAPHICALLY PROVEN. Trust anchor: DNSSEC root (IANA) + DANE-EE. Whisper API NOT trusted
```

No CLI required: the same fact answers over one keyless HTTPS call, and again with nothing but `dig -x`, the tool already in every ops toolbox:

```bash
# The public verify endpoint: evidence chain in JSON, no account:
curl -s https://whisper.online/verify-identity/2a04:2a01:7a2::4840 | jq
{"is_whisper_agent":true,"dane_ok":true,"jws_ok":true,
 "evidence":{"address":"2a04:2a01:7a2::4840","ptr":"…","forward_aaaa":"2a04:2a01:7a2::4840"}}

# Because the reverse is forward-confirmed (PTR ⇄ AAAA), dig -x alone proves the name:
dig +short -x 2a04:2a01:7a2::4840
opcua-plc7.asset.<tenant>.agents.whisper.online.

# And a device with no registered identity says so plainly (no key required):
curl -s https://whisper.online/verify-identity/2a04:2a01::1
{"is_whisper_agent":false,"detail":"no Whisper agent identity anchors this address"}
```

That is mutual, forge-proof peer verification at the layer OPC UA's own model leaves open across organisations: neither side depends on a token that could be replayed from another host, and neither side has to trust a CA, or a TrustList, the other one chose.

## Recipe 3: Make the device's MUD declaration enforceable at the `/128`

This is the OT differentiator. Under **[MUD (RFC 8520)](https://www.rfc-editor.org/rfc/rfc8520)**, an industrial device already *declares* exactly what it should communicate with: a manufacturer-signed manifest of `from-device-policy` and `to-device-policy` ACLs ([RFC 8519](https://www.rfc-editor.org/rfc/rfc8519)), emitted over DHCP (options `161` / `112`), an LLDP vendor TLV (type `127`), or the `id-pe-mud-url` X.509 extension (`OID 1.3.6.1.5.5.7.1.25`) co-located with an 802.1AR IDevID. MUD's fatal weakness: the manifest is fetched from the manufacturer's domain and enforced by a *local* MUD manager at the nearest switch, so the rules are only a suggestion, and a compromised device still reaches anything the local admin didn't think to block. Bind that declaration to the asset's verifiable `/128` and enforce it as **default-deny** egress governance where traffic actually leaves, cross-org and checkable by anyone.

> **Keyless to read, keyed to enforce.** The device's MUD URL can be [DANE-pinned](/docs/dane) and checked with no key (below). Setting the policy is the control half: it takes your API key.

Start from the device's own manifest: the manufacturer already ships it. Here it is, abbreviated to the two ACEs that matter:

```bash
# The device's own MUD manifest (RFC 8520), CMS-signed by the manufacturer, abbreviated.
# Its MUD URL can be DANE-pinned and checked keyless, exactly like Recipe 2:
dig +short TLSA _443._tcp.mud.vendor.example
3 1 1 b653a4ef…fcb82d1d

{ "ietf-mud:mud": { "mud-url": "https://mud.vendor.example/plc-x/v2",
    "to-device-policy":   { "access-lists": {"access-list":[{"name":"cl-from-historian"}]} },
    "from-device-policy": { "access-lists": {"access-list":[{"name":"cl-to-controller"}]} } },
  "ietf-access-control-list:acls": { "acl": [ { "name":"cl-to-controller",
     "aces":{"ace":[{ "matches":{"ipv6":{"dst-dnsname":"controller.line2.local"}},
                "actions":{"forwarding":"accept"} }]} } ] } }
```

Now translate the manifest's declared destinations into default-deny egress governance *bound to the asset's `/128`*. Egress is source-bound to that address, so policy is enforced by name *and* by address: a conduit at asset granularity, not a VLAN:

```bash
# The MUD declaration, enforced: this PLC may reach ONLY the destinations it declared:
# its historian, its controller, and its vendor OTA endpoint. Everything else is denied.
whisper policy set --default deny \
  --allow historian.example-plant.com,controller.line2.local,ota.vendor.example

# The same as a per-asset firewall over the control plane (allow/deny by host, cidr or port).
# Ports are the real ones: 4840 = OPC UA, 502 = Modbus/TCP. Send this to
# POST https://graph.whisper.security/api/query with your X-API-Key.
CALL whisper.agents({op:'firewall', args:{
  agent:'2a04:2a01:7a2::4840', default:'deny',
  allow:['historian.example-plant.com:4840', 'controller.line2.local:502', 'ota.vendor.example:443']
}})
```

*Figure: Same manifest, two fates. Pinned to a globally-verifiable identity and enforced at the `/128` where traffic actually egresses, the manufacturer's declaration of intent finally becomes a cryptographic, cross-org control, not a suggestion at the nearest hop. The dashed box marks the honest edge, spelled out below.*

This directly implements the **CISA CPG 2.0** pattern (an IPv6-addressable asset register plus logical segmentation permitting only required communications) and the **IEC 62443-3-3** conduit, but keyed to a *cryptographically verifiable* identity rather than a spoofable MUD URL or a VLAN tag. See [Egress governance](/docs/egress-governance) for the full policy surface.

**The honest edge.** Egress governance changes *who may reach and speak to* an asset, and constrains what a governed asset may reach. It contains C2, exfil and lateral movement across the convergence bridge and the remote-access surface. It does *not* add authentication to Modbus, DNP3 or PROFINET on the wire, and it does not stop a purely-internal manipulation by an attacker who already holds an OT-segment foothold: closing that last inch needs identity enforced *in the command path*, at the PLC, a protocol-aware broker-gateway, or the EWS. We say so before your assessor does.

## Recipe 4: Back-trace the operator behind a suspicious remote-access session

Identity stops the next forgery; the graph names the operator behind a session already in your logs: attribution that survives IP rotation because it fingerprints the operator and the tooling, not the ephemeral egress IP. OT environments run remote-access sprawl (Claroty Team82: 55% run 4+ tools, 33% run 6+), so a destructive write can arrive over a vendor's jump-host or a rotating proxy and all your OT SOC ever logs is a meaningless *last IP*. Behavioral OT monitoring is excellent inside the plant. But it stops at the Purdue boundary and the firewall. The graph collapses the rotation.

> **Boundary, stated plainly.** This is an **additive feed** into the OT monitoring, the SOC, and the PSIRT you already run; Whisper does **not** do deep-packet ICS-protocol inspection. And it is honest about reach: attribution names the operator behind a rotating egress. It does **not**, by itself, evict a below-identity-layer implant, a stolen-but-enrolled identity, or an insider at the console. Use it as the who/where accelerator for incident handling, not a claim to have stopped a nation-state-class campaign.

Pass the suspicious address to `whisper.identify` over the public graph API with your key. There is no `whisper identify` CLI verb: the read-only Cypher call below *is* the interface:

```bash
curl -s https://graph.whisper.security/api/query \
  -H "X-API-Key: whisper_live_xxx" \
  -H 'content-type: application/json' \
  -d '{"query":"CALL whisper.identify(\"45.83.x.x\")"}' | jq
  operator:  <fingerprinted> · seen across AWS, GCP and Azure + a remote-access pool
  proxy swarm collapsed by JA4: same tooling, 41 exit IPs → 1 operator
```

*Figure: Attribution survives rotation because it tracks the infrastructure and the tooling, not the ephemeral egress IP. The read-only verbs (`identify`, `origins`, `walk`, `variants`, `history`) each return a reproducible, replayable JSON evidence chain your OT SOC, PSIRT, and a regulator can replay.*

Two levers, kept honestly separate: infrastructure genealogy clusters cloud rotation by shared ASN, hosting and certificate lineage; a `JA4/JA3` client fingerprint collapses a rotating remote-access or residential-proxy pool to one operator, invisible to the proxy because it lives in the TLS handshake. More in [Graph & cognition](/docs/graph-api).

## Recipe 5: Govern asset egress, cap it and kill it worldwide

Recipe 3 set the allow-list; this is the rest of the control plane: the cap and the off-switch. Because the asset's `/128` resolves through Whisper's own graph-first resolver and its egress is source-bound to that address, you can put a hard ceiling on what a *live* asset moves and cut it off in one call. This is the containment that a flat network structurally lacks: compromise one asset and you've compromised *that asset*, not the plant.

> **Boundary.** This rides existing DNS / IPv6 and adds **no inline packet chokepoint** on the OT segment. It is not an ICS-protocol firewall, and it does not sit in the PLC's command path. The verify plane is built to **fail open**: a Whisper outage never bricks an asset; checks degrade to your existing anchors. It complements the plant's own segmentation and monitoring; it never replaces them.

```bash
# Cap the asset's egress and arm the kill-switch. Send to
# POST https://graph.whisper.security/api/query with your X-API-Key.
CALL whisper.agents({op:'budget', args:{
  agent:'2a04:2a01:7a2::4840', max_mb_per_day:50, on_exceed:'kill'
}})

# A compromised PLC, a module swap, a change of integrator, a decommission:
# one call tears down the /128, its PTR and its DANE pin everywhere at DNS-TTL speed.
CALL whisper.agents({op:'revoke', args:{agent:'2a04:2a01:7a2::4840'}})

# The CLI does the same teardown in one verb:
whisper kill --revoke 2a04:2a01:7a2::4840
```

After the TTL, every keyless check in Recipe 2 flips to `is_whisper_agent:false` for that address *everywhere on the planet*, and `dig -x` returns nothing: no CRL to distribute, no per-vendor blocklist to push, no fleet-wide firmware flash. Revocation is the cross-org off-switch OPC UA's local TrustList never had.

> **Nothing issued in the dark.** Every provision and every revoke lands in a public, append-only [RFC 6962 Merkle transparency log](/docs/transparency), Ed25519-signed and anchored to Bitcoin via OpenTimestamps: an auditable, non-repudiable issuance/revocation trail for an **EU CRA** filing, an **IEC 62443** assessment or a **TSA** review. *Honest status:* tamper-evident and Bitcoin-anchored today, but **not yet independently witnessed** (it already speaks the C2SP `tlog-witness` protocol, so an external witness can co-sign). And you can bind each historian feed or telemetry stream to the asset's forge-proof `/128`, and [sign it under](/docs/sign-outputs) that same identity, so an integrator, an auditor or downstream settlement can trust the numbers came from the real asset, not a spoofed source on the flat network.

## Recipe 6: See who probed or enumerated your asset

Discovery is Stage 1 of the OT kill chain: before anyone speaks *to* your PLC, they scan and resolve it: Censys catalogued **145,000+** internet-exposed ICS services across 175 countries, and Shodan indexes the rest. Because every asset identity resolves through Whisper's own authoritative DNS and RDAP, the owner sees exactly who looked: a reconnaissance tripwire the OPC UA local, out-of-band TrustList never gave you. This is the reverse of `op:logs` (the asset's *own* outbound): `op:lookups` answers *who checked this asset*. It reads keyless for your own address:

```bash
# The recon tripwire: who has been resolving / RDAP-querying this asset's identity?
# A spike in PTR/AAAA/TLSA/RDAP lookups from an unexpected origin = someone enumerating
# before the write lands. Early warning, not a post-mortem. No key required:
curl -s https://whisper.online/ip/2a04:2a01:7a2::4840/lookups | jq
{ "address":"2a04:2a01:7a2::4840",
  "lookups":[ {"kind":"PTR","count":128,"first":"…","last":"…"},
              {"kind":"TLSA","count":74,"…":"…"},
              {"kind":"rdap","count":19,"…":"…"} ] }

# With your key, the same over the control plane, per asset, alongside op:logs:
CALL whisper.agents({op:'lookups', args:{agent:'2a04:2a01:7a2::4840', window:'24h'}})
```

Paired with `op:logs` (the asset's own outbound activity) and `/ip/<addr>/transparency` (its ordered lifecycle), you have both halves of the picture: what an asset reaches out to, and who is reaching in to look at it. And pair the tripwire with Recipe 4: an unexpected surge of lookups against a line's PLCs, then `whisper.identify` on the querying source, turns "someone is casing the plant" from a hunch you find in the post-mortem into a named operator you can act on before the write lands.

## Where each recipe stops, and what's roadmap

Honest scoping, one row per recipe. Whisper anchors the IP / DNS / transport boundary; the ICS protocols on the wire, the OPC UA local TrustList, and the plant's own segmentation stay exactly where they are.

| Recipe | Complements | Does *not* touch / replace |
|---|---|---|
| 1 · Bind an asset identity | the OPC UA `ApplicationUri` cert (Part 6 §6.2.2) · 802.1AR IDevID · BRSKI | the OPC UA local TrustList / CA (still issues the cert); no re-keying |
| 2 · Keyless asset verify | the app-instance cert across the vendor/integrator conduit | OPC UA session security at the handshake; issues no OPC UA certs |
| 3 · Make MUD enforceable | MUD (RFC 8520) · CISA CPG 2.0 · IEC 62443-3-3 conduits | Modbus / DNP3 / PROFINET auth on the wire; the in-path command write |
| 4 · Back-trace on the graph | behavioral OT monitoring · the SOC / PSIRT | ICS deep-packet inspection; not a claim to evict a below-identity implant |
| 5 · Govern & kill | plant segmentation · NIST SP 800-82r3 compensating controls | inline ICS-protocol inspection; adds no chokepoint on the OT segment |
| 6 · Lookups tripwire | OT asset-visibility feeds · exposure management | Nothing else (a keyless read of your own identity's query record) |

> **Roadmap, clearly labelled.** Streaming this evidence into a SIEM ships today for **Splunk**, **Microsoft Sentinel** and **OpenCTI** (signed, replayable JSON → CEF / ECS fields). A **STIX 2.1 over TAXII** feed and a sector-ISAC (E-ISAC / WaterISAC) machine-readable export for sharing revocation and attribution evidence are proposed, not yet available. And the first-class typed `--applicationuri` / `--mud` CLI flags are roadmap: provision and govern via the control-plane API shown above, which is live. Nothing on this list is required for the six recipes; they run on shipped primitives alone. Every proposed integration sits at the asset / IP boundary, complements the stack you already run, and is never named against a specific operator as a breach victim.

## Next

- [Asset & PLC identity](/docs/industries/ot/asset-identity): the full derivation the `/128` above comes from, key + ApplicationUri to address
- [DANE & TLSA](/docs/dane): the `3 1 1` pin every check here rests on, byte for byte
- [Verify an agent](/docs/verify): the full keyless proof chain `whisper verify --trustless` walks
- [IEC 62443 · EU CRA · TSA](/docs/industries/ot/ot-compliance): where these recipes map into assessment and incident-reporting evidence

---

← [IEC 62443 · EU CRA · TSA](/docs/industries/ot/ot-compliance) · [Asset & PLC identity →](/docs/industries/ot/asset-identity)
