@kagal/ed25519-secret
v0.3.2
Published
Ed25519 signer with DKIM-style selector validation
Downloads
849
Maintainers
Readme
@kagal/ed25519-secret — Ed25519 keys, signing, and verification for WebCrypto
WebCrypto Ed25519 — key-pair construction, signing and verification, JWKS-ready and DNS-TXT-ready public key publication, DKIM-style key-record parsing and selector validation, and base64 helpers. Zero runtime dependencies — only the host runtime's WebCrypto.
Runs anywhere with crypto.subtle — modern browsers,
Node ≥ 20, Cloudflare Workers, Deno, and Bun.
Install
npm install @kagal/ed25519-secretyarn add @kagal/ed25519-secretpnpm add @kagal/ed25519-secretUsage
Generating a fresh Ed25519 key pair
newKeys() mints the private seed (to store), the
public key (to publish), and a JWKS-ready publicJWK
in one call:
import { encodeBase64, encodeKey, newKeys } from '@kagal/ed25519-secret';
const selector = 's1';
// `undefined` ⇒ generate a fresh seed; the selector
// becomes `publicJWK.kid`.
const { privateKey, publicKey, publicJWK } =
await newKeys(undefined, selector);
// Private — store somewhere safe (env var, secret manager, etc.)
const secret = `${selector}:${encodeBase64(privateKey)}`;
// Public — base64 of the raw public key for DNS-style
// distribution; `publicJWK` (carrying `kid: selector`)
// for a JWKS endpoint
const distributable = await encodeKey(publicKey);Building an Ed25519 key pair from your own seed
When you already hold a 32-byte seed (raw bytes or its base64 encoding — e.g. derived from a KDF):
import { newKeys } from '@kagal/ed25519-secret';
// `seed`: a 32-byte Uint8Array or its base64 encoding
const { privateKey, publicKey } = await newKeys(seed);Publishing a JWKS endpoint
makeJWKS wraps one or many keys into the JWK Set
body (RFC 7517 §5) served by a jwks.json endpoint;
each publicJWK carries the kid supplied to
newKeys verbatim (RFC 7517 §4.5):
import { makeJWKS, newKeys } from '@kagal/ed25519-secret';
// `seed` from your secret store (or `undefined` for a fresh seed)
const key = await newKeys(seed, 's1');
const jwks = makeJWKS(key);
// jwks:
// {
// keys: [{
// kty: 'OKP', crv: 'Ed25519', x: '<base64url>',
// use: 'sig', alg: 'EdDSA', kid: 's1',
// }],
// }
//
// Pass an array of keys to publish many at once —
// each entry's `kid` rides on its own `publicJWK`.
// Serve as application/jwk-set+json (RFC 7517 §8.5.1):
return new Response(JSON.stringify(jwks), {
headers: { 'content-type': 'application/jwk-set+json' },
});For the env-var rotation pattern, see Parsing multiple secrets at once.
Publishing keys as DKIM-style DNS TXT records
makeKeyRecords builds the publishable record body
for each key (RFC 6376 §3.2 syntax, §3.6.1 p=
semantics) and returns them keyed by selector — ready
to serialise as DKIM tag-list TXT values:
import { makeKeyRecords, newKeys } from '@kagal/ed25519-secret';
// `seed` from your secret store (or `undefined` for a fresh seed)
const { publicKey } = await newKeys(seed);
const records = await makeKeyRecords(
{ publicKey, selector: 's1' },
{ v: 'DKIM1' },
);
// records:
// {
// 's1': { v: 'DKIM1', k: 'ed25519', p: '<base64>' },
// }
//
// Pass an array of `{ publicKey, selector }` inputs to
// publish many at once — each input's selector becomes
// the dict key. `KeyConfig` (from `parseSecretToKey` /
// `parseSecretsToKeys`) satisfies the input shape
// structurally.
// Serialise each entry as a DKIM tag-list TXT value
// (`v=DKIM1; k=ed25519; p=<base64>`) and publish under
// `<selector>._domainkey.<domain>`.Parsing a secret and signing a message
import { encodeBase64, parseSecretToKey } from '@kagal/ed25519-secret';
// `secret` may use standard or URL-safe base64 — both round-trip
const config = await parseSecretToKey(secret);
const signature = await config.signer.sign('payload');
const wire = encodeBase64(new Uint8Array(signature)); // for transportParsing multiple secrets at once
One rotation pattern: append new secrets to the end of the env var so existing entries keep their position. Which entry signs new tokens is a signing-side choice — the example uses the last entry.
The JWKS publishes every entry's publicJWK and
verifiers match by kid (RFC 7517 §4.5), so
signatures issued before the rotation continue to
verify:
import { Hono } from 'hono';
import {
makeJWKS,
parseSecretsToKeys,
splitLast,
} from '@kagal/ed25519-secret';
// hypothetical — your token-issuing handler factory
import { mountTokensHandler } from './tokens';
type Bindings = { SIGNING_SECRETS: string };
async function loadKeys(env: Bindings) {
const keys = await parseSecretsToKeys(env.SIGNING_SECRETS);
const { last: current } = splitLast(keys);
if (!current) {
throw new Error('SIGNING_SECRETS contained no usable secrets');
}
return { keys, current };
}
const app = new Hono<{ Bindings: Bindings }>();
// Publish every public key — verifiers match by `kid`
app.get('/.well-known/jwks.json', async (c) => {
const { keys } = await loadKeys(c.env);
return c.json(makeJWKS(keys));
});
// Issue tokens with the most recent secret
mountTokensHandler(app, async (c) => {
const { current } = await loadKeys(c.env);
return current.signer;
});
export default app;Plugging in a custom Signer
Drop in any Signer implementation — it's just
{ sign: (message: BufferSource | string) => Promise<ArrayBuffer> }.
Implementations that forward the message to another
byte-oriented API (a fetch body, an HSM SDK, …) can
use asMessageBytes to coerce string inputs to UTF-8
bytes:
import { asMessageBytes, type Signer } from '@kagal/ed25519-secret';
const remoteSigner: Signer = {
sign: async (message) => {
const response = await fetch('https://signer.example.com/sign', {
method: 'POST',
body: asMessageBytes(message),
});
return response.arrayBuffer();
},
};
const signature = await remoteSigner.sign('payload');Publishing the public key from a stored secret
If you already have the selector:base64 secret and need to
(re-)publish the public key — e.g. rolling out to new DNS infra
without rotating the seed:
import { encodeKey, parseSecretToKey } from '@kagal/ed25519-secret';
const config = await parseSecretToKey(secret);
const distributable = await encodeKey(config.publicKey);
// publish under a selector-scoped channel (e.g. a DNS TXT record)For HTTP publication, pass the same config to
makeJWKS — the carried publicJWK lands in the
JWK Set's keys array.
Fetching a published public key
parseKeyRecord handles the DKIM-style tag-list parsing,
DoH-JSON quote stripping, and multi-piece concatenation
(RFC 1035 §3.3 and RFC 6376 §3.6.2.2); feed the returned
record's p to importVerifyKey.
DNS-over-HTTPS JSON via fetch works in any runtime with
global fetch:
import { importVerifyKey, parseKeyRecord } from '@kagal/ed25519-secret';
const response = await fetch(
`https://1.1.1.1/dns-query?name=${selector}._keys.example.com&type=TXT`,
{ headers: { accept: 'application/dns-json' } },
);
if (!response.ok) throw new Error(`DoH ${response.status}`);
const { Answer } = await response.json();
const data = Answer?.[0]?.data;
if (!data) throw new Error('record not found');
const record = parseKeyRecord(data);
if (record.p === undefined) throw new Error('key has been revoked');
// RFC 6376 §3.6.1: absent k= defaults to rsa.
const publicKey = await importVerifyKey(record.k ?? 'rsa', record.p);record.p is undefined when the record uses RFC 6376
§3.6.1's revoked-on-empty convention; branch on it before
importing. record.k is undefined when the record omits
k=, which RFC 6376 §3.6.1 defaults to rsa; the
?? 'rsa' fallback makes that default explicit, and
importVerifyKey then rejects it as
unsupported algorithm: rsa. importVerifyKey matches
its algorithm argument case-insensitively, so DKIM's
'ed25519' lands without pre-normalisation. The
v= tag and any additional tags pass through unchecked —
the package is protocol-agnostic across DKIM-style record
formats; protocol-specific version validation (matching
record.v against an expected label) belongs with the
caller.
Verifying an Ed25519 signature in WebCrypto
newVerifier wraps an Ed25519 public CryptoKey in a
Verifier — the verify-side counterpart to Signer. It
gate-checks algorithm.name and usages at construction
and delegates each verify call to crypto.subtle.verify,
which is specified to apply RFC 8032 §5.1.7 strict
verification (cofactor handling and signature-malleability
resistance); confirm your runtime conforms, or fall back to
a strict-verify library such as @noble/ed25519.
import { newVerifier } from '@kagal/ed25519-secret';
// `publicKey` from the previous snippet; `signature` is the bytes
// you received over the wire (BufferSource)
const verifier = newVerifier(publicKey);
const ok = await verifier.verify(signature, 'payload');verify accepts the message as bytes (BufferSource) or
as a string; strings are encoded as UTF-8. Callers needing
another encoding can pass bytes directly.
The two walkthroughs above take a record apart step by step. Two helpers bundle those steps for production use:
parseRecordToKey— the fetch section'sparseKeyRecord+importVerifyKey, returning the verify-onlyCryptoKey.parseRecordToVerifier— the above plus thenewVerifierwrap, returning a readyVerifier.
Both carry the surviving tags alongside the key in the
returned record, so the revoked-key, k=-default, and tag
pass-through rules described above are unchanged:
import { parseRecordToVerifier } from '@kagal/ed25519-secret';
// `data` from the DoH fetch above; `signature` from the wire
const { p: verifier } = await parseRecordToVerifier(data);
if (verifier === undefined) throw new Error('key has been revoked');
const ok = await verifier.verify(signature, 'payload');Validating a DKIM-style selector
import { assertValidSelector, isValidSelector } from '@kagal/ed25519-secret';
// Predicate: branch on the result
if (isValidSelector(value)) {
// matches the pattern
}
// Assertion: fail fast on misconfigured input
assertValidSelector(value, 'config');API
VERSION— package version string, mirrorspackage.json#version.
Keys and seeds
KeyContext— the returned value:privateKey(the brandedEd25519Seed, for persistence),publicKey(extractable, for distribution),signKey(non-extractable, for in-process signing), andpublicJWK(publication-ready JWK).KeyPair— deprecated alias forKeyContext.Ed25519PublicJWK— typed public JWK for Ed25519 (RFC 8037 §3.1): literalkty: 'OKP',crv: 'Ed25519',x(base64url public key),use: 'sig',alg: 'EdDSA', and the optionalkid(RFC 7517 §4.5). Values returned bynewKeysareObject.freezed.Ed25519Seed— branded 32-byte seed (RFC 8032); values are length-validated and defensive-copied at construction.asEd25519Seed(input, context?)— validate length and brand a seed; accepts a 32-byteUint8Arrayor its base64 encoding.encodeKey(key, context?)— export an extractable publicCryptoKeyfor a supported algorithm as standard base64 of its raw form, ready for out-of-band distribution (e.g. a DNS TXT record). The output round-trips throughdecodeBase64+crypto.subtle.importKey('raw', ...). ThrowsTypeErrorif the key's algorithm isn't supported, or if it isn't a public key, or if WebCrypto refuses to export the raw bytes (non-extractable); passcontextto prefix the error message.newKeys(input?, kid?, context?)— build aKeyContextfrom a 32-byte raw seed (or its base64 encoding); omit / passundefinedto generate a fresh seed viacrypto.getRandomValues.kid(optional, free-form string) is threaded intopublicJWK.kid; falsy values (undefined, empty string) omit the field.contextprefixes any thrown error and defaults to'newKeys'.newKeyPair(input?, context?)— deprecated wrapper overnewKeyspreserving the original 2-arg signature.contextdefaults to'newKeyPair'; the returnedpublicJWKcarries nokid.
JWKS
Ed25519JWKSet— JWK Set (RFC 7517 §5) containing Ed25519 public JWKs only — the shape served by ajwks.jsonendpoint when every key is Ed25519:{ keys: Ed25519PublicJWK[] }. Values returned bymakeJWKSareObject.freezed (the set and itskeysarray).makeJWKS(keys)— collect every entry'spublicJWKinto anEd25519JWKSet. Accepts a singleKeyContext(or any{ publicJWK }container), an array (including empty), orundefined; empty inputs yield{ keys: [] }. Input order is preserved.
Key records
KeyRecord<P>— DKIM-style tag-list record (RFC 6376 §3.2 syntax, §3.6.1p=semantics) with declaredk?,p,v?and an index signature for additional tags.Ptracksp's value type:Uint8Array(default; parse direction),string(publish direction),CryptoKey(verify-only, post-import), orVerifier(post-wrap). Consumers needing typed access to a specific tag set extend the interface.KeyRecordInput—{ publicKey?, selector }; a publicCryptoKeyof a supported algorithm paired with the DKIM selector under which it will be published. OmitpublicKeyto publish a revocation record (emptyp=, RFC 6376 §3.6.1).KeyConfig(and any config carrying aselector) satisfies this structurally.makeKeyRecords(input, template?, context?)— buildKeyRecords ready for publication as<selector>._keys.<domain>DNS TXT values. Accepts a singleKeyRecordInput, an array (including empty), orundefined; returns a frozen{ [selector]: record }keyed by selector. Input order is preserved; duplicate selectors last-write-wins.templatesuppliesv=and any additional tags (via its index signature);k=(the key's algorithm — lowercase WebCrypto name,'ed25519') andp(the base64-encoded public key) are synthesised by the function and override any same-named entries intemplate. An input that omitspublicKeyyields a revocation record (emptyp=,k=omitted, RFC 6376 §3.6.1).context(default'makeKeyRecords') prefixes any thrown error; array inputs decorate as<context>: input Nto disambiguate failures.parseKeyRecord(input, context?)— parse a TXT record value (raw string, DoH-JSON-quoted string, or a pre-extracted character-string array) into aKeyRecord<Uint8Array>. Strict on tag-list syntax, lenient on semantics (unknownv=/k=values and extra tags pass through). Emptyp=yieldsp: undefinedper RFC 6376 §3.6.1's revoked-key convention.contextprefixes any thrown error.parseRecordToKey(input, context?)— parse a TXT record value into aKeyRecord<CryptoKey>, importing thep=bytes into a verify-onlyCryptoKey. The algorithm comes fromk=, defaulting torsaonly whenk=is absent (RFC 6376 §3.6.1); an unsupported algorithm — thersadefault, an emptyk=, or any non-Ed25519 value — is rejected rather than silently substituted. A revoked record (emptyp=) carries through asp: undefined; other tags pass through.context(default'parseRecordToKey') prefixes any thrown error.parseRecordToVerifier(input, context?)— likeparseRecordToKey, but wraps the imported key as aVerifier, yielding aKeyRecord<Verifier>. Same revocation and tag pass-through behaviour;contextdefaults to'parseRecordToVerifier'.
Secrets
KeyConfig— extendsKeyContextwith three fields, inheritingprivateKey,publicKey,signKey,publicJWKfrom it:selector: string— validated againstSELECTOR_PATTERN; also set aspublicJWK.kid.signer: Signer— pre-built, backed bysignKey.verifier: Verifier— pre-built, backed bypublicKey.
newSecret(selector, context?)— mint a freshselector:base64secret. ValidatesselectoragainstSELECTOR_PATTERN, then encodes a freshly generated 32-byte Ed25519 seed (crypto.getRandomValues) as standard base64. No selector default — the caller supplies it.contextprefixes any thrown error and defaults to'newSecret'.parseSecretToKey(secretString, context?)— parse aselector:base64secret into aKeyConfig. The base64 portion is a 32-byte Ed25519 seed (standard or URL-safe).contextprefixes any thrown error and defaults to'parseSecretToKey'.parseSecretsToKeys(secrets, strict?, context?)— parse multipleselector:base64secrets from a single string with whitespace- or punctuation-separated entries; empty fragments are dropped.strict: true(default) rejects on a malformed entry with<context>: secret N: ....strict: falsesilently skips failures and returns only the entries that parsed (input order preserved).contextdefaults to'parseSecretsToKeys'.
Signer
Signer—{ sign: (message) => Promise<ArrayBuffer> }wheremessage: BufferSource | stringnewSigner(key, context?)— WebCrypto Ed25519 signer factory. Pass an Ed25519 privateCryptoKeywith'sign'inusages; returns 64-byte raw RFC 8032 signatures. ThrowsTypeErroron a non-Ed25519 key or missing usage;contextprefixes the message.
Verifier
Verifier—{ verify: (sig, msg) => Promise<boolean> }wheresig: BufferSourceandmsg: BufferSource | stringnewVerifier(key, context?)— WebCrypto Ed25519 verifier factory. Pass an Ed25519 publicCryptoKeywith'verify'inusages; delegates each call tocrypto.subtle.verify, which is specified to apply RFC 8032 §5.1.7 strict verification on conformant runtimes. ThrowsTypeErroron a non-Ed25519 key or missing usage;contextprefixes the message.importVerifyKey(algorithm, keyData, context?)— import a raw-encoded public verifying key (e.g. thep=bytes fromparseKeyRecord) into an extractable verify-onlyCryptoKey.algorithmmatches case-insensitively, so DKIMk=values ('ed25519'per RFC 6376 §3.6.1) work without pre-normalisation.keyDataaccepts raw bytes or their base64 encoding (standard or URL-safe). ThrowsTypeErrorfor an unsupported algorithm, wrong byte length, or undecodable base64;contextprefixes the message.
Selector validation
SELECTOR_PATTERN—/^[A-Za-z](?:[\dA-Za-z_-]{0,61}[\dA-Za-z])?$/, the DKIM selector grammar (RFC 6376 §3.1, narrowed to a single label so the value is also a valid sf-token under RFC 9651). Selectors must start with a letter and end with a letter or digit.isValidSelector(value)— boolean predicate.assertValidSelector(value, context?)— throwsTypeErroron a non-matching value, naming the pattern and quoting the input;contextprefixes the message.
Byte helpers
Bytes—Uint8Array<ArrayBuffer>-shaped type (TS lib 5.7+; plainUint8Arrayon older), matching whatBufferSource(and therefore everycrypto.subtle.*byte parameter) accepts. The return type ofdecodeBase64,getRandom, andasBytes.encodeBase64(bytes)— encode bytes as standard base64 with=padding.decodeBase64(b64, context?)— decode standard or URL-safe base64, padding optional. ThrowsTypeErroronatob-rejected input (original rejection ascause); passcontextto prefix the error message.decodeASCII(bytes, context?)— decode bytes as 7-bit ASCII, one code point per byte. ThrowsTypeErroron any byte ≥0x80rather than mapping it into the Latin-1 range; passcontextto prefix the error message.asBytes(input, context?)— normalise a bytes-or-base64 input to a freshUint8Array. Bytes are defensive-copied; strings go throughdecodeBase64.asMessageBytes(message)— normalise aBufferSource | stringinput toBufferSource. Bytes pass through; strings are encoded as UTF-8. Used internally bySigner.sign/Verifier.verifyto accept either shape; differs fromasBytes, whose string input is base64-decoded.getRandom(length, context?)— freshUint8Arrayof the requested length filled viacrypto.getRandomValues. ThrowsTypeErroron non-integer or negativelength; passcontextto prefix the error message.
Numeric helpers
atLeast(min, value?)— a minimum floor for an optional numeric value: always returns a whole number no smaller thanmin, never a fractional orNaNresult. A missing, non-finite, or below-minvalue collapses tomin; anything larger is rounded to the nearest integer.isInRange(value, min, max?)— whethervalueis an integer within the inclusive range[min, max].maxdefaults toNumber.MAX_SAFE_INTEGER, so a two-argument call tests whethervalueis an integer ≥min. Anundefined, fractional,NaN, infinite, or out-of-range value isfalse.
List helpers
splitFirst(items)/splitLast(items)— splititemsintofirst/last+rest. Accepts a list, a single value, orundefined.undefinedor an empty array yields{ rest: [] }; a single value or a one-element array yields{ first/last, rest: [] }.
Licence
MIT — see LICENCE.txt.
