Sign application payloads without exposing your keys
By the end of this guide, your application signs payloads using a key the VTA holds and manages on your behalf.
This guide registers your application as its own identity on the VTA, mints a signing key inside a context reserved for it, and has the application call the VTA to sign rather than holding the key itself. For how contexts isolate one application’s keys from another’s, see Keys and contexts.
Without remote signing, application signing keys must be distributed to every service instance:
- Rotating a key requires a redeployment.
- Signing events have no central audit trail.
- Revoking a compromised key means redeploying every instance that holds it.
The VTA holds the signing keys instead. Your application receives only signatures, and the VTA records each successful signing call in its audit trail.
Use this guide when:
- Your application needs to sign payloads using a key that is managed centrally, not distributed to each instance.
- You want a central audit trail of signing calls across all app instances.
- You want to rotate or revoke signing keys without redeploying the service.
This guide has two parts. Part 1 (Steps 1–4) runs once on the developer’s machine: create a context, mint a signing key, provision app credentials, and store them in a secrets manager. Part 2 (Steps 5–6) is the app runtime: load the credential, authenticate, and call the signing endpoint. Once Part 1 is done, every runtime signing call follows the flow below:
Two keys, two purposes
This flow involves two distinct keys:
| Key | Where it lives | What it does |
|---|---|---|
| App authentication key | Your secrets manager | Signs the auth challenge that proves your app’s identity to the VTA. Never used for application signing. |
| Context signing keys | Inside the VTA only | Signs your application payloads. The VTA signs on your behalf and returns only the signature. |
The App authentication key is generated by the producer’s pnm when it runs pnm auth-credential create, registered in the VTA’s ACL by its DID only, and delivered to you once in the sealed bundle, so its private half stays between the producer and you. The context signing keys are derived from the VTA’s own seed and stay on the VTA, which signs with them on request. An admin holding the key-export capability can export a derived key unless it is marked non-exportable, as described in Security model.
Prerequisites
Running appliance of Verifiable Trust Agent (VTA).
Install Rust 1.95 or later on your machine (required for compiling the Personal Network Manager (PNM) CLI).
pnminstalled and configured with a working VTA connection.cargo install pnm-cli@0.23.1 --locked --registry crates-ioSuper-admin access to the VTA (required for
pnm contexts create).
Step 1: Create a context
A context groups a set of VTA-held signing keys with an access-control list. The access control list (ACL) records which DIDs may use those keys and at what role. An app identity scoped to one context can use only that context’s signing keys and those of its sub-contexts. See Contexts for how context isolation works across a VTA.
pnm contexts create \
--id signing-app \
--name "Signing App"Sample output:
Context created:
ID: signing-app
Name: Signing App
Base Path: m/26'/2'/2'Step 2: Mint a signing key
The context created in Step 1 holds no keys by default. Your app’s runtime code lists active Ed25519 keys in the context and uses the first one found to sign. Without at least one key, every signing call fails with no active Ed25519 key found in context. The application role provisioned in Step 3 can call the signing endpoint. Creating keys stays an admin-only operation.
pnm keys create \
--key-type ed25519 \
--context signing-app \
--label "signing-app-key-1"Sample output:
Key created:
Key ID: m/26'/2'/2'/0'
Key Type: ed25519 (classical signing)
Derivation Path: m/26'/2'/2'/0'
Public Key: z6MkvZqY2CbpWQmbjuuNshtAptH8qbFaCGk3w6Jt72xBXiGi
Status: active
Label: signing-app-key-1
Created At: 2026-01-15 10:24:07 +08:00The Key ID is the same value as the Derivation Path: a BIP-32 path, not a short opaque ID.
Your app discovers the key by context and key type at runtime. You do not need to hard-code the Key ID.
Step 3: Provision an app identity
The three commands below deliver the new credential through sealed transfer, with the work split between two roles. One person on one machine can play both:
- Recipient: the machine where the credential will be used.
pnm bootstrap requestcreates a one-time key pair, keeps the private key in PNM’s local configuration directory, and writes only the public key, a nonce, and an optional label to the request file. The recipient later runspnm bootstrap openon the same machine, since only that private key can open the bundle. - Producer: an administrator whose PNM is connected to the VTA.
pnm auth-credential createcreates the new DID, adds it to the context’s access control list (ACL), seals its credential to the public key in the request file, and prints a SHA-256 digest of the sealed bundle.
The digest proves that the bundle the recipient opens is the one the producer sealed, because pnm bootstrap open --expect-digest refuses any other bundle. When the roles run on separate machines, move the request file to the producer over a channel you trust, since the producer seals the credential to whichever public key that file carries. The bundle can travel back over any channel, provided the digest reaches the recipient separately over a channel you trust, for example read out over a call.
Here the recipient is the machine that runs your signing application, and it opens the bundle in Step 4.
# Store the X25519 secret locally so the bundle can be opened in step 4.
pnm bootstrap request --out request.json
# Create the app DID, register it in the signing-app context with the application
# role, and seal the credential bundle to your bootstrap request.
# The armored bundle goes to bundle.txt; the SHA-256 digest prints to
# stderr, so it stays visible in your terminal. Copy it for step 4.
pnm auth-credential create \
--role application \
--contexts signing-app \
--label "signing-app-auth" \
--recipient request.json > bundle.txtThe application role covers signing and the other application operations listed under Roles, in this context and its sub-contexts. Key management, ACL changes, and context administration stay outside the application role. If the credential is ever compromised, an attacker can do whatever the application role allows in this context, so store it as you would any other production secret.
Step 4: Open the bundle and store the credential
Last step on your developer machine. After this, the bundle and request.json can be deleted.
--expect-digest verifies that the bundle you received is the one pnm auth-credential create sealed in Step 3. If someone substituted a different bundle between creation and delivery, the digest check fails and the command exits with an error. This prevents a scenario where an attacker replaces the bundle with one that contains their own credentials rather than the app’s.
pnm bootstrap open \
--bundle bundle.txt \
--expect-digest <digest-from-step-3> \
--out credential.jsoncredential.json now contains:
{
"did": "did:key:z6Mk...",
"privateKeyMultibase": "z...",
"vtaDid": "did:webvh:..."
}If
vtaUrlis absent, your VTA’spublic_urlis not configured. SetVTA_URLas an environment variable and pass it as thevta_urlfield ofAutoConnect, for examplevta_url: &std::env::var("VTA_URL")?.
Store credential.json as a single secret in your secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault, etc.). The bundle and request.json are single-use and can be deleted.
credential.json carries the application’s private key. Never commit it or hardcode it into an image: inject it from your secrets manager at runtime, and delete bundle.txt and request.json once the credential is stored.
The export VTA_CREDENTIAL=... form used in the example below is convenient for a local run. It also places the key in your shell history and in the process environment, where processes running as the same user, root, or a crash dump can read it. In production, read the credential from your secrets manager inside the process instead.
Confirm the credential is not echoed in logs or debug output. Rotate it through Rotate an application credential if it is ever exposed.
Step 5: Authenticate at runtime
The code in this guide uses vta-sdk, the Rust client library for the VTA, published on crates.io with API documentation on docs.rs. pnm is built on the same crate. From another language, call the VTA’s HTTPS API directly or connect through the MCP bridge, as described in Which client to use for your language.
Your app loads the CredentialBundle JSON from the secrets manager and passes it to the SDK. VtaClient::connect_auto checks whether a mediator is configured. If not, it connects over REST. If one is configured, it connects over DIDComm through that mediator. For signing, the same calls work on either transport. Over REST, connect_auto returns a short-lived bearer token without refreshing it, so a long-running service calls connect_auto again when the token expires.
use vta_sdk::client::{AutoConnect, VtaClient};
use vta_sdk::credentials::CredentialBundle;
let cred_json = std::env::var("VTA_CREDENTIAL").map_err(|_| "VTA_CREDENTIAL not set")?;
let cred: CredentialBundle = serde_json::from_str(&cred_json)?;
// Set VTA_MEDIATOR_DID to connect over DIDComm; leave it unset for REST.
let mediator_did = std::env::var("VTA_MEDIATOR_DID").ok();
let connected = VtaClient::connect_auto(AutoConnect {
vta_url: cred.vta_url.as_deref().unwrap_or_default(),
vta_did: &cred.vta_did,
credential_did: &cred.did,
private_key_multibase: &cred.private_key_multibase,
mediator_did: mediator_did.as_deref(),
})
.await?;
let client = connected.client;Step 6: Call signing and crypto operations
With the client from Step 5, your app requests signing from the VTA. The context signing keys stay on the VTA, and only signatures come back. Keeping them centralised means you can:
- Rotate signing keys without redeploying your application.
- Review each successful signing call in the VTA audit trail.
- Enforce which contexts an app identity can sign under.
When your app is done, call client.shutdown().await. It’s a no-op over REST, so it’s safe to call unconditionally.
The App authentication key your app holds locally is used only for the authentication handshake in Step 5. It plays no role in signing application payloads.
List keys in your context
let keys = client
.list_keys(0, 50, Some("active"), Some(&context_id))
.await?;
for k in &keys.keys {
println!("{:?} {}", k.key_type, k.key_id);
}Sign a payload
Select the key from the list above and pass your application payload as raw bytes. The VTA signs remotely and returns the base64url-encoded signature. The signing key stays on the VTA. Replace b"hello world" with your actual payload.
use vta_sdk::protocols::key_management::sign::SignAlgorithm;
let key = keys
.keys
.iter()
.find(|k| matches!(k.key_type, vta_sdk::keys::KeyType::Ed25519))
.ok_or("no active Ed25519 key found in context")?;
let response = client
.sign(&key.key_id, b"hello world", SignAlgorithm::EdDSA)
.await?;The supported algorithms are EdDSA (Ed25519 keys) and ES256 (P-256 keys). The algorithm must match the key type.
The VTA signs a domain-separated form of your payload: the tag vti.vta.opaque-signing.v1, a zero byte, then the payload. A verifier checks the signature over that same input rather than over the raw payload.
Over REST, the VTA limits a request body, sign included, to 1 MB. A larger payload is rejected before signing runs. If your application payload can exceed this, sign a digest of the payload instead of the payload itself.
Full example
The service below authenticates with the credential from Step 4, lists the active Ed25519 keys in the context, and signs a sample payload. The signing key stays on the VTA throughout. Only the signature is returned to your application. Replace b"hello world" with your actual payload bytes and CONTEXT_ID with the context created in Step 1.
Create a new project with the following Cargo.toml and src/main.rs:
[package]
name = "vta-signing-service"
version = "0.1.0"
edition = "2024"
[dependencies]
vta-sdk = { version = "0.52.0", features = ["session"] } # add "acl-setup" too if you connect through a mediator
# Async runtime
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
# JSON deserialization for CredentialBundle
serde_json = "1"use vta_sdk::client::{AutoConnect, VtaClient};
use vta_sdk::credentials::CredentialBundle;
use vta_sdk::protocols::key_management::sign::SignAlgorithm;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cred_json = std::env::var("VTA_CREDENTIAL").map_err(|_| "VTA_CREDENTIAL not set")?;
let cred: CredentialBundle = serde_json::from_str(&cred_json)?;
// Set VTA_MEDIATOR_DID to connect over DIDComm; leave it unset for REST.
let mediator_did = std::env::var("VTA_MEDIATOR_DID").ok();
let connected = VtaClient::connect_auto(AutoConnect {
vta_url: cred.vta_url.as_deref().unwrap_or_default(),
vta_did: &cred.vta_did,
credential_did: &cred.did,
private_key_multibase: &cred.private_key_multibase,
mediator_did: mediator_did.as_deref(),
})
.await?;
let client = connected.client;
let context_id = std::env::var("CONTEXT_ID").map_err(|_| "CONTEXT_ID not set")?;
// Run the fallible work in a block so shutdown() below always runs,
// even when an early `?` returns an error.
let result: Result<String, Box<dyn std::error::Error>> = async {
// List active keys in the context; signing keys stay on the VTA.
let keys = client
.list_keys(0, 50, Some("active"), Some(&context_id))
.await?;
for k in &keys.keys {
println!("{:?} {}", k.key_type, k.key_id);
}
// Sign with the first active Ed25519 key.
let key = keys
.keys
.iter()
.find(|k| matches!(k.key_type, vta_sdk::keys::KeyType::Ed25519))
.ok_or("no active Ed25519 key found in context")?;
let response = client
.sign(&key.key_id, b"hello world", SignAlgorithm::EdDSA)
.await?;
Ok(response.signature)
}
.await;
// shutdown() must run whether the block above succeeded or failed:
// dropping a DIDComm client without it panics in debug builds.
client.shutdown().await;
println!("Signature: {}", result?);
Ok(())
}Export the credential and context, and run the service:
export VTA_CREDENTIAL=$(cat credential.json)
export CONTEXT_ID=signing-app
# Optional: set this to connect over DIDComm instead of REST.
# export VTA_MEDIATOR_DID=<mediator-did-from-pnm-services-list>
cargo runExpected output:
Ed25519 m/26'/2'/2'/0'
Signature: UbiqkBl5tEarCAW-JqE9yDT_mVkrB...Confirm
Run these against your own context and credential, rather than relying on the full example above having compiled.
Test 1: the signing key exists in the context
pnm --vta <vta-slug> keys list --context signing-app --status activeThe key minted in Step 2 appears, with active in its detail row. Its key ID, the first line of the entry, is the BIP-32 derivation path, not a short opaque identifier.
Test 2: the app credential holds the application role, scoped to the context
pnm acl get <did-from-credential.json>Name: signing-app-auth
DID: did:key:z6Mk...
Role: application
Contexts: signing-app
Capabilities: (none granted by name — everything `application` allows)
Created At: 1789525447
Created By: did:key:z6MkAdmin...Role: admin here means the credential was provisioned with more authority than the guide intends. Re-run Step 3 with --role application.
Test 3: the application signs and returns a signature
Running the Step 6 code against the context returns a base64url signature:
Ed25519 m/26'/2'/2'/0'
Signature: UbiqkBl5tEarCAW-JqE9yDT_mVkrB...The signature came back, and no private key material reached your process. That is the outcome this guide exists to produce.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
pnm bootstrap open fails with digest mismatch | The bundle was modified in transit, or the digest from a different run was used. | Re-run Steps 3–4: generate a new bundle and use its digest. |
connect_auto (REST path) fails with an authentication or network error | The VTA URL is unreachable, or the credential’s DID is not in the VTA’s ACL. A malformed credential.json fails earlier, when it is parsed. | Set VTA_URL if vtaUrl is absent from the file, and confirm the DID with pnm acl get <did>. |
no active Ed25519 key found in context at runtime | No Ed25519 signing key has been minted in the context, or all existing keys have been revoked. | Run Step 2 of setup: pnm keys create --key-type ed25519 --context signing-app. |
sign fails with key <id> not found, cannot sign with a revoked key, or no access to context | The key ID doesn’t exist, the key was revoked, or it belongs to a context the credential can’t reach. | Call list_keys again to get a current, active key ID from the correct context. |
sign fails with application role or higher required | The credential role is below application. | Re-provision the credential with --role application. |
sign over DIDComm times out with no error | The acl-setup feature is not enabled, so a mediator enforcing ExplicitAllow ACL mode never registered your service DID and silently drops the reply. | Add "acl-setup" to the features list in Cargo.toml (see the comment above). |
connect_auto (DIDComm path) fails with DID not found | VTA_MEDIATOR_DID is incorrect or refers to a different VTA. | Run pnm services list and copy the DID from the Mediator: line under DIDComm:. |
peer does not serve https://trusttasks.org/spec/keys/list/0.1 (DIDComm only, REST works) | Your VTA appliance is running a version older than when keys/list/0.1 was wired into the DIDComm trust-task dispatcher. | Upgrade the appliance, or unset VTA_MEDIATOR_DID so connect_auto falls back to REST until it’s upgraded. |
connect_auto returns REST transport requires a non-empty vta_url | No mediator was supplied and the credential has no vtaUrl. | Set VTA_URL and pass it, or supply VTA_MEDIATOR_DID to use the DIDComm path instead. |
VTA_CREDENTIAL not set or CONTEXT_ID not set | The variable isn’t set in the current shell. export only persists for that terminal session. | Re-run the export commands before cargo run in the same terminal. |
Next steps
Your application signs payloads using centrally managed keys, and the only key your service holds is its own authentication key, injected from your secrets manager. Each successful signing call is recorded in the VTA audit trail, and you can rotate or revoke keys without redeploying the service.
Glad to hear it! Please tell us how we can improve more.
Sorry to hear that. Please tell us how we can improve.
Thank you for sharing your feedback so we can improve your experience.