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. Your application receives only signatures, and the private key stays on the VTA.

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:

Your applicationapp authentication keyVTAcontext signing keypayloadsignatureSigning key stays on the VTA:only the signature returns to your app.Your applicationVerifierany third partypayload + signatureVerified independently:against the opaque-signing input, without calling the VTA.

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

  • pnm installed and configured with a working VTA connection.

    cargo install pnm-cli@0.23.1 --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. 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: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 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 request creates 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 runs pnm bootstrap open on 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 create creates 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.txt

The 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.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 as the vta_url field of AutoConnect, for example vta_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.


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. 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 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 signing-app --status active

The 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

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) fails with an authentication or network errorThe 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 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 fails with key <id> not found, cannot sign with a revoked key, or no access to contextThe 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 requiredThe 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 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_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, 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.

  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