JOSE: JWS signatures and JWK keys

The JOSE message envelope (RFC 7515 JWS, RFC 7518 JWA, RFC 7638 JWK thumbprint), profiled for RFC 8555 ACME but usable on its own. A JWS here is the Flattened JSON Serialization only, { protected, payload, signature }, with the multi-signature signatures member, the unprotected header member, and the RFC 7797 unencoded-payload b64 option all structurally forbidden. Every base64url field is decoded by a strict codec (Node's Buffer.from(s, "base64url") accepts padding, whitespace, and non-canonical trailing bits, all of which are rejected here), and every JSON document is read by a bounded reader that rejects a duplicate member at any nesting depth (the parser-differential smuggling class JSON.parse silently allows).

Algorithms resolve through an alg-keyed registry (ES256/384/512, RS256/384/512, PS256/384/512, EdDSA, and the RFC 9964 ML-DSA-44/65/87 PQC rows), never a switch: the registry binds alg to a key type and pins the exact signature byte length before any crypto call, so alg:"none", a MAC algorithm on the outer profile, an ES256/RSA-key confusion, and a DER-vs-raw ECDSA signature all fail closed. sign and verify are each driven by one declarative profile table, so the same data drives both directions.

pki.jose.base64url.encode

since 0.1.25 stable
pki.jose.base64url.encode(bytes) -> string

Encode a Buffer as unpadded base64url (RFC 4648 sec. 5): the +// characters become -/_ and the trailing = padding is omitted.

Example

pki.jose.base64url.encode(Buffer.from([1, 2, 3]));   // -> "AQID"

References

pki.jose.base64url.decode

since 0.1.25 stable
pki.jose.base64url.decode(text) -> Buffer

Decode base64url to a Buffer, STRICTLY (RFC 8555 sec. 6.1): trailing = padding, any non-alphabet character (+, /, whitespace), and a non-canonical final character (one whose discarded low bits are non-zero) each throw jose/bad-base64url. The canonical check is a re-encode round-trip.

Example

pki.jose.base64url.decode("AQID");   // -> <Buffer 01 02 03>

References

pki.jose.parseJson

since 0.1.25 stable
pki.jose.parseJson(input) -> value

Parse a JSON document (a Buffer or a string) with a bounded, strict reader: the byte size is capped (jose/too-large), nesting is capped (jose/too-deep), a duplicate member at ANY depth is rejected (jose/duplicate-member), and a Buffer is decoded as strict UTF-8 (invalid bytes throw). Unlike JSON.parse, a duplicate key never silently resolves to the last value.

Example

pki.jose.parseJson('{"a":1}');   // -> { a: 1 }

References

pki.jose.assertPublicJwk

since 0.1.25 stable
pki.jose.assertPublicJwk(jwk) -> jwk

Assert that a JWK is public-only before it is published (embedded in a JWS protected header or an ACME External Account Binding payload). A JWK carrying any private member (d, the RSA CRT parameters p/q/dp/dq/qi, the symmetric k, or the AKP priv) throws jose/private-key-material, so an accidentally exported private JWK is never sent to a server. Returns the JWK.

Example

pki.jose.assertPublicJwk({ kty: "EC", crv: "P-256", x: "...", y: "..." });

References

pki.jose.verify

since 0.1.25 stable
pki.jose.verify(jws, opts) -> Promise<{ header, payload, keySource }>

Verify a Flattened JSON JWS against a profile (opts.profile, default "acme-outer"). Structural rules fail closed before any crypto: the signatures/header members and a detached payload are rejected, the protected header is validated against the profile (alg registry, nonce, url, exactly-one-of jwk/kid, crit), and the signature byte length is pinned per alg.

opts.key names the key the message must be signed under, and it governs: where the profile also permits an embedded header jwk, the two must be the same key or the message is refused with jose/key-mismatch. They are compared as RFC 7638 thumbprints, so member order and members outside the key itself cannot make equal keys look different. Without opts.key the embedded jwk is used where the profile permits one, which verifies that the message is internally consistent, not that any particular signer produced it. keySource reports which of the two answered, so a signature checked against a caller-named key is distinguishable from one checked against the key the message brought with it.

Returns { header, payload, keySource } (payload a raw Buffer); a failed signature throws jose/verify-failed.

Options

profile: string   // "acme-outer" | "eab-inner" | "keychange-inner"
key: object       // a public JWK, required unless the profile embeds jwk

Example

async function example() {
  var ec = await pki.key.generate({ name: "ECDSA", namedCurve: "P-256" });
  var accountJwk = await pki.webcrypto.subtle.exportKey("jwk", ec.publicKey);
  var jws = await pki.jose.sign({ protected: { alg: "ES256", jwk: accountJwk,
    nonce: "oFvnlFP1wIhRlYS2jTaXbA", url: "https://ca.example/acme/new-acct" },
    payload: Buffer.from("{}"), key: ec.privateKey });
  var v = await pki.jose.verify(jws, { profile: "acme-outer", key: accountJwk });
  v.header.alg;   // -> "ES256"
}
example();

References

pki.jose.sign

since 0.1.25 stable
pki.jose.sign(opts) -> Promise<{ protected, payload, signature }>

Produce a Flattened JSON JWS. opts.protected is the protected-header object (validated against opts.profile), opts.payload the raw payload octets (a Buffer; the empty Buffer yields the POST-as-GET payload:""), and opts.key a private CryptoKey. The signing input is built from the encoded header and payload and signed with the alg the header names.

The key need not have been created by this toolkit's own WebCrypto: one from the platform's, or from a separately-installed copy of this toolkit, is re-imported through this engine, since it carries none of the material this engine signs with. A key created non-extractable cannot be re-imported, and is refused with that as the reason, as is one whose implementation keeps its material out of reach entirely.

Options

protected: object    // the protected header (alg, nonce, url, jwk|kid)
payload: Buffer      // the raw payload octets ("" for POST-as-GET)
key: CryptoKey       // the private signing key
profile: string      // default "acme-outer"
jwk: object          // the public JWK, in kid mode (a header that embeds jwk supplies its own)

Example

async function example() {
  var ec = await pki.key.generate({ name: "ECDSA", namedCurve: "P-256" });
  var priv = ec.privateKey;
  var hdr = { alg: "ES256", jwk: await pki.webcrypto.subtle.exportKey("jwk", ec.publicKey),
    nonce: "oFvnlFP1wIhRlYS2jTaXbA", url: "https://ca.example/acme/new-acct" };
  var jws = await pki.jose.sign({ protected: hdr, payload: Buffer.from("{}"), key: priv });
}
example();

References

pki.jose.thumbprint

since 0.1.25 stable
pki.jose.thumbprint(jwk) -> Promise<string>

The RFC 7638 JWK SHA-256 thumbprint as base64url: the canonical JSON of just the key type's required members, lexicographically ordered, no whitespace, hashed. Optional members (alg, use, kid) are excluded, so the same key always yields the same thumbprint (the ACME key-authorization anchor).

Example

async function example() {
  // the RFC 7638 sec. 3.1 worked example, so the thumbprint below is the spec's own
  var accountJwk = { kty: "RSA", e: "AQAB", n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78L" +
    "hWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXA" +
    "rwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajr" +
    "n1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-" +
    "kEgU8awapJzKnqDKgw" };
  await pki.jose.thumbprint(accountJwk);   // -> "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
}
example();

References

pki.jose.sigAlgs

since 0.5.3 stable
pki.jose.sigAlgs() -> Array<{alg,kty,crv,hash,saltLength}>

The JWS signature algorithms this toolkit verifies, one row per alg, each naming the JWK key type it requires (kty, plus the exact crv where the curve is fixed), the hash, and the RSASSA-PSS salt length where the algorithm is PSS. Rows describe key material in RFC 7517 / 7518 vocabulary only, so a caller can decide whether a key it holds can sign or verify a given alg without a table of its own.

MAC algorithms are deliberately absent: HS* is not a signature algorithm and listing it beside RS256 is how the HMAC key-confusion class starts. none does not exist here at all.

Each call returns a fresh array of fresh rows. The registry that drives verification is never handed out, so nothing a caller does to the result can widen what a signature check accepts.

Example

var pss = pki.jose.sigAlgs().filter(function (r) { return r.saltLength; });
pss.map(function (r) { return r.alg; });   // -> ["PS256", "PS384", "PS512"]

References