Ed25519 Signatures

How SignedApproval creates unforgeable, offline-verifiable cryptographic proofs using Ed25519 elliptic curve signatures.

Key Concepts

Ed25519 is an elliptic curve digital signature algorithm using Curve25519. It provides 128-bit security with compact 64-byte signatures and 32-byte keys. Ed25519 is used in SSH, TLS, cryptocurrency (Solana, Cardano), and secure messaging protocols.

SignedApproval uses Ed25519 because it is fast (can sign and verify in microseconds), deterministic (same input always produces the same signature — no random nonce needed at signing time), and has been extensively analyzed by the cryptographic community.

Each approver has their own Ed25519 key pair. The private key is encrypted with AES-256-GCM using SignedApproval's ENCRYPTION_KEY and stored in the database. The public key is available through the verification API.

The Signed Payload

When an approver makes a decision, SignedApproval constructs this canonical JSON payload:

JSON
{
  "v": 1,
  "rid": "request-uuid",
  "did": "decision-uuid",
  "approver": "sha256(user_id)[:16]",
  "action": "sha256(action_text)[:16]",
  "decision": "approved",
  "method": "passkey",
  "ts": 1709000000,
  "exp": 1709086400,
  "nonce": "random-hex-32"
}

Each field serves a specific purpose:

  • v — Payload version. Currently always 1. Allows future format changes without breaking verifiers.
  • rid — The approval request UUID. Links the decision to the original request.
  • did — The decision UUID. Unique identifier for this specific decision.
  • approver — First 16 characters of SHA-256 hash of the approver's user ID. Privacy-preserving: you can match against a known user without exposing who approved.
  • action — First 16 characters of SHA-256 hash of the action text. Proves the signature covers a specific action without revealing the action to the verifier.
  • decision — "approved" or "rejected".
  • method — How the approver authenticated: "passkey", "totp", or "biometric".
  • ts — Unix timestamp when the decision was made.
  • exp — Unix timestamp when the signed decision expires (matches the request TTL).
  • nonce — Random 32-character hex string. Prevents replay attacks and ensures uniqueness even if all other fields are identical.

Key Management

Ed25519 key pairs are managed automatically:

  • Generation — A key pair is generated on your first approval using Node.js crypto.generateKeyPairSync('ed25519').
  • Encryption — The private key is encrypted with AES-256-GCM. The encrypted form is stored as iv:authTag:ciphertext (base64url-encoded parts separated by colons).
  • Storage — Encrypted private key and plaintext public key are stored in signedapproval_signing_keys.
  • Separation — SignedApproval's signing keys are completely independent from SignedInbox or any other service. Compromise of one system does not affect the other.

Signing Process

The signing process follows these exact steps:

  1. Construct the canonical payload object with all required fields.
  2. Serialize to JSON with deterministic key ordering (keys sorted alphabetically).
  3. Decrypt the approver's Ed25519 private key from AES-256-GCM storage.
  4. Sign the serialized JSON bytes with crypto.sign(null, data, privateKey).
  5. Encode the signature as base64.
  6. Store the signed payload, signature, and public key reference in the decision record.
  7. Zero-fill the decrypted private key in memory.
Important
The payload JSON must be serialized with deterministic key ordering. If you reconstruct the payload for offline verification, the keys must be in the same order, or the signature will not match. The canonical order is alphabetical by key name.

Offline Verification

Anyone can verify a signed decision without contacting SignedApproval's servers. You need three things:

  1. The signed payload (JSON)
  2. The Ed25519 signature (base64)
  3. The public key (base64)

All three are returned by the verification API, but once you have them, verification is purely mathematical:

javascript
import { createPublicKey, verify } from 'crypto';

const publicKey = createPublicKey({
  key: Buffer.from(publicKeyBase64, 'base64'),
  format: 'der',
  type: 'spki',
});

const payload = Buffer.from(JSON.stringify(canonicalPayload));
const signature = Buffer.from(signatureBase64, 'base64');

const isValid = verify(null, payload, publicKey, signature);
console.log('Signature valid:', isValid);
Tip
Ed25519 signatures are deterministic — signing the same payload with the same key always produces the same signature. This makes testing and debugging straightforward: if you have the payload and public key, you can predict exactly what the signature should be.