Key Custody and Selective Disclosure

Asqav lets a customer decide who holds the signing key, who co-signs an action, and how much of a receipt a verifier gets to see. These are signer-side controls a regulated buyer asks for: a human approval bound into the receipt, a private key that never enters Asqav's database, a payload Asqav signs without ever reading, and a way to disclose one field of a receipt without revealing the rest. Each control on this page is deployed and callable from the asqav SDK today.

Co-sign with a user-held key (WebAuthn and FIDO2)

Some actions should not be the agent's call alone. Asqav accepts a second signature from the end user, produced by a hardware passkey or platform authenticator, and binds it into the same receipt.

The sign call takes a user_intent envelope: a signature the end user produced over the asserted bytes, in one of three algorithms, ed25519, ecdsa-p256, or webauthn. For the WebAuthn algorithm, Asqav parses the COSE credential public key, reconstructs the authenticator data and client-data hash, and verifies the FIDO2 assertion server-side before the receipt is signed. Successful verification confirms the signature on the supplied bytes. Your relying party must also validate the challenge, RP ID, origin, user-presence requirements and binding between the credential and the user before treating it as authorization.

python
receipt = agent.sign(
    "payment:transfer",
    {"amount": 5000, "to": "acct_9f2"},
    user_intent={
        "algorithm": "webauthn",
        "public_key": "<COSE credential public key>",
        "signature": "<FIDO2 assertion>",
        "signed_message": "<authenticatorData || sha256(clientDataJSON)>",
    },
)

The TypeScript client takes the same envelope as userIntent. When Asqav cannot verify the assertion, the receipt is refused rather than signed without the co-signature.

Keep the private key out of Asqav's database

For the default hosted signer, Asqav holds the agent key encrypted at rest with AES-256-GCM. Customers who want the key never to exist in Asqav's database choose a customer-managed key instead.

When an agent is configured for a customer KMS, the agent's secret_key column is null and the key lives in the customer's KMS or HSM. Asqav supports AWS KMS and GCP KMS for ML-DSA-65, both backed by a FIPS-validated HSM, selected per deployment. The signer calls the KMS to sign. The private key material never enters Asqav's process or storage. See Key Management for the configuration.

For deployments that want the key and the payload to stay on the customer side entirely, the signer runs as a customer-deployed container and forwards only digests upstream. See Self-hosted Signer and Out-of-process signing.

Sign a payload Asqav never reads

For the SDK signing path, mode="hash-only" hashes the action context locally and sends the digest with allowed metadata. Set the mode when initializing the SDK; Agent.sign() accepts the context, not a hash argument.

python
import os
import asqav

asqav.init(api_key=os.environ["ASQAV_API_KEY"], mode="hash-only")
agent = asqav.Agent.get(os.environ["ASQAV_AGENT_ID"])
receipt = agent.sign(
    "filesystem:write",
    context={"path": "/example/report.txt", "operation": "write"},
)

Retain the original context if a recipient will need to recompute its digest. Signature verification and comparison with the original context are separate checks. See the Data handling page for metadata, hashing and optional capture features.

Disclose one field, withhold the rest

A receipt sometimes needs to prove one fact to an outside service without handing over everything. Asqav issues a post-quantum SD-JWT (selective-disclosure JWT) so a holder can reveal a chosen subset of claims and keep the rest sealed.

The token is signed with ML-DSA-65 and carries a hash for each disclosable claim. The holder presents only the disclosures it chooses. A verifier re-derives each disclosed claim's hash, confirms it belongs to the signed set, and rejects any disclosure that does not. The verifier gets cryptographic assurance that each revealed value was part of the original signed token, without seeing the claims that stayed sealed.

python
token = agent.issue_sd_token(
    claims={"tier": "enterprise", "region": "eu", "seat_count": 40},
    disclosable=["tier", "region", "seat_count"],
)

# Reveal only the tier to the downstream service.
presented = token.present(disclose=["tier"])

Require more than one signer (threshold signing)

For the highest-assurance actions, no single party should hold the whole key. Asqav supports threshold ML-DSA signing: the key is split into shares with a Shamir secret-sharing scheme, and a configurable number of participants must each contribute a partial signature before the combiner produces the final receipt signature. The shares are encrypted at rest.

python
config = asqav.create_signing_group("agt_9f2", min_approvals=2, total_shares=3)

A signing session collects the required approvals, each producing a partial signature, and only a quorum of participants can produce a valid receipt. No single share, and no single participant, can sign alone. See Multi-Party Signing for the session flow.

How these compose

The controls stack. An action can be hash-only so Asqav never reads the payload, co-signed by a user passkey, signed with a customer-managed key that never enters Asqav's database, and gated behind a threshold of approvers. Whatever the combination, the result is the same wire format, verifiable against the public key at GET /.well-known/jwks.json, with no Asqav account required by the reader.

Read the directory: key_thumbprint and the public_key migration window

The public directory at GET /.well-known/jwks.json publishes every verification key as an RFC 9964 AKP JWK: kty is AKP, alg names the ML-DSA level, and pub carries the raw public key in unpadded base64url. A third-party JOSE library reads pub directly; no other JWK member carries key material (the older public_key member repeats the same bytes in padded base64 for readers still on it).

Each entry also carries key_thumbprint, the SHA-256 hash (as sha256:<64 hex>) of the JCS-canonical {"alg", "kty", "pub"} object. The receipt signs this value, and it is the only member a receipt signs to name exactly one key: recompute it from the three published members and it must match, byte for byte.

python
import hashlib, json
jwk = {"alg": entry["alg"], "kty": entry["kty"], "pub": entry["pub"]}
digest = "sha256:" + hashlib.sha256(json.dumps(jwk, separators=(",", ":")).encode()).hexdigest()
assert digest == entry["key_thumbprint"]

Migration window: pub is canonical from 2026-10-18. The older public_key member (padded base64 of the same bytes) stays until no earlier than 2026-11-18, and only goes once the shipped SDKs resolve directory keys from pub alone. Retiring it changes no thumbprint and breaks no receipt: thumbprints were always computed over {alg, kty, pub}, so verification is unaffected.

Related

References