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.
- A compromised key cannot be revoked without taking the service offline.
The VTA holds the signing keys on the server instead: the private key never leaves, and every signing event is recorded in the 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 every signing event 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; the private key never leaves. |
The App authentication key is minted by the VTA and exported to you once in the sealed bundle. The context signing keys are derived from the VTA’s own seed and are never exported.
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.16.4 --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. Each context is isolated: one app’s identity cannot reach another app’s signing keys. 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
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 bootstrap protocol secures credential delivery using sealed transfer, the same mechanism every credential-bearing VTA operation uses. pnm bootstrap request generates a local X25519 keypair and writes only the public key to request.json. The VTA encrypts the credential bundle to that public key. Only the machine holding the matching private key can open bundle.txt. Intercepting the file is not enough to extract the credential.
# 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 grants the Sign capability but no access to key management, ACL changes, or context administration. If the credential is ever compromised, an attacker can call the signing endpoint but cannot extract keys, modify the ACL, or issue credentials to new identities.
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 the VTA created 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 at runtime:VtaClient::from_credential(&cred, Some(&vta_url)).await?.
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 anything reading /proc or a crash dump can see 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
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. Your application code doesn’t need to know which transport it used.
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 never leave the server. Keeping them centralised means you can:
- Rotate signing keys without redeploying your application.
- Audit every signing event in the VTA logs.
- 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 never leaves the server. 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 enforces a 1 MB request-body limit on every authenticated call, including sign. 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.34.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 never leave 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 my-app --status activeThe key minted in Step 2 appears, with Status: active. Its Key ID 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>DID: did:key:z6Mk...
Role: application
Contexts: my-appRole: 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 without the private key ever reaching 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) returns authentication failed | The credential JSON is malformed, or the VTA URL is unreachable. | Validate credential.json is valid JSON; set VTA_URL if vtaUrl is absent from the file. |
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 returns key not found | The key_id is from a different context or the key has been revoked. | Call list_keys again to get a current key ID from the correct context. |
sign returns access denied | 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 DIDComm row. |
trust task failed [unsupportedType]: unsupported type: .../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, with no private key material in your service binary or configuration. Every signing event is 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.