@zamatica/auth-core
v0.1.0
Published
Pure-function cryptographic primitives for the zamatica fleet-env cert auth scheme. Ed25519 keys, RFC 8785 (JCS) canonicalization, certs, trust bundles, CRLs, HTTP request signing, and daemon-per-deploy identity. No framework dependencies.
Readme
@zamatica/auth-core
Pure-function cryptographic primitives for the zamatica fleet-env cert auth scheme.
This lib has zero framework dependencies — no NestJS, no Express, no DI. It's safe to consume from any TypeScript context (CLI, desktop main process, NestJS guards, browsers via Node-crypto-compatible polyfills). The NestJS wiring layer lives in @mtz/auth-backend (and will be harvested into @zamatica/auth-backend once a second downstream consumer exists).
What's in the box
| Module | What it does |
|---|---|
| errors | AuthError discriminated error type + Result<T, E> discriminated union |
| roles | Centrally enumerated ROLE_NAMES + RoleName type + isRoleName() guard |
| constant-time | constantTimeEqual() — wraps crypto.timingSafeEqual for signature comparisons |
| jcs | RFC 8785 JSON Canonicalization Scheme — the ONLY canonicalizer used for signing |
| serial | Deterministic cert serial from sha256(pubkey || issuedAt), first 16 bytes hex |
| keypair | Ed25519 generate / sign / verify via node:crypto. Base64-of-DER representation. |
| cert | Per-user cert sign / parse / verify |
| trust-bundle | Per-fleet-env trust anchor (root(s) + CRL-signer subkey), with rotation support |
| crl | Certificate revocation list sign / verify / serial-lookup |
| http-signature | Sign and verify HTTP requests (method + canonical URL + ts + nonce + body hash + instance ID) |
| daemon-identity | Per-deploy daemon keypair certification (TOFU pinning support) |
Crypto choices
- Ed25519: small keys (32 bytes), fast, modern, no parameter choices to get wrong. Supported natively in Node 22+ via
node:crypto. - SHA-256: for serials, body hashes, fingerprints. Sufficient for non-collision needs at these sizes.
- AES-GCM: NOT in this lib — that's in
@zamatica/auth-credentialsfor encrypted-file fallback storage. - No X.509: certs are JSON with a signature field; trust bundles are JSON with a signature field. Simple to read, simple to sign, no ASN.1 parser to misconfigure.
JCS — why it matters
Naive JSON.stringify produces non-deterministic bytes: key order is engine-dependent, numbers have multiple valid representations (1e2 vs 100 vs 100.0), whitespace is implementation-defined. Signing those bytes means a verifier with a different stringify produces a different digest, and signature verification silently fails.
RFC 8785 (JCS) pins exact rules: keys sorted lexicographically (by UTF-16 code units), numbers per ECMA-262, strings escaped per RFC 8259's shortest form. Every signature in this lib goes through canonicalize() first. Don't sign JSON.stringify(obj) — always sign canonicalize(obj).
Install
bun add @zamatica/auth-core
# or
npm install @zamatica/auth-coreQuick examples
Generate a keypair, sign + verify a payload
import { generateKeyPair, sign, verify } from '@zamatica/auth-core';
const { publicKey, privateKey } = generateKeyPair();
const payload = new TextEncoder().encode('hello');
const signature = sign(privateKey, payload);
const ok = verify(publicKey, payload, signature); // trueSign + verify a user cert
import { generateKeyPair, signCert, verifyCert, parseCert } from '@zamatica/auth-core';
const root = generateKeyPair();
const user = generateKeyPair();
const cert = signCert(
{
user: 'alice',
acronym: 'mtz',
fleetEnv: 'prod',
pubkey: user.publicKey,
issuedAt: new Date().toISOString(),
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
roles: ['user', 'admin'],
},
root.privateKey,
);
const result = verifyCert(cert, root.publicKey);
if (!result.ok) {
throw result.error; // AuthError
}Sign + verify an HTTP request
import { signRequest, verifyRequest } from '@zamatica/auth-core';
const headers = signRequest(cert, user.privateKey, {
method: 'POST',
url: new URL('https://daemon.example.com/api/foo?x=1'),
body: new TextEncoder().encode('{"hello":"world"}'),
timestamp: Math.floor(Date.now() / 1000),
nonce: crypto.randomBytes(16).toString('hex'),
instanceId: 'abc-123', // optional but recommended; required for /admin/*
});
// On the daemon side:
const seenNonces = new Set<string>();
const verified = verifyRequest(
{
method: 'POST',
url: new URL('https://daemon.example.com/api/foo?x=1'),
body: new TextEncoder().encode('{"hello":"world"}'),
headers: { /* ...the headers from above... */ },
expectedInstanceId: 'abc-123',
signatureWindowSeconds: 300,
seenNonces: { has: (n) => seenNonces.has(n), add: (n) => seenNonces.add(n) },
},
trustBundle,
);
if (!verified.ok) {
// verified.error.code is one of the AuthErrorCode values
}Development
From the workspace root:
bunx nx build auth-core
bunx nx test auth-core