Sign application payloads without exposing your keys

Sign application payloads using a key the VTA holds, instead of distributing signing keys to every service instance. The private key never leaves the server.

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:

Your applicationapp authentication keyVTAcontext signing keypayloadsignatureSigning key never leaves:only the signature returns to your app.Your applicationVerifierany third partypayload + signatureVerified independently:no VTA access is needed to check the signature.

Two keys, two purposes

This flow involves two distinct keys:

KeyWhere it livesWhat it does
App authentication keyYour secrets managerSigns the auth challenge that proves your app’s identity to the VTA. Never used for application signing.
Context signing keysInside the VTA onlySigns 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).

  • pnm installed and configured with a working VTA connection.

    cargo install pnm-cli@0.16.4 --locked --registry crates-io
  • Super-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:00

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

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

credential.json now contains:

{
  "did":                 "did:key:z6Mk...",
  "privateKeyMultibase": "z...",
  "vtaDid":              "did:webvh:..."
}

If vtaUrl is absent, your VTA’s public_url is not configured. Set VTA_URL as 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.


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 run

Expected 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 active

The 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-app

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 without the private key ever reaching your process. That is the outcome this guide exists to produce.

Troubleshooting

SymptomLikely causeFix
pnm bootstrap open fails with digest mismatchThe 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 failedThe 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 runtimeNo 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 foundThe 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 deniedThe credential role is below application.Re-provision the credential with --role application.
sign over DIDComm times out with no errorThe 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 foundVTA_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_urlNo 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 setThe 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.

  Explore a sample implementation of VTA signing with Git

  Mint, inspect, and retire signing keys: add keys to this context, or revoke the one you just created.

  Grant and revoke access: withdraw this application’s credential when the service is retired.

  Manage the contexts on a VTA: remove the context and everything in it once the application is decommissioned.

  Backup VTA instance data