WhisperDocs
Recipes

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 and DANE TLSA 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 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 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 - 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:

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:

# 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, done under a DNSSEC AD=1 answer, is the trust: your own eyes replace the CA. No JOSE library, no did:web fetch, nothing but openssl and dig.

With Whisper, trustless - the identity underneath the signature:

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. 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:

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 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.

# 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 agent's /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 fails 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 (RFC 4255) record automatically, derived from the same per-agent key - no separate upload step:

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.

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 for pinning the time half onto the same chain, and Transparency log for making the issuance of the signing key itself independently auditable.

Next

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