orbseal/Docs

Encryption

Orbseal uses end-to-end encryption for secrets. The server stores only ciphertext — it never receives or holds the plaintext value of any secret.


Key model

Every app generates a keypair:

Private key (orbsk-...) Stays on your infrastructure. Never sent to Orbseal.
Public key (orbpk-...) Registered with Orbseal when you create an app key.

Keys use X25519 Diffie-Hellman for key exchange and XSalsa20-Poly1305 for symmetric encryption, implemented via libsodium's crypto_box_seal.


Generating a keypair

npx orbseal keygen
Recovery phrase — write this down and store safely:

  witch collapse practice feed shame open despair creek road again ice least

  public key   orbpk-6VqoWhXczDR1ydCHVFxhNhv8uQaWLicCHRiXQz5fXRet
  private key  orbsk-F1NdfoJULGa8qdaWr6xAbWeYz7bc5uu6EFwZ3AznGHua

The recovery phrase uses BIP-39 and can regenerate the keypair if you lose the private key file:

npx orbseal recover "witch collapse practice feed shame open despair creek road again ice least"

Sealing a secret

# Save your public key to context once
npx orbseal use --public-key "orbpk-6VqoWhXczDR1..."

# Seal a secret — prompts for plaintext, never logged
npx orbseal seal taskflow:database_url --scope environment --ref production
# Secret value: [hidden]

What happens client-side:

  1. Plaintext value entered in terminal (hidden input)
  2. crypto_box_seal(plaintext, recipient_public_key) runs locally in Node.js
  3. Only the ciphertext is sent to the API — Orbseal never sees the plaintext
PUT /v1/workspaces/:ws/projects/:proj/secrets/:plugin/:key
{
  "ciphertext": "wM2pSljqMNJNrbBAgTo9...",
  "public_key":  "m1pJN0blB7vdOtYlYn9d...",
  "scope":       "environment",
  "scope_ref":   "production"
}

Resolving secrets at runtime

When your app calls /v1/config/resolve, Orbseal returns the ciphertext sealed for your app's public key. Your app decrypts it locally using the private key:

import _sodium from 'libsodium-wrappers';

const { config, secrets } = await fetch(
  'https://api.orbseal.com/v1/config/resolve',
  { headers: { Authorization: `Bearer ${process.env.ORBSEAL_TOKEN}` } }
).then(r => r.json());

await _sodium.ready;
const s  = _sodium;
const pk = s.from_base64(process.env.ORBSEAL_PUBLIC_KEY,  s.base64_variants.ORIGINAL);
const sk = s.from_base64(process.env.ORBSEAL_PRIVATE_KEY, s.base64_variants.ORIGINAL);

const dbUrl = s.to_string(
  s.crypto_box_seal_open(
    s.from_base64(secrets['taskflow:database_url'], s.base64_variants.ORIGINAL),
    pk, sk
  )
);

Threat model

Threat Mitigation
Orbseal server compromised Attacker gets ciphertext only — useless without app's private key
Network interception TLS + ciphertext in transit
Admin token leaked Attacker can set new ciphertext, but cannot read existing secrets
Private key lost Recover from BIP-39 phrase
Private key compromised Rotate: generate new keypair, re-seal all secrets

Rotating secrets

If a secret is compromised or a key is rotated:

# Generate new keypair
npx orbseal keygen

# Update the app key with the new public key
npx orbseal keys create "prod-worker-v2" \
  --public-key "orbpk-<new>" \
  --env production

# Re-seal all secrets with the new public key
npx orbseal use --public-key "orbpk-<new>"
npx orbseal seal taskflow:database_url --scope environment --ref production
npx orbseal seal taskflow:stripe_secret --scope environment --ref production

# Deploy new app with new private key
# Revoke the old app key
npx orbseal keys revoke <old-key-id>

Related