Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ed25519 Signing

Ed25519 lets anyone holding the public key verify envelopes without being able to create them.

1. Flow

  1. Generate a keypair once and store the raw 32-byte private seed and public key in two files (the engine does not generate keys).
  2. Configure setup.signing = Setup::ed25519(private_path, public_path)?. A missing file is an error, and Setup::run() checks that the private key is a 32-byte seed and that the public key matches it.
  3. Every envelope gets sig = lowercase hex Ed25519 signature over the v2 message (see Cryptographic Verification).
  4. A verifier with the public key checks the version, freshness, slot and signature.
┌────────────────────────┐
│  Rust origin           │  signs the v2 message with the private key
└───────────┬────────────┘
            ▼
┌────────────────────────┐
│  CDN / proxy           │  forwards the envelope
└───────────┬────────────┘
            ▼
┌────────────────────────┐
│  Verifier (edge,       │  checks sig with the public key
│  service or your JS)   │
└────────────────────────┘

2. Rust API

src/crypto/ed25519/sign.rs and src/crypto/ed25519/verify.rs:

// anza::crypto::ed25519::sign
pub fn payload(private_key_bytes: &[u8], data: &[u8]) -> anza::Result<String>;
// anza::crypto::ed25519::verify
pub fn check(public_key_bytes: &[u8], data: &[u8], signature_hex: &str) -> bool;
use anza::crypto::{ed25519::{sign, verify}, envelope};

// Raw 32-byte keys, e.g. from ed25519-dalek or read from the files above.
let private_key: [u8; 32] = std::fs::read("keys/origin.key")?.try_into().unwrap();
let public_key: [u8; 32] = std::fs::read("keys/origin.pub")?.try_into().unwrap();

let message = envelope::message(1_724_771_200, "feed", "<ui-card>Live</ui-card>", None);
let signature_hex = sign::payload(&private_key, &message)?;

assert!(verify::check(&public_key, &message, &signature_hex));

check returns false (never an error) for a bad key, bad hex or a wrong signature. It only checks the signature: use verify_envelope (below) to also check the version, age and slot.

To create a keypair, use ed25519-dalek (with its rand_core feature) directly:

use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;

let signing_key = SigningKey::generate(&mut OsRng);
std::fs::write("keys/origin.key", signing_key.to_bytes())?;
std::fs::write("keys/origin.pub", signing_key.verifying_key().to_bytes())?;

3. Verifying on the server

A service or edge that holds only the public key verifies a received envelope with anza::verify_envelope:

use anza::{verify_envelope, Envelope, VerifyKey, VerifyOptions};

let public_key = std::fs::read("keys/origin.pub")?;
let envelope: Envelope = serde_json::from_str(&body)?;

// Slot "feed", at most 60 seconds old, up to 5 seconds in the future.
verify_envelope(VerifyKey::Ed25519(&public_key), &envelope, &VerifyOptions::new("feed", 60))?;

Every failure is an Error::Crypto naming the check (see Cryptographic Verification). A public key that is not 32 bytes is invalid verification key.

4. Verifying in the browser (your code)

The Anza browser library does not do this for you. If you want browser-side checks, publish the public key yourself (for example in a <meta name="anza-key"> tag), rebuild the v2 message and verify with Web Crypto. Ed25519 in crypto.subtle is supported in current Chrome, Firefox and Safari.

const enc = new TextEncoder();

// "anza-sig-v2\n" field(ts) field(slot) field(html) field(css)
function signatureMessage({ ts, slot, html, css = '' }) {
  const parts = [enc.encode('anza-sig-v2\n')];
  for (const value of [String(ts), slot, html, css]) {
    const bytes = enc.encode(value);
    parts.push(enc.encode(`${bytes.length}:`), bytes, enc.encode('\n'));
  }
  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
  let offset = 0;
  for (const p of parts) { out.set(p, offset); offset += p.length; }
  return out;
}

async function verifyEnvelope(envelope, publicKeyBytes, slot, maxAgeSeconds = 60) {
  if (envelope.v !== 2 || !/^[0-9a-f]{128}$/.test(envelope.sig ?? '')) return false;
  const now = Math.floor(Date.now() / 1000);
  if (envelope.ts > now + 5 || now - envelope.ts > maxAgeSeconds) return false;
  if (envelope.slot !== slot) return false;

  const key = await crypto.subtle.importKey('raw', publicKeyBytes, { name: 'Ed25519' }, false, ['verify']);
  const sig = Uint8Array.from(envelope.sig.match(/../g), (h) => parseInt(h, 16));
  return crypto.subtle.verify({ name: 'Ed25519' }, key, sig, signatureMessage(envelope));
}