From 8a9df49673d9345442ec6cd325833ae10193a763 Mon Sep 17 00:00:00 2001 From: Jonathan Raphaelson Date: Sun, 15 Jun 2025 16:11:34 -0600 Subject: [PATCH] pre-auth protocol works mostly --- eslint.config.js | 2 +- src/cmd/register-ident.js | 36 ++++++++++ src/common/crypto/jwks.js | 80 +++++++-------------- src/common/crypto/jwts.js | 4 +- src/common/errors.js | 1 + src/common/protocol.js | 8 ++- src/common/socket.js | 2 +- src/server/routes-socket/handler-preauth.js | 56 +++++++++------ 8 files changed, 109 insertions(+), 80 deletions(-) create mode 100644 src/cmd/register-ident.js diff --git a/eslint.config.js b/eslint.config.js index 224f708..d80e1c5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -61,7 +61,7 @@ export default defineConfig([ // server specific { - files: ['./src/server/*.js', './src/server/**/*.js'], + files: ['./src/server/*.js', './src/server/**/*.js', './src/cmd/*.js', './src/cmd/**/*.js'], languageOptions: { globals: { ...globals.es2024, diff --git a/src/cmd/register-ident.js b/src/cmd/register-ident.js new file mode 100644 index 0000000..56fc7c0 --- /dev/null +++ b/src/cmd/register-ident.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +/* eslint-disable jsdoc/require-jsdoc */ + +import { generateSignableJwt, generateSigningJwkPair, jwkExport } from '#common/crypto/jwks.js' +import { IdentBrand, RealmBrand } from '#common/protocol.js' + +async function generateRegistrationJWT() { + const keypair = await generateSigningJwkPair() + const realmid = RealmBrand.generate() + const identid = IdentBrand.generate() + + const payload = { + iss: identid, + aud: realmid, + msg: 'preauth.register', + pubkey: await jwkExport.parseAsync(keypair.publicKey), + } + + const jwt = generateSignableJwt(payload) + .setIssuedAt() + .setExpirationTime('1m') + .sign(keypair.privateKey) + + console.log('Generated Preauth JWT:') + console.log(jwt) + + console.log('\nPayload:') + console.log(JSON.stringify(payload, null, 2)) +} + +// this is just a test +// do not be alarmed +// this is only a test + +generateRegistrationJWT().catch(console.error) diff --git a/src/common/crypto/jwks.js b/src/common/crypto/jwks.js index 0e802f3..f30e576 100644 --- a/src/common/crypto/jwks.js +++ b/src/common/crypto/jwks.js @@ -2,34 +2,13 @@ import * as jose from 'jose' import { z } from 'zod/v4' +import { CryptoError } from './errors.js' -const signAlgo = { name: 'ES256' } - -const jwkBaseSchema = z.object({ - 'alg': z.string().optional(), - 'ext': z.boolean().optional(), - 'key_ops': z.array(z.string()).optional(), - 'kid': z.string().optional(), - 'use': z.string().optional(), - 'x5c': z.array(z.string()).optional(), - 'x5t#S256': z.string().optional(), - 'x5t': z.string().optional(), - 'x5u': z.string().optional(), -}) - -const jwkOkpPublicSchema = z.object({ - ...jwkBaseSchema.shape, - crv: z.string(), - x: z.string(), -}) - -const jwkOkpPrivateSchema = z.object({ - ...jwkOkpPublicSchema.shape, - d: z.string(), -}) +const subtleSignAlgo = { name: 'ECDSA', namedCurve: 'P-256' } +const joseSignAlgo = { name: 'ES256' } const jwkEcPublicSchema = z.object({ - ...jwkBaseSchema.shape, + kty: z.literal('EC'), crv: z.string(), x: z.string(), y: z.string(), @@ -40,44 +19,17 @@ const jwkEcPrivateSchema = z.object({ d: z.string(), }) -const jwkRSAPublicSchema = z.object({ - ...jwkBaseSchema.shape, - e: z.string(), - n: z.string(), -}) - -const jwkRSAPrivateSchema = z.object({ - ...jwkRSAPublicSchema.shape, - d: z.string(), - dp: z.string(), - qp: z.string(), - p: z.string(), - q: z.string(), - qi: z.string(), -}) - -const jwkOctSchema = z.object({ - ...jwkBaseSchema.shape, - k: z.string(), -}) - /** * a zod schema describing a JWK from jose - * EC, OKP, RSA and oct key types are supported + * we only support EC keys, to make life easier * * @see https://www.rfc-editor.org/rfc/rfc7517 * @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2 * @type {z.ZodType} */ export const jwkSchema = z.union([ - jwkBaseSchema, - jwkOkpPublicSchema, - jwkOkpPrivateSchema, jwkEcPublicSchema, jwkEcPrivateSchema, - jwkRSAPublicSchema, - jwkRSAPrivateSchema, - jwkOctSchema, ]) /** @@ -88,7 +40,7 @@ export const jwkSchema = z.union([ export const jwkImport = z.transform(async (val, ctx) => { try { if (typeof val === 'object' && val !== null) { - const key = await jose.importJWK(val, signAlgo.name) + const key = await jose.importJWK(val, joseSignAlgo.name) if (key instanceof CryptoKey) { return key } @@ -145,3 +97,23 @@ export const jwkExport = z.transform(async (val, ctx) => { return z.NEVER }) + +/** + * @returns {Promise} a newly generated, signing compatible keypair + */ +export async function generateSigningJwkPair() { + const pair = await crypto.subtle.generateKey(subtleSignAlgo, true, ['sign', 'verify']) + if (!('publicKey' in pair)) + throw new CryptoError('keypair returned a single key!?') + + return pair +} + +/** + * @param {jose.JWTPayload} payload the payload to sign + * @returns {jose.SignJWT} a properly configured jwt signer, with the payload provided + */ +export function generateSignableJwt(payload) { + return new jose.SignJWT(payload) + .setProtectedHeader({ alg: joseSignAlgo.name }) +} diff --git a/src/common/crypto/jwts.js b/src/common/crypto/jwts.js index fbe19ee..965f552 100644 --- a/src/common/crypto/jwts.js +++ b/src/common/crypto/jwts.js @@ -40,7 +40,7 @@ export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => { /** @typedef {Partial>} VerifyOpts */ /** - * @param {JWTToken} jwt the (already decoded) token to verify + * @param {string} jwt the (still encoded) token to verify * @param {CryptoKey} pubkey the key with which to verify the token * @param {VerifyOpts} [options] the key with which to verify the token * @returns {Promise} a verified payload @@ -48,7 +48,7 @@ export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => { */ export async function verifyJwtToken(jwt, pubkey, options = {}) { try { - const result = await jose.jwtVerify(jwt.token, pubkey, { + const result = await jose.jwtVerify(jwt, pubkey, { algorithms: [signAlgo.name], ...options, }) diff --git a/src/common/errors.js b/src/common/errors.js index d3e5542..fcf6a70 100644 --- a/src/common/errors.js +++ b/src/common/errors.js @@ -8,6 +8,7 @@ import { prettifyError, ZodError } from 'zod/v4' */ const StatusCodes = { 400: 'Bad Request', + 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 408: 'Request Timeout', diff --git a/src/common/protocol.js b/src/common/protocol.js index a00895b..e1a1809 100644 --- a/src/common/protocol.js +++ b/src/common/protocol.js @@ -10,14 +10,20 @@ export const IdentBrand = new Brand('ident') export const RealmBrand = new Brand('realm') /** @typedef {z.infer} RealmID */ +/** zod schema for `preauth.authn` message */ +export const preauthRegisterMessageSchema = z.object({ + msg: z.literal('preauth.register'), + pubkey: jwkSchema, +}) + /** zod schema for `preauth.authn` message */ export const preauthAuthnMessageSchema = z.object({ msg: z.literal('preauth.authn'), - pubkey: jwkSchema, }) /** zod schema for any `preauth` messages */ export const preauthMessageSchema = z.discriminatedUnion('msg', [ + preauthRegisterMessageSchema, preauthAuthnMessageSchema, ]) diff --git a/src/common/socket.js b/src/common/socket.js index 952b5fb..fa5decf 100644 --- a/src/common/socket.js +++ b/src/common/socket.js @@ -155,7 +155,7 @@ export async function* streamSocket(ws, config_) { signal?.throwIfAborted() const [event, value] = await queue.dequeue(signal) - if (queue.depth < backoffThresh) { + if (inBackoffMode && queue.depth < backoffThresh) { console.log('message stream will stop dropping messages due to eased backpressure') inBackoffMode = false } diff --git a/src/server/routes-socket/handler-preauth.js b/src/server/routes-socket/handler-preauth.js index 44f3c72..2f04fa0 100644 --- a/src/server/routes-socket/handler-preauth.js +++ b/src/server/routes-socket/handler-preauth.js @@ -2,9 +2,10 @@ import { combineSignals, timeoutSignal } from '#common/async/aborts.js' import { jwkImport } from '#common/crypto/jwks.js' import { jwtSchema, verifyJwtToken } from '#common/crypto/jwts.js' import { normalizeError, ProtocolError } from '#common/errors.js' -import { IdentBrand, preauthAuthnMessageSchema, RealmBrand } from '#common/protocol.js' +import { IdentBrand, preauthMessageSchema, RealmBrand } from '#common/protocol.js' import { takeSocket } from '#common/socket.js' +import * as protocol_types from '#common/protocol.js' import * as realms from './state.js' /** @@ -21,33 +22,46 @@ export async function preauthHandler(ws, signal) { const combinedSignal = combineSignals(signal, timeout.signal) try { - // if any of the parsing fails, it'll throw a zod error const data = await takeSocket(ws, combinedSignal) - const jwt = jwtSchema.parse(data) - const msg = await preauthAuthnMessageSchema.parseAsync(jwt.payload) - - const registrantid = IdentBrand.parse(jwt.payload.iss) - const registrantkey = await jwkImport.parseAsync(msg.pubkey) + // if any of the parsing fails, it'll throw a zod error + const jwt = jwtSchema.parse(data) + const msg = await preauthMessageSchema.parseAsync(jwt.payload) + const identid = IdentBrand.parse(jwt.payload.iss) const realmid = RealmBrand.parse(jwt.payload.aud) - const realm = realms.ensureRegisteredRealm(realmid, registrantid, registrantkey) - - // important! if the real already existed, we hove _not_ mutated it - // so we have to check the signature against whatever pubkey we have in the store, - // not the one tha comes in from the request; we only allow it to come in for bootstrapping - try { - const knownkey = realm.identities.require(registrantid) - const payload = await verifyJwtToken(jwt, knownkey) - console.log('payload', payload) - return { realmid, realm, identid: registrantid, pubkey: knownkey } - } - catch (exc) { - const err = normalizeError(exc) - throw new ProtocolError('jwt verification failed', 401, { cause: err }) + // if we're registering, make sure the realm exists + if (msg.msg === 'preauth.register') { + const registrantkey = await jwkImport.parseAsync(msg.pubkey) + realms.ensureRegisteredRealm(realmid, identid, registrantkey) } + + return authenticatePreauth(realmid, identid, jwt.token) } finally { timeout.cancel() } } + +/** + * @param {protocol_types.RealmID} realmid the realm id to lookup + * @param {protocol_types.IdentID} identid the identity id to authenticate against + * @param {string} token the (still encoded) JWT to verify + * @returns {Promise} an authenticated connection from this token + * @throws {ProtocolError} when the token isn't validly signed or the identity is unrecognized + */ +async function authenticatePreauth(realmid, identid, token) { + try { + const realm = realms.realmMap.require(realmid) + const pubkey = realm.identities.require(identid) + + // at this point we no langer care about the payload + // but this throws as a side-effect if the token is invalid + await verifyJwtToken(token, pubkey) + return { realmid, realm, identid, pubkey } + } + catch (exc) { + const err = normalizeError(exc) + throw new ProtocolError('jwt verification failed', 401, { cause: err }) + } +} -- 2.51.2