API Documentation

Integrate end-to-end encrypted one-time secret sharing into your own apps and workflows

Your client does the encryption

Vanisec is zero-knowledge. The server never receives your plaintext, your password, or your encryption key. It stores ciphertext and nothing else. That means the API does not accept a secret directly. You encrypt first, then upload the result.

If you just want to share a secret, use the website. This API is for building your own client, and you have to implement the key derivation described below exactly, or your secret will not decrypt.

How the crypto works

Every secret is password-protected. Two independent values are derived from that password using PBKDF2-HMAC-SHA256, each with its own random 16-byte salt:

  • Encryption key, derived with encSalt, 256 bits, used as an AES-256-GCM key with a random 12-byte IV. This key never leaves your client.
  • Verifier, derived with authSalt, 256 bits, sent to the server to prove you know the password. The server stores only its SHA-256 hash and compares in constant time, so it cannot recover the verifier, let alone the password or the key.

Because the two salts differ, holding the verifier tells you nothing about the encryption key. Wrong guesses are rejected without consuming the secret.

Parameters

KDF          PBKDF2-HMAC-SHA256
Iterations   600000   (minimum accepted; sent as "iterations")
Salts        16 random bytes each, for encSalt and authSalt
Cipher       AES-256-GCM
IV           12 random bytes
Encoding     base64url, unpadded, for every binary field

Plaintext envelope

Encrypt this JSON structure, not your raw string. Text-only secrets set file to null.

{
  "text": "your secret text",
  "file": null
}

// or, to attach a file:
{
  "text": "",
  "file": {
    "name": "id_rsa",
    "type": "application/octet-stream",
    "size": 2610,
    "data": "<standard base64 of the file bytes>"
  }
}

Create a secret

POST/api/secrets

Stores an encrypted payload and returns its id. Build the share link yourself as /secret/{id}.

Request body

{
  "ciphertext":        "string  (required, base64url, max 12000000 chars)",
  "iv":                "string  (required, base64url)",
  "passwordProtected": true,
  "encSalt":           "string  (required, base64url)",
  "authSalt":          "string  (required, base64url)",
  "verifier":          "string  (required, base64url)",
  "iterations":         600000,
  "expiresIn":          24
}

passwordProtected must be true. Every secret requires a password. iterations must be at least 600000. expiresIn is in hours and must be one of 1, 6, 24, 72 or 168. The request body itself is capped at 16 MB.

Response

{
  "id": "31c3e8d4-43a8-4e72-aac7-b38a00b46a50"
}

Fetch retrieval parameters

GET/api/secrets/{id}

Returns what you need to compute the verifier. This does not consume the secret, and it deliberately withholds encSalt until you have proven you know the password. A password-protected secret answers with 401, which is expected, not an error.

Response: 401

{
  "requiresPassword": true,
  "authSalt":         "string (base64url)",
  "iterations":        600000
}

Always use the returned iterations rather than assuming the current default. Secrets created before the work factor was raised carry the older value.

Retrieve and burn a secret

POST/api/secrets/{id}

Submits the verifier. On a match the secret is deleted atomically and returned in the same operation, so exactly one caller can ever succeed. On a mismatch nothing is deleted.

Request body

{
  "verifier": "string (base64url)"
}

Response: 200

{
  "ciphertext": "string (base64url)",
  "iv":         "string (base64url)",
  "encSalt":    "string (base64url)",
  "iterations":  600000
}

Derive the AES key from your password and this encSalt, decrypt, then parse the envelope.

Response: 401

{
  "error": "Invalid password",
  "attemptsRemaining": 7
}

Create a pairing code

POST/api/pair

Issues a short code that resolves to an existing secret id, for moving a secret onto a device where a full link cannot reasonably be typed. The code carries the id and nothing else: the password still gates retrieval, so a code grants strictly less than the link does.

Request body

{
  "id": "31c3e8d4-43a8-4e72-aac7-b38a00b46a50"
}

Response

{
  "code":      "4F2K-9QX1",
  "expiresIn":  300
}

Codes live five minutes, or until the secret expires if that comes first, and are usable once. The short life is what stands in for the entropy a code gives up against a full id, so it is not configurable. Returns 404 when the secret is missing or has expired.

Redeem a pairing code

POST/api/pair/redeem

Exchanges a code for the secret id, then continues through the normal retrieval endpoints above. POST rather than GET so a live code never reaches an access log or a Referer header.

Request body

{
  "code": "4f2k-9qx1"
}

Case, dashes and spaces are all ignored. Because the alphabet omits I, L, O and U, a typed I or L is read as 1 and O as 0.

Response: 200

{
  "id": "31c3e8d4-43a8-4e72-aac7-b38a00b46a50"
}

Response: 404

{
  "error": "That code has expired or has already been used"
}

Unknown, malformed, expired and already-redeemed codes all answer identically, so a wrong guess reveals nothing. Redemption is rate limited per address.

Clipboard

The shortest path: store something, hand someone a four digit code, they read it once. Clips expire after five minutes.

Unlike the endpoints above, this one is not zero-knowledge. Four digits is ten thousand possibilities, far too few to derive a key from, so the key travels with the ciphertext and is stored beside it. Vanisec can decrypt a clip while it exists, and the code space is small enough to enumerate. The five minute lifetime and the single use are what bound that, which is why neither is configurable.

Store

POST/api/clip
// request
{
  "ciphertext": "string (base64url, max 12000000 chars)",
  "iv":         "string (base64url)",
  "key":        "string (base64url, raw AES-GCM 256 key)"
}

// response
{ "code": "4242", "expiresIn": 300 }

The server picks the code, so a client cannot claim one or probe for which are free. If every code it tries is taken it answers 503 rather than evicting a live clip.

Open

POST/api/clip/open
// request
{ "code": "4242" }

// response
{ "ciphertext": "...", "iv": "...", "key": "..." }

Atomic fetch-and-delete, so a clip opens exactly once. Unknown, expired and already-opened all return the same 404. Spaces and dashes in the code are ignored. Opening is rate limited per address, well below what walking the code space would need.

Complete example

Node.js 18 or newer, no dependencies. It uses the built-in Web Crypto API. The same code runs in a browser unchanged apart from the base64 helpers.

const BASE = 'https://vanisec.clouddrove.com'
const ITERATIONS = 600000

const b64url = (bytes) =>
  Buffer.from(bytes).toString('base64')
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

const unb64url = (s) =>
  new Uint8Array(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'))

async function deriveBits(password, salt, iterations) {
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'])
  return new Uint8Array(await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, key, 256))
}

async function createSecret(text, password) {
  const encSalt = crypto.getRandomValues(new Uint8Array(16))
  const authSalt = crypto.getRandomValues(new Uint8Array(16))
  const iv = crypto.getRandomValues(new Uint8Array(12))

  const envelope = JSON.stringify({ text, file: null })
  const keyBits = await deriveBits(password, encSalt, ITERATIONS)
  const key = await crypto.subtle.importKey(
    'raw', keyBits, { name: 'AES-GCM' }, false, ['encrypt'])
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, new TextEncoder().encode(envelope))
  const verifier = await deriveBits(password, authSalt, ITERATIONS)

  const res = await fetch(BASE + '/api/secrets', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      ciphertext: b64url(new Uint8Array(ciphertext)),
      iv: b64url(iv),
      passwordProtected: true,
      encSalt: b64url(encSalt),
      authSalt: b64url(authSalt),
      verifier: b64url(verifier),
      iterations: ITERATIONS,
      expiresIn: 24,
    }),
  })
  if (!res.ok) throw new Error('create failed: ' + res.status)
  const { id } = await res.json()
  return BASE + '/secret/' + id
}

async function readSecret(url, password) {
  const id = url.split('/').pop()

  const metaRes = await fetch(BASE + '/api/secrets/' + id)
  const meta = await metaRes.json()
  if (metaRes.status !== 401) throw new Error('not available: ' + metaRes.status)

  const verifier = await deriveBits(password, unb64url(meta.authSalt), meta.iterations)
  const res = await fetch(BASE + '/api/secrets/' + id, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ verifier: b64url(verifier) }),
  })
  if (!res.ok) throw new Error('read failed: ' + res.status)
  const data = await res.json()

  const keyBits = await deriveBits(password, unb64url(data.encSalt), data.iterations)
  const key = await crypto.subtle.importKey(
    'raw', keyBits, { name: 'AES-GCM' }, false, ['decrypt'])
  const plaintext = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: unb64url(data.iv) }, key, unb64url(data.ciphertext))
  return JSON.parse(new TextDecoder().decode(plaintext)).text
}

const url = await createSecret('deploy key: abc123', 'hunter2')
console.log('created:', url)
console.log('read back:', await readSecret(url, 'hunter2'))
// a second read fails, the secret is gone

Errors

StatusMeaning
400Malformed body, missing field, iterations below the minimum, or an expiry outside the allowed set
401On GET, the secret exists and needs a password. On POST, the verifier did not match
404No such secret, or it has already been viewed
410The secret expired, or was taken between your two requests
413Request body over 16 MB
429Rate limited. Check the Retry-After header

Rate limits

All limits use fixed windows and return 429 with a Retry-After header when exceeded.

  • Creating secrets: 30 per 10 minutes per IP
  • Reading metadata: 120 per 15 minutes per IP
  • Password attempts: 60 per 15 minutes per IP, and 10 per 15 minutes per secret

The per-secret limit is what stops a leaked link from being brute-forced. A failed attempt never deletes the secret, so the limit is the only thing bounding guesses.

Self-Hosting

Vanisec is fully open source. Run your own instance on your own infrastructure. Docker and docker-compose configs are included.

View on GitHub