Files
bal-server/docs/10_dnssec_key_publishing.md
svatantrya 4af1ca8220 feat(contrib): add TXT record helper with key generation
- Add contrib/make_welist_txt.sh to print the _welist-key TXT
  name, value and BIND zone line for a domain and Ed25519 key
- Accept a private/public PEM file or an existing base64 SPKI DER
- Generate private_key.pem and public_key.pem when the key argument
  is omitted, reusing an existing private key
- Document the helper in docs/10_dnssec_key_publishing.md
2026-11-08 23:26:32 -04:00

20 KiB
Raw Blame History

DNSSEC-authenticated WeTime pings (v2)

This document explains how to publish the Ed25519 public key used by the bal-pusher v2 WeTime ping and how to verify that the record is usable.

In v2 the WeTime service discovers the executor public key from:

  • a DNSSEC-validated TXT record at _welist-key.<host>, or
  • the Ed25519 identity key embedded in a v3 .onion address, or a dedicated ping key delegated by that identity key.

This guide covers the DNS TXT record. Onion authentication needs no DNS record and should use delegation so the onion identity key stays offline (see Onion URLs). For local development no record is needed either (see Local development).

All values below use we.example.com as a placeholder for the hostname in BAL_SERVER_URL. Replace it with your real executor hostname.


1. Choose the hostname

Use the public hostname that clients use to reach your executor:

BAL_SERVER_URL=https://we.example.com

The DNS key is published at:

_welist-key.we.example.com

Rules:

  • Use a DNS hostname, not an IP address. The service rejects v2 pings whose URL host is an IP address with v2 DNS lookup requires a hostname.
  • Use the host only: no scheme, port, path, or trailing slash.
  • The record must exist at exactly _welist-key.<host>. CNAME answers are rejected by the service.

2. Generate or confirm the Ed25519 key pair

The pusher signs with the private key at SSL_KEY_PATH. The TXT record must contain the matching public key.

The pusher and the bal-server use two separate settings. SSL_KEY_PATH is the private key used for signing; BAL_SERVER_PUB_KEY_PATH is the public key served at /.pub_key.pem and used by the legacy v1 scheme (and by the check in section 7). Configure both with the same key pair.

If you already have a key pair for the executor, reuse it. Do not generate a new pair for SSL_KEY_PATH alone: the legacy v1 signature is verified against /.pub_key.pem, so a split pair breaks v1 authentication and makes the section 7 check fail.

Onion hosts: do not point SSL_KEY_PATH at the onion identity key. Use a separate ping key and issue a delegation certificate (see Onion URLs). The identity key is only needed offline, once, to sign that certificate.

If you do not have a key pair yet:

openssl genpkey -algorithm ED25519 -out private_key.pem
openssl pkey -in private_key.pem -pubout -out public_key.pem
chmod 600 private_key.pem

Alternatively, contrib/make_welist_txt.sh <host> generates private_key.pem and public_key.pem in the current directory when the key argument is omitted, then prints the TXT record. The private key is created at mode 0600. If ./private_key.pem already exists, the script reuses it instead of overwriting it. Move both files to a protected location before starting the services.

Then point both settings at the new pair and restart bal-server and bal-pusher:

export SSL_KEY_PATH="/absolute/path/to/private_key.pem"
export BAL_SERVER_PUB_KEY_PATH="/absolute/path/to/public_key.pem"

Keep private_key.pem private and outside version control. The TXT record only contains the public key.


3. Build the TXT value

Derive the base64 SubjectPublicKeyInfo (SPKI) DER from the private key:

KEY=$(openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0)
echo "v=we1; alg=ed25519; key=$KEY"

On platforms where base64 -w0 is unavailable (for example macOS):

KEY=$(openssl pkey -in private_key.pem -pubout -outform DER | base64 | tr -d '\n')
echo "v=we1; alg=ed25519; key=$KEY"

contrib/make_welist_txt.sh <host> [key] prints the record name, value, and a BIND zone line directly. key may be a private/public PEM file or an existing base64 SPKI DER value; omit it to generate a new key pair first.

The value has this form:

v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA...

Notes:

  • key is the base64 of the full SPKI DER, not a hash and not the PEM text. It is the same base64 body that appears in public_key.pem.
  • Use standard base64 (+, /, = padding), not base64url.
  • Ed25519 SPKI DER is 44 bytes, so the base64 value is about 60 characters and the whole TXT string is about 90 characters. It fits in a single TXT string.
  • If a DNS provider splits the value into multiple quoted strings, that is acceptable: the resolver concatenates TXT chunks before parsing.

4. Enable DNSSEC

The service validates DNSSEC and fails closed. An unsigned or bogus answer is rejected; there is no unauthenticated fallback.

Enabling DNSSEC normally requires two steps:

  1. Enable DNSSEC signing for the zone at the DNS provider.
  2. Publish the provider's DS record at the registrar or parent zone.

The whole chain from the root to the zone that contains _welist-key.we.example.com must be signed.

Verify DNSSEC

dig +short DS example.com
dig +dnssec DNSKEY example.com
dig +dnssec TXT _welist-key.we.example.com

Look for RRSIG records in the answers. A validating resolver also sets the ad flag:

;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, ...

If dig does not show ad, your local resolver may not validate. Use a validating tool instead:

delv TXT _welist-key.we.example.com

or:

kdig +dnssec TXT _welist-key.we.example.com

Resolver used by the WeTime service

The service performs its own validation with a built-in root trust anchor. The upstream resolver only needs to return DNSSEC records.

If the service host uses a stub resolver that strips DNSSEC data, point it at a DNSSEC-capable resolver:

export WELIST_DNS_NAMESERVERS=1.1.1.1,9.9.9.9

WELIST_DNS_NAMESERVERS replaces the system resolver configuration; each entry is an IP or IP:PORT (default port 53).

You can also point at a custom trust anchor file:

export WELIST_DNSSEC_TRUST_ANCHOR=/path/to/root.key

Local development

DNSSEC cannot authenticate localhost or an IP address. For local development use the legacy scheme and let the service verify the published public key:

  • Keep WELIST_PING_SIG_VERSION unset (or 1), so the pusher signs with SSL_KEY_PATH and the service verifies against GET /.pub_key.pem.
  • Start the service with WELIST_ALLOW_LEGACY_PING=true and, for loopback or private addresses, WELIST_ALLOW_LOCALHOST_PING=true.

No TXT record and no DNSSEC setup are required in this case.


5. Publish the TXT record

Field Value
Name _welist-key.we.example.com
Type TXT
Value v=we1; alg=ed25519; key=<base64 SPKI DER>
TTL 300 to 3600 seconds

The WeTime service caches DNS keys for the TXT TTL, clamped to 60–86400 seconds. A TTL of 300–3600 keeps rotation reasonably fast.

BIND / generic zone file

_welist-key.we.example.com. 3600 IN TXT "v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA..."

Reload or re-sign the zone after adding the record. If you use inline signing, the record is signed automatically. If you sign offline, re-run your signing step and publish the new signed zone.

Cloudflare

  1. Enable DNSSEC in the Cloudflare dashboard.
  2. Add the DS record shown by Cloudflare at your registrar.
  3. Create the TXT record:
  • Type: TXT
  • Name: _welist-key.we (Cloudflare appends the zone name)
  • Content: v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA...
  • TTL: Auto or 1h

Cloudflare proxying applies to HTTP records, not TXT. The TXT record is served directly, so DNSSEC validation works normally.

Route 53

  1. Enable DNSSEC signing for the hosted zone.
  2. Add the resulting DS record at the registrar or parent zone.
  3. Create the record:
  • Record name: _welist-key.we.example.com
  • Record type: TXT
  • Value: "v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA..."
  • TTL: 3600

Route 53 may split long TXT values into quoted chunks. That is supported.

Important

  • Do not create a CNAME for _welist-key. The service rejects CNAME answers.
  • Do not put the private key, a hash, or PEM headers in the TXT value.
  • If you need to host the record in another zone, use a proper DNSSEC-signed delegation (NS + DS), not a CNAME.

6. Configure the pusher

Set these variables in the pusher environment (for local development, bal-pusher.sh):

export SSL_KEY_PATH="/absolute/path/to/private_key.pem"
export BAL_SERVER_URL="https://we.example.com"
export WELIST_PING_SIG_VERSION=2
  • SSL_KEY_PATH must point to the private key matching the published TXT key.
  • BAL_SERVER_URL must be the public DNS hostname. The pusher normalizes it by trimming whitespace and trailing slashes before signing.
  • WELIST_PING_SIG_VERSION=2 activates the canonical JSON signature. Leaving it unset (or 1) keeps the legacy v1 scheme, which the service rejects unless WELIST_ALLOW_LEGACY_PING=true.

For onion hosts, also configure the delegation certificate (see Onion URLs):

export SSL_KEY_PATH="/absolute/path/to/ping_private_key.pem"
export WELIST_PING_DELEGATION_FILE="/absolute/path/to/onion-delegation.json"
export BAL_SERVER_URL="http://<address>.onion"
  • SSL_KEY_PATH is the dedicated ping key, not the onion identity key.
  • WELIST_PING_DELEGATION_FILE is optional. When set, its JSON certificate is sent only with onion pings. The pusher validates it at startup: onion must match BAL_SERVER_URL, valid_until must be in the future, and key must match SSL_KEY_PATH. If the file is missing or malformed, or any check fails, it logs a warning and disables delegation, then signs with SSL_KEY_PATH directly. That fallback works only when SSL_KEY_PATH is the onion identity key; otherwise the service rejects the pings until the certificate or key is corrected.

Restart the pusher after changing the key or environment.

With v2, the pusher also fetches info, base_fee, and version from the local /{chain}/info endpoint and signs those values. If the fetch fails, the fields are signed as null; the ping is still valid. Metadata is also signed as null when it fails validation:

  • info longer than 4096 bytes, or empty;
  • version longer than 64 bytes, or empty;
  • base_fee negative.

For .onion URLs the pusher skips the fetch entirely (it cannot reach its own onion address without a Tor proxy), so those fields are always null.


7. Verify the published key

Query the record and extract the key= field (this also joins TXT chunks and drops the quotes the resolver adds):

dig +short TXT _welist-key.we.example.com | tr -d '" ' | sed 's/^.*key=//'

Compare it with the local public key:

openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0

The two commands must print the same base64 string.

Optionally check that the legacy /.pub_key.pem endpoint serves the same key:

curl -s https://we.example.com/.pub_key.pem \
  | openssl pkey -pubin -outform DER \
  | base64 -w0

This third check is only meaningful when BAL_SERVER_PUB_KEY_PATH and SSL_KEY_PATH reference the same key pair, which is the recommended setup. The endpoint serves whatever BAL_SERVER_PUB_KEY_PATH points at; if the two settings use different pairs, compare the TXT record with SSL_KEY_PATH only.


8. Test a v2 ping

Wait for the next block, or restart the pusher. With trace logging enabled you should see:

canonical message to be sent: WELIST-PING-V2\n{"base_fee":...
Report to welist(...) status=200 body="ok"

The WeTime service should log PING ADDED and refresh the executor's last_update. After a valid v2 ping, signed info, base_fee, and version values are persisted.

A response of "ko" means the signature was rejected. Check the service logs for the specific PING REJECTED reason.


9. Key rotation

Multiple TXT records are supported and any one of them can verify a signature. To rotate without downtime:

  1. Publish the new public key as an additional TXT record.
  2. Switch SSL_KEY_PATH to the new private key and restart the pusher.
  3. Wait at least the old TXT TTL, and no more than the 86400-second cache cap.
  4. Remove the old TXT record.

The service stores a key only after it verifies a signature, so an invalid DNS answer cannot poison the cache. A refreshed TXT answer replaces the cached v2 keys for that host.


10. Troubleshooting

Service log Cause Fix
no TXT records Wrong record name or not propagated Query _welist-key.<host> exactly
zone is not DNSSEC-signed DNSSEC not enabled or DS missing Enable DNSSEC and publish the DS record
DNSSEC validation failed Expired/bogus signatures, clock skew, broken chain Check with delv or a DNSSEC debugger
DNSSEC status is indeterminate Upstream resolver strips DNSSEC data Set WELIST_DNS_NAMESERVERS to a capable resolver
dns unavailable Resolver unreachable or invalid configuration Check WELIST_DNS_NAMESERVERS and WELIST_DNSSEC_TRUST_ANCHOR
CNAME answers are not accepted _welist-key is an alias Publish a real TXT record
malformed record Wrong version, algorithm, or syntax Use v=we1; alg=ed25519; key=...
invalid key Not a base64 Ed25519 SPKI DER Regenerate with the openssl command above
v2 DNS lookup requires a hostname BAL_SERVER_URL uses an IP address Use a DNS hostname
invalid v2 signature Key mismatch or non-canonical URL Ensure the TXT key matches SSL_KEY_PATH and the URL has no trailing slash
invalid onion delegation Certificate signature does not verify against the onion identity key Re-issue the certificate with the correct onion key; do not edit key/valid_until after signing
delegation does not match the ping onion host onion field differs from BAL_SERVER_URL Re-issue the certificate for the exact host
delegation expired valid_until is in the past Re-issue the certificate and restart the pusher
delegation is only supported for onion hosts delegation sent with a DNS URL Unset WELIST_PING_DELEGATION_FILE for DNS hosts
legacy ping disabled v1 ping sent while the service requires v2 Set WELIST_PING_SIG_VERSION=2 (or enable legacy on the service)

Useful diagnostics:

dig +dnssec TXT _welist-key.we.example.com
delv TXT _welist-key.we.example.com
dig +trace +dnssec TXT _welist-key.we.example.com

Onion URLs

A v3 .onion address embeds the service's Ed25519 identity key, so no TXT record is needed. The recommended design keeps that identity key offline and delegates ping signing to a dedicated, rotatable key:

  • ping_private_key.pem (SSL_KEY_PATH) signs every v2 ping.
  • The onion identity key signs a delegation certificate once.
  • Each onion ping carries the certificate in a delegation object.
  • The WeTime service verifies the certificate against the onion address, then verifies the ping signature with the delegated key.

Without a delegation object the service still accepts pings signed directly by the onion identity key. That fallback exists for compatibility; it requires the identity key to be online and should not be used for new deployments.

The ping url must itself use the .onion host. The onion_url database field is display-only and does not affect authentication.

Certificate format

The request carries:

{
  "onion": "<address>.onion",
  "key": "<base64 SPKI DER of the delegated Ed25519 key>",
  "valid_until": 1767225600,
  "signature": "<base64 Ed25519 signature by the onion identity key>"
}

The identity signature covers exactly:

WELIST-PING-DELEGATION-V1\n{"key":"...","onion":"...","valid_until":1767225600}

Rules enforced by the service:

  • onion must exactly equal the ping URL host (lowercase, no trailing dot).
  • valid_until is a Unix timestamp in seconds and must be in the future.
  • key must be a valid Ed25519 SubjectPublicKeyInfo DER in standard base64.
  • The identity signature must verify against the key embedded in the onion address.
  • A present but invalid, expired, or mismatched certificate is rejected; the service does not fall back to the identity key.
  • delegation is rejected on non-onion hosts.

Issue the certificate

Create a dedicated ping key pair:

openssl genpkey -algorithm ED25519 -out ping_private_key.pem
chmod 600 ping_private_key.pem

Use the bundled helper with your onion key. It accepts either a Tor hs_ed25519_secret_key file or a standard PKCS#8 PEM Ed25519 key:

export ONION="<address>.onion"
export ONION_KEY="/var/lib/tor/hidden_service/hs_ed25519_secret_key"

contrib/make_onion_delegation.py \
  --onion "$ONION" \
  --identity-key "$ONION_KEY" \
  --ping-key /etc/bal/ping_private_key.pem \
  --days 365 \
  --out /etc/bal/onion-delegation.json

For Tor-format keys the helper needs Python cryptography and pynacl:

pip install cryptography pynacl

If the onion identity key is a PKCS#8 PEM file, point --identity-key at it and pynacl is not needed. Run this step on a machine that holds the identity key (ideally offline), then copy only onion-delegation.json and ping_private_key.pem to the executor. The certificate contains public data; the ping private key must remain secret.

Configure the pusher

export SSL_KEY_PATH="/etc/bal/ping_private_key.pem"
export WELIST_PING_DELEGATION_FILE="/etc/bal/onion-delegation.json"
export BAL_SERVER_URL="http://<address>.onion"
export WELIST_PING_SIG_VERSION=2

The pusher sends delegation only when BAL_SERVER_URL is an .onion host. It validates the certificate at startup against the onion host, its expiry, and SSL_KEY_PATH. If the file is missing or malformed, or validation fails, delegation is disabled and the pusher signs with SSL_KEY_PATH directly; that works only when SSL_KEY_PATH is the onion identity key.

For local development, the WELIST_ALLOW_LOCALHOST_PING and legacy v1 notes above still apply; delegation itself needs no network access.

Rotation

  1. Generate a new ping_private_key.pem.
  2. Re-run contrib/make_onion_delegation.py with the new key and a new valid_until.
  3. Replace onion-delegation.json and SSL_KEY_PATH on the executor.
  4. Restart the pusher.

The onion address does not change, and the identity key stays offline. There is no DNS cache to wait for; the service checks the certificate on every ping.

Security notes

  • Delegation limits the blast radius: a compromised online ping key forges pings only until rotation, not the onion service identity.
  • A long valid_until increases the impact of a leaked delegated key. Prefer one year or less and rotate before expiry.
  • The onion identity key should never be copied to the executor.

Metadata for onion pings

The pusher cannot fetch its own .onion endpoint without a Tor proxy, so info, base_fee, and version are signed as null for onion pings.

With WELIST_PING_SIG_VERSION=1 the pusher sends no metadata at all. The v1 signature covers only the legacy liveness concatenation (url, chain, height, median_time, last_block_hash), and the WeTime service refreshes last_update without persisting info, base_fee, or version. Unsigned metadata is therefore not published. Use v2 when executor metadata must be authenticated and stored.


Minimal checklist

  • Ed25519 key pair generated; private_key.pem is chmod 600.
  • SSL_KEY_PATH and BAL_SERVER_PUB_KEY_PATH use the same key pair.
  • TXT value built from openssl pkey ... -outform DER | base64 -w0.
  • TXT record _welist-key.<host> published, not a CNAME.
  • Zone DNSSEC-signed and DS published at the registrar.
  • dig +dnssec / delv shows a validated answer.
  • BAL_SERVER_URL is the public DNS hostname.
  • WELIST_PING_SIG_VERSION=2 is set.
  • Pusher restarted and reports body="ok".

For onion hosts, replace the TXT/DNSSEC items with:

  • A dedicated ping_private_key.pem exists and is chmod 600.
  • onion-delegation.json was issued offline by the onion identity key.
  • SSL_KEY_PATH points at the delegated ping key, not the identity key.
  • WELIST_PING_DELEGATION_FILE points at the certificate.
  • valid_until is in the future and rotation is scheduled.
  • Pusher restarted and reports body="ok".