# Sign & prove agent outputs

**A report, a commit, an API response - anything your agent produces can carry a signature the recipient checks against a key pinned in DNS, not a "trust me" footer.**

An agent finishes a job - a security scan, a generated report, a code change, a JSON payload handed to another service - and hands over the result. How does the recipient know *which* agent produced it, and that it hasn't been altered since?

Today the answer is usually nothing: a filename, a `From:`-style header, maybe a Slack message saying "done." None of that is checkable by anyone who wasn't in the room. If the output later matters - an audit, a dispute, a downstream agent deciding whether to act on it - "the agent said so" is a rumor with good formatting, not evidence.

It gets worse at scale, not better: a fleet of short-lived agents producing outputs continuously is a constant stream of unverifiable claims, with no way to tell a real one from a forged one after the fact.

Whisper doesn't add a new signing product to solve this. It already gives your agent the one thing that makes a signature checkable by a stranger: a **DNSSEC-anchored name**, with a signing key already pinned under it - the same key its [`did:web` document](/docs/did-web) and [DANE `TLSA`](/docs/dane) already resolve identity against.

So the idea here isn't a new key; it's a use of the anchor you already have. `whisper sign file` signs your agent's output with that per-agent key, the public half is already resolvable at the agent's name, and the recipient verifies against DNS: no CA, no vendor dashboard, no shared secret.

## The mechanism: the key you already have, pinned twice

Every Whisper agent's [`did:web` document](/docs/did-web) publishes two verification keys, both DANE-anchored, each fragment-identified by the SHA-256 hash of the key itself rather than a shared `#key-1`: an `authentication` key, and an `assertionMethod` key - the one that signs statements this agent makes, and, deliberately, the *same key* the [DANE `TLSA`](/docs/dane) record pins for its TLS leaf. There is no separate, agent-generated "output-signing key" to mint and no third slot to publish one into; the assertionMethod key already carried by the identity is the signing key.

`whisper sign file` puts that key to work: it asks the control plane for a per-agent *emailProtection* certificate over the same key, and produces a detached CMS (S/MIME, RFC 5652) signature over your file. The signer's exact public key is pinned by a DNSSEC-signed `SMIMEA` (RFC 8162) record, so a verifier resolves and checks it without a public S/MIME CA. Full mechanism, including encryption's mirror image, lives on [Sign & encrypt as your agent](/docs/sign-encrypt) - this page is the "why prove agent output" framing and a real, live-captured run of it.

## Sign it, then verify it two ways

**With Whisper:**

```bash
whisper sign file report.md
# whisper: signed report.md as agent@acc06de161238775a.{tenant}.agents.whisper.online -> report.md.p7s
#          verify (no key): whisper sign verify report.md --sig report.md.p7s

whisper sign verify report.md --sig report.md.p7s
# whisper: VERIFIED - signature is valid and the signer key is DANE-anchored by the DNSSEC
#          SMIMEA for agent@acc06de161238775a.{tenant}.agents.whisper.online (spki sha256 0e66b6d7f90adb6c).
#          Trust: DNSSEC/SMIMEA, not a public S/MIME CA.
```

That is a real run, captured live against a freshly registered agent for this page. Verification needs no Whisper account and no Whisper software of the signer's - just the ability to validate DNSSEC.

**With stock tools - the same signature, checked by hand.** `whisper sign file` writes an ordinary PEM-wrapped CMS `SignedData` object; `openssl` reads it directly:

```bash
# 1. the CMS signature is cryptographically valid over report.md (no DANE check yet):
openssl smime -verify -inform PEM -in report.md.p7s -content report.md -noverify -out /dev/null
# Verification successful

# 2. pull the signer cert out of the signature and hash its public key:
openssl pkcs7 -in report.md.p7s -print_certs -out signer.crt
openssl x509 -in signer.crt -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256
# 0e66b6d7f90adb6ca14d9cc0ced18003f65a5e003c5240ba15a7ad4e14140f02

# 3. the DNSSEC-signed pin for that exact key - the local-part hash for every Whisper
#    agent's signing cert is fixed (sha256("agent"), truncated per RFC 8162), so only
#    the {fqdn} changes from one agent to the next:
dig @1.1.1.1 +dnssec +short SMIMEA \
  d4f0bc5a29de06b510f9aa428f1eedba926012b591fef7a518e776a7._smimecert.acc06de161238775a.{tenant}.agents.whisper.online
# 3 1 1 0E66B6D7F90ADB6CA14D9CC0CED18003F65A5E003C5240BA15A7AD4E 14140F02
```

Step 2's hash equals step 3's pin, byte for byte - that comparison, made against a `TLSA` record whose signature you validated yourself with `delv`, *is* the trust: your own eyes replace the CA. No JOSE library, no `did:web` fetch, nothing but `openssl`, `dig` and `delv`. Validate with `delv` rather than reading the `AD` bit, which only tells you a resolver claims to have validated and is forgeable over plaintext UDP.

**With Whisper, trustless - the identity underneath the signature:**

```bash
whisper verify --trustless acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online
```
```
CHECK         RESULT  TRUST        DETAIL
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 == a published TLSA pin (1 published); …
transparency  pass    DNSSEC-root  root signature verified; 1 event(s), root_hash bound; ledger checkpoint verified (no leaf for this address)
identity_doc  pass    DNSSEC-root  JWS verified against the DNSSEC-anchored key; address/fqdn/tlsa claims match the DNSSEC-validated facts
whisper: acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online is CRYPTOGRAPHICALLY PROVEN - trust anchor: DNSSEC root (IANA anchor) + DANE-EE + DNSSEC-anchored transparency/ledger keys -- Whisper API NOT trusted
```

`--trustless` re-derives the whole chain from the DNSSEC root in-process instead of taking a server's word for it, so a forged or MITM'd answer fails the chain instead of silently handing you an attacker's key. Feed the exit code into a CI gate - `whisper verify --trustless "$fqdn" && whisper sign verify report.md --sig report.md.p7s` - and the whole thing fails closed.

## If you want a raw JOSE/JWS claim instead of CMS

Some downstream tooling wants a compact JWS, not a CMS envelope - a Verifiable Credential pipeline, for instance. The same `assertionMethod` key is usable there too, because it is the same key: resolve it from `did.json` by matching `id` against the `assertionMethod` array entry (never assume array position - a document can list its keys in either order), not a fixed `#sig-1` fragment. The full worked recipe - resolving the right `verificationMethod`, checking the ES256 signature, folding it into `whisper verify --trustless` - is on [did:web & verifiable credentials](/docs/did-web). What Whisper does not ship today is a CLI wrapper that *produces* an arbitrary JWS manifest for you the way `whisper sign file` produces a CMS one, so `whisper sign file` stays the one-command path for CMS. The JOSE route is a DIY assembly from the same DANE-pinned key, and rather than leave that as an exercise, here is the whole of it. It is stock `python3` and stock `openssl`: no JOSE library, no third-party package, nothing to install.

Save this as `sign_jws.py`:

```python
import base64, hashlib, json, subprocess, sys

key, payload_file = sys.argv[1], sys.argv[2]
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()

# 1. The kid is SHA-256 of the key's SubjectPublicKeyInfo: the same value the identity's
#    _whisper-agentkey TXT publishes and its DANE-EE 3 1 1 record pins.
spki = subprocess.run(["openssl", "pkey", "-in", key, "-pubout", "-outform", "DER"],
                      capture_output=True, check=True).stdout
header = {"alg": "ES256", "kid": hashlib.sha256(spki).hexdigest()}

# 2. The payload must NAME its signer ("fqdn" or "address"), because one key can be
#    published under two names and only the signed name attributes the bytes.
payload = json.load(open(payload_file))
si = (b64u(json.dumps(header, separators=(",", ":")).encode()) + "."
      + b64u(json.dumps(payload, separators=(",", ":")).encode()))

# 3. openssl emits ECDSA in DER; a JWS wants the RAW r||s pair, 64 bytes. Unwrap it.
der = subprocess.run(["openssl", "dgst", "-sha256", "-sign", key],
                     input=si.encode(), capture_output=True, check=True).stdout
i, raw = (2 if der[1] < 0x80 else 3), b""
for _ in range(2):
    n = der[i + 1]
    raw += der[i + 2:i + 2 + n].lstrip(b"\x00").rjust(32, b"\x00")
    i += 2 + n
print(si + "." + b64u(raw))
```

That is the exact inverse of the raw-to-DER conversion the [verify walkthrough](https://whisper.online/verify) does in step 4, and it is the step every hand-written JOSE recipe gets wrong, because `openssl dgst -sign` produces DER and a JWS wants the bare `r||s` pair.

```bash
# The identity key, generated once on the machine that will hold it. Its public half is
# the SPKI you hand to op:connect as identity_public_key, and the /128 is derived from
# that, so this one file is the root of everything below. It never leaves the machine.
openssl ecparam -name prime256v1 -genkey -noout -out identity-key.pem
chmod 600 identity-key.pem
openssl pkey -in identity-key.pem -pubout -outform DER | base64 | tr -d '\n'

# On the agent. frame.json is whatever you are attesting to, and it must name its signer.
python3 sign_jws.py identity-key.pem frame.json > frame.jws

# On the counterparty: one command, and no key to fetch. It resolves the agent's key from
# its DNSSEC-signed _whisper-agentkey TXT, validates that chain to the IANA root in-process,
# and trusts no Whisper API and no HTTPS. Works from a v4-only host: this is DNS, not TLS
# to the /128.
whisper verify --signature frame.jws --payload-out verified-frame.json {the-agents-128}
```

```
FIELD           VALUE
signer          a5c36d07faf155bbb.{tenant}.agents.whisper.online
address         2a04:2a01:9f31:f8d8:5c36:d07f:af15:5bbb
key_record      _whisper-agentkey.a5c36d07faf155bbb.{tenant}.agents.whisper.online
signed_by_kid   03cf98d3a793daba6fcd6b43fbe04af67e5b542d7c3dcb067fe0d01ec035a211
published_kids  03cf98d3a793daba6fcd6b43fbe04af67e5b542d7c3dcb067fe0d01ec035a211
payload_sha256  de3aa1e71d7654b92ebf901d67766468e9fb73c406fc3266bab8642cc39decf9

CHECK            RESULT  TRUST        DETAIL
dnssec           pass    DNSSEC-root  AAAA, PTR and TLSA(3 1 1) all DNSSEC-validated to the IANA root; 2a04:2a01:9f31:f8d8:5c36:d07f:af15:5bbb <-> a5c36d07faf155bbb.{tenant}.agents.whisper.online consistent
agent_key        pass    DNSSEC-root  _whisper-agentkey.a5c36d07faf155bbb.{tenant}.agents.whisper.online publishes 1 per-agent key(s) (kid 03cf98d3a793daba6fcd6b43fbe04af67e5b542d7c3dcb067fe0d01ec035a211), DNSSEC-validated to the IANA root and signed by agents.whisper.online
agent_signature  pass    DNSSEC-root  ES256 verified under kid 03cf98d3a793daba6fcd6b43fbe04af67e5b542d7c3dcb067fe0d01ec035a211, one of the keys published at _whisper-agentkey.a5c36d07faf155bbb.{tenant}.agents.whisper.online; payload sha256 de3aa1e71d7654b92ebf901d67766468e9fb73c406fc3266bab8642cc39decf9
dane_binding     pass    DNSSEC-root  the signing key is the same key the agent's DANE-EE TLSA pins (kid == the 3 1 1 association value)
signed_claims    pass    pin+DNSSEC   1 identity claim(s) in the payload match the DNSSEC-validated signer
did_assertion    skip    DNSSEC-root  did.json unavailable over DANE (fetch identity_doc https://a5c36d07faf155bbb.{tenant}.agents.whisper.online/.well-known/did.json: Get "https://a5c36d07faf155bbb.{tenant}.agents.whisper.online/.well-known/did.json": dial tcp [2a04:2a01:9f31:f8d8:5c36:d07f:af15:5bbb]:443: i/o timeout); the DNS key stands alone
whisper: verified payload -> verified-frame.json
whisper: a5c36d07faf155bbb.{tenant}.agents.whisper.online's own key 03cf98d3a793daba... signed these bytes - proven from the DNSSEC root, no Whisper API trusted.
         this key's private half is held by the agent OR, under hosted custody, by Whisper on the agent's behalf; a pass proves the bytes were signed by THIS agent's key, not by a member identity the platform cannot fabricate. A routed agent supplies its own public key, and a public key can be supplied twice, so the NAME comes from the signed payload's own claim, never from the key alone
```

Exit 0, and `verified-frame.json` written, only if that agent's own published key signed those exact bytes. Flip one byte of the payload and `agent_signature` reads `jws: ES256 signature does NOT verify under kid ...`; sign with a key the agent never published and it reads `jws: no published key for kid ...`. Both exit 1 and write nothing, so a caller can gate on the exit status alone and never handle unverified bytes.

Four rows decide the verdict: `dnssec`, `agent_key`, `agent_signature` and `signed_claims`. `dane_binding` and `did_assertion` are corroboration: they may legitimately `skip`, but a corroboration row that `fail`s still sinks the verdict, so skipping and failing are not the same thing here. The capture above was taken on a host with no working public IPv6, and `did_assertion` can only ever skip there, because reading `did.json` means opening a DANE-pinned connection to the agent's IPv6 `/128`. That is the honest shape of this recipe on the host it recommends: the signature leg needs nothing but DNS, and the leg that needs the agent to be reachable says so rather than quietly passing. The error it reports is an `i/o timeout` rather than `network is unreachable`, which is what a route to nowhere looks like as opposed to no route at all. The names, the kid and the digests are one live identity's, with the tenant segment elided; yours will read differently.

Read the last two lines rather than skipping them. They are what the tool prints to bound its own claim: the pass proves *this agent's key* signed the bytes, and under hosted custody that private half is held by Whisper on the agent's behalf rather than by the agent. On the routed tier these recipes provision, the agent supplies its own key and holds it. The distinction matters wherever the word non-repudiation is doing work, so the tool says it every time and this page does not trim it away.

## Bonus: an SSHFP record, already there

Every Whisper agent also publishes an [`SSHFP`](https://www.rfc-editor.org/rfc/rfc4255) (RFC 4255) record automatically, derived from the same per-agent key - no separate upload step:

```bash
dig +short SSHFP acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online
# 3 2 3F6EEA438E6CA3ECC6777FA6DE17CEB0A789202D63D0B27A8CCF56BF 295B8139
```

That is algorithm `3` (ECDSA), fingerprint type `2` (SHA-256) - not the Ed25519/`4` some SSHFP examples elsewhere assume; match the algorithm byte against what your own tooling expects rather than assume. `SSHFP` is the DNSSEC-anchored analogue of `TLSA` for SSH host and signing keys - it is what lets `ssh`'s own `VerifyHostKeyDNS` skip `known_hosts`, and it is the same DNS-anchored-key pattern this whole page rests on, published for you the moment the identity exists.

## Why this beats a "signed by" footer - and what it doesn't do

A text footer is unforgeable only in the sense that forging it takes zero effort; anyone can type "signed by Agent-7." A DNS-anchored signature is unforgeable in the sense that matters: producing a valid one requires the private key an attacker doesn't have, and checking one requires nothing but a resolver and `openssl` the recipient probably already ships. This is the same idea behind keyless code-signing schemes like Sigstore, minus the OIDC identity provider and the Fulcio CA in the middle - the identity binding here is DNSSEC + DANE, a chain that terminates at the IANA root instead of at a third-party signing service, and it works completely offline from any CI provider.

What signing an output does **not** prove is *when* - a signature alone can't stop someone from claiming an output existed earlier than it did. That's a separate, composable proof: see [OpenTimestamps & Bitcoin anchoring](/docs/opentimestamps) for pinning the *time* half onto the same chain, and [Transparency log](/docs/transparency) for making the issuance of the signing key itself independently auditable.

## Next

[Sign & encrypt as your agent](/docs/sign-encrypt) for the full `whisper sign`/`whisper encrypt` mechanism this page runs · [did:web & verifiable credentials](/docs/did-web) for the document the key lives in · [DANE & DNSSEC](/docs/dane) for the chain that anchors it.
