Skip to content
Liorith/Registry

Docs · Reference

Proof format

Five parameters, one signed line. Everything the registry can conclude comes from what that line binds together.

The signed message

Five fields joined by colons, signed as UTF-8 bytes. The version prefix is part of the message, so a v1 signature can never be replayed as a v2 one.

Messagetext
v2:<site>:<host>:<ts>:<nonce>
sitestringThe site id the proof is made for.
hoststringThe host being claimed, lower-cased.
tsnumberUnix seconds at signing time.
noncestring12 random bytes, hex encoded. New for every proof.

The signature is Ed25519 over that message, encoded base64url. The five fields plus sig travel as query parameters — nothing else, and nothing secret.

Proof URLtext
https://registry.liorith.net/verify
  ?site=demo
  &host=demo.liorith.net
  &ts=1756800000
  &nonce=9f2c1ab4e7d05c31
  &sig=MEUCIQD...

What each part rules out

  1. The signature rules out forgery

    Only the holder of the private key can produce it. The registry holds only the public key, so a leak of registry data cannot be turned into proofs.

  2. The timestamp rules out old proofs

    A proof is valid for 120 seconds. Timestamps more than 30 seconds in the future are refused as clock_skew rather than accepted, so a wrong clock fails loudly instead of widening the window.

  3. The nonce rules out reuse

    The registry consumes it atomically on the first verification. A second request with the same URL answers replay, even inside the two minutes.

  4. The host rules out borrowed proofs

    The host is inside the signed message and is checked against the site's allowed domains. A valid proof pasted onto another domain answers host_mismatch.

Signing

Use the SDK if you are on JavaScript. If you are not, the scheme is small enough to implement directly — Ed25519 is in every mainstream crypto library.

With the SDKts
import { signProofV2, proofToUrl } from 'liorith-registry-sdk';

const params = signProofV2(siteId, host, privateKeyPem);
const url    = proofToUrl(params);
Without itts
import { createPrivateKey, randomBytes, sign } from 'crypto';

const ts    = Math.floor(Date.now() / 1000);
const nonce = randomBytes(12).toString('hex');
const msg   = Buffer.from(`v2:${siteId}:${host}:${ts}:${nonce}`);
const sig   = sign(null, msg, createPrivateKey(pem)).toString('base64url');
Signing belongs on the server. A private key shipped to a browser is a public key with extra steps — anyone who reads it can produce proofs for your site until you rotate it.

Keys

Generate a pair with npx liorith-verify keygen. The private key is a PKCS8 PEM and stays on your server; the public key is an SPKI PEM and goes into the admin panel under your site.

A PEM carries line breaks, which a Compose .env cannot transport. Pass it base64-encoded and decode it at startup, or keep it in a real secret store.

ini
LIORITH_REGISTRY_PRIVATE_KEY_B64=LS0tLS1CRUdJTi...   # base64 of the PEM
LIORITH_REGISTRY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."  # or the PEM directly

v1, and why it is not the default

The original scheme signs v1:<site>:<host>:<ts>:<nonce> with HMAC-SHA256 and a shared secret, encoded hex. It still verifies, so existing integrations keep working.

It is not what new integrations should use: a shared secret means the registry has to store a value that can produce proofs. With Ed25519 the registry stores only a public key, so nothing on our side can sign for your site. Rotating to v2 is a keygen and a paste into the admin panel.