pre-auth protocol works mostly

Changed files
+109 -80
src
+1 -1
eslint.config.js
··· 61 62 // server specific 63 { 64 - files: ['./src/server/*.js', './src/server/**/*.js'], 65 languageOptions: { 66 globals: { 67 ...globals.es2024,
··· 61 62 // server specific 63 { 64 + files: ['./src/server/*.js', './src/server/**/*.js', './src/cmd/*.js', './src/cmd/**/*.js'], 65 languageOptions: { 66 globals: { 67 ...globals.es2024,
+36
src/cmd/register-ident.js
···
··· 1 + #!/usr/bin/env node 2 + 3 + /* eslint-disable jsdoc/require-jsdoc */ 4 + 5 + import { generateSignableJwt, generateSigningJwkPair, jwkExport } from '#common/crypto/jwks.js' 6 + import { IdentBrand, RealmBrand } from '#common/protocol.js' 7 + 8 + async function generateRegistrationJWT() { 9 + const keypair = await generateSigningJwkPair() 10 + const realmid = RealmBrand.generate() 11 + const identid = IdentBrand.generate() 12 + 13 + const payload = { 14 + iss: identid, 15 + aud: realmid, 16 + msg: 'preauth.register', 17 + pubkey: await jwkExport.parseAsync(keypair.publicKey), 18 + } 19 + 20 + const jwt = generateSignableJwt(payload) 21 + .setIssuedAt() 22 + .setExpirationTime('1m') 23 + .sign(keypair.privateKey) 24 + 25 + console.log('Generated Preauth JWT:') 26 + console.log(jwt) 27 + 28 + console.log('\nPayload:') 29 + console.log(JSON.stringify(payload, null, 2)) 30 + } 31 + 32 + // this is just a test 33 + // do not be alarmed 34 + // this is only a test 35 + 36 + generateRegistrationJWT().catch(console.error)
+26 -54
src/common/crypto/jwks.js
··· 2 3 import * as jose from 'jose' 4 import { z } from 'zod/v4' 5 - 6 - const signAlgo = { name: 'ES256' } 7 - 8 - const jwkBaseSchema = z.object({ 9 - 'alg': z.string().optional(), 10 - 'ext': z.boolean().optional(), 11 - 'key_ops': z.array(z.string()).optional(), 12 - 'kid': z.string().optional(), 13 - 'use': z.string().optional(), 14 - 'x5c': z.array(z.string()).optional(), 15 - 'x5t#S256': z.string().optional(), 16 - 'x5t': z.string().optional(), 17 - 'x5u': z.string().optional(), 18 - }) 19 - 20 - const jwkOkpPublicSchema = z.object({ 21 - ...jwkBaseSchema.shape, 22 - crv: z.string(), 23 - x: z.string(), 24 - }) 25 26 - const jwkOkpPrivateSchema = z.object({ 27 - ...jwkOkpPublicSchema.shape, 28 - d: z.string(), 29 - }) 30 31 const jwkEcPublicSchema = z.object({ 32 - ...jwkBaseSchema.shape, 33 crv: z.string(), 34 x: z.string(), 35 y: z.string(), ··· 40 d: z.string(), 41 }) 42 43 - const jwkRSAPublicSchema = z.object({ 44 - ...jwkBaseSchema.shape, 45 - e: z.string(), 46 - n: z.string(), 47 - }) 48 - 49 - const jwkRSAPrivateSchema = z.object({ 50 - ...jwkRSAPublicSchema.shape, 51 - d: z.string(), 52 - dp: z.string(), 53 - qp: z.string(), 54 - p: z.string(), 55 - q: z.string(), 56 - qi: z.string(), 57 - }) 58 - 59 - const jwkOctSchema = z.object({ 60 - ...jwkBaseSchema.shape, 61 - k: z.string(), 62 - }) 63 - 64 /** 65 * a zod schema describing a JWK from jose 66 - * EC, OKP, RSA and oct key types are supported 67 * 68 * @see https://www.rfc-editor.org/rfc/rfc7517 69 * @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2 70 * @type {z.ZodType<jose.JWK>} 71 */ 72 export const jwkSchema = z.union([ 73 - jwkBaseSchema, 74 - jwkOkpPublicSchema, 75 - jwkOkpPrivateSchema, 76 jwkEcPublicSchema, 77 jwkEcPrivateSchema, 78 - jwkRSAPublicSchema, 79 - jwkRSAPrivateSchema, 80 - jwkOctSchema, 81 ]) 82 83 /** ··· 88 export const jwkImport = z.transform(async (val, ctx) => { 89 try { 90 if (typeof val === 'object' && val !== null) { 91 - const key = await jose.importJWK(val, signAlgo.name) 92 if (key instanceof CryptoKey) { 93 return key 94 } ··· 145 146 return z.NEVER 147 })
··· 2 3 import * as jose from 'jose' 4 import { z } from 'zod/v4' 5 + import { CryptoError } from './errors.js' 6 7 + const subtleSignAlgo = { name: 'ECDSA', namedCurve: 'P-256' } 8 + const joseSignAlgo = { name: 'ES256' } 9 10 const jwkEcPublicSchema = z.object({ 11 + kty: z.literal('EC'), 12 crv: z.string(), 13 x: z.string(), 14 y: z.string(), ··· 19 d: z.string(), 20 }) 21 22 /** 23 * a zod schema describing a JWK from jose 24 + * we only support EC keys, to make life easier 25 * 26 * @see https://www.rfc-editor.org/rfc/rfc7517 27 * @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2 28 * @type {z.ZodType<jose.JWK>} 29 */ 30 export const jwkSchema = z.union([ 31 jwkEcPublicSchema, 32 jwkEcPrivateSchema, 33 ]) 34 35 /** ··· 40 export const jwkImport = z.transform(async (val, ctx) => { 41 try { 42 if (typeof val === 'object' && val !== null) { 43 + const key = await jose.importJWK(val, joseSignAlgo.name) 44 if (key instanceof CryptoKey) { 45 return key 46 } ··· 97 98 return z.NEVER 99 }) 100 + 101 + /** 102 + * @returns {Promise<CryptoKeyPair>} a newly generated, signing compatible keypair 103 + */ 104 + export async function generateSigningJwkPair() { 105 + const pair = await crypto.subtle.generateKey(subtleSignAlgo, true, ['sign', 'verify']) 106 + if (!('publicKey' in pair)) 107 + throw new CryptoError('keypair returned a single key!?') 108 + 109 + return pair 110 + } 111 + 112 + /** 113 + * @param {jose.JWTPayload} payload the payload to sign 114 + * @returns {jose.SignJWT} a properly configured jwt signer, with the payload provided 115 + */ 116 + export function generateSignableJwt(payload) { 117 + return new jose.SignJWT(payload) 118 + .setProtectedHeader({ alg: joseSignAlgo.name }) 119 + }
+2 -2
src/common/crypto/jwts.js
··· 40 /** @typedef {Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>} VerifyOpts */ 41 42 /** 43 - * @param {JWTToken} jwt the (already decoded) token to verify 44 * @param {CryptoKey} pubkey the key with which to verify the token 45 * @param {VerifyOpts} [options] the key with which to verify the token 46 * @returns {Promise<jose.JWTPayload>} a verified payload ··· 48 */ 49 export async function verifyJwtToken(jwt, pubkey, options = {}) { 50 try { 51 - const result = await jose.jwtVerify(jwt.token, pubkey, { 52 algorithms: [signAlgo.name], 53 ...options, 54 })
··· 40 /** @typedef {Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>} VerifyOpts */ 41 42 /** 43 + * @param {string} jwt the (still encoded) token to verify 44 * @param {CryptoKey} pubkey the key with which to verify the token 45 * @param {VerifyOpts} [options] the key with which to verify the token 46 * @returns {Promise<jose.JWTPayload>} a verified payload ··· 48 */ 49 export async function verifyJwtToken(jwt, pubkey, options = {}) { 50 try { 51 + const result = await jose.jwtVerify(jwt, pubkey, { 52 algorithms: [signAlgo.name], 53 ...options, 54 })
+1
src/common/errors.js
··· 8 */ 9 const StatusCodes = { 10 400: 'Bad Request', 11 403: 'Forbidden', 12 404: 'Not Found', 13 408: 'Request Timeout',
··· 8 */ 9 const StatusCodes = { 10 400: 'Bad Request', 11 + 401: 'Unauthorized', 12 403: 'Forbidden', 13 404: 'Not Found', 14 408: 'Request Timeout',
+7 -1
src/common/protocol.js
··· 11 /** @typedef {z.infer<typeof RealmBrand.schema>} RealmID */ 12 13 /** zod schema for `preauth.authn` message */ 14 export const preauthAuthnMessageSchema = z.object({ 15 msg: z.literal('preauth.authn'), 16 - pubkey: jwkSchema, 17 }) 18 19 /** zod schema for any `preauth` messages */ 20 export const preauthMessageSchema = z.discriminatedUnion('msg', [ 21 preauthAuthnMessageSchema, 22 ]) 23
··· 11 /** @typedef {z.infer<typeof RealmBrand.schema>} RealmID */ 12 13 /** zod schema for `preauth.authn` message */ 14 + export const preauthRegisterMessageSchema = z.object({ 15 + msg: z.literal('preauth.register'), 16 + pubkey: jwkSchema, 17 + }) 18 + 19 + /** zod schema for `preauth.authn` message */ 20 export const preauthAuthnMessageSchema = z.object({ 21 msg: z.literal('preauth.authn'), 22 }) 23 24 /** zod schema for any `preauth` messages */ 25 export const preauthMessageSchema = z.discriminatedUnion('msg', [ 26 + preauthRegisterMessageSchema, 27 preauthAuthnMessageSchema, 28 ]) 29
+1 -1
src/common/socket.js
··· 155 signal?.throwIfAborted() 156 157 const [event, value] = await queue.dequeue(signal) 158 - if (queue.depth < backoffThresh) { 159 console.log('message stream will stop dropping messages due to eased backpressure') 160 inBackoffMode = false 161 }
··· 155 signal?.throwIfAborted() 156 157 const [event, value] = await queue.dequeue(signal) 158 + if (inBackoffMode && queue.depth < backoffThresh) { 159 console.log('message stream will stop dropping messages due to eased backpressure') 160 inBackoffMode = false 161 }
+35 -21
src/server/routes-socket/handler-preauth.js
··· 2 import { jwkImport } from '#common/crypto/jwks.js' 3 import { jwtSchema, verifyJwtToken } from '#common/crypto/jwts.js' 4 import { normalizeError, ProtocolError } from '#common/errors.js' 5 - import { IdentBrand, preauthAuthnMessageSchema, RealmBrand } from '#common/protocol.js' 6 import { takeSocket } from '#common/socket.js' 7 8 import * as realms from './state.js' 9 10 /** ··· 21 const combinedSignal = combineSignals(signal, timeout.signal) 22 23 try { 24 // if any of the parsing fails, it'll throw a zod error 25 - const data = await takeSocket(ws, combinedSignal) 26 const jwt = jwtSchema.parse(data) 27 - const msg = await preauthAuthnMessageSchema.parseAsync(jwt.payload) 28 - 29 - const registrantid = IdentBrand.parse(jwt.payload.iss) 30 - const registrantkey = await jwkImport.parseAsync(msg.pubkey) 31 - 32 const realmid = RealmBrand.parse(jwt.payload.aud) 33 - const realm = realms.ensureRegisteredRealm(realmid, registrantid, registrantkey) 34 35 - // important! if the real already existed, we hove _not_ mutated it 36 - // so we have to check the signature against whatever pubkey we have in the store, 37 - // not the one tha comes in from the request; we only allow it to come in for bootstrapping 38 - try { 39 - const knownkey = realm.identities.require(registrantid) 40 - const payload = await verifyJwtToken(jwt, knownkey) 41 - console.log('payload', payload) 42 - 43 - return { realmid, realm, identid: registrantid, pubkey: knownkey } 44 - } 45 - catch (exc) { 46 - const err = normalizeError(exc) 47 - throw new ProtocolError('jwt verification failed', 401, { cause: err }) 48 } 49 } 50 finally { 51 timeout.cancel() 52 } 53 }
··· 2 import { jwkImport } from '#common/crypto/jwks.js' 3 import { jwtSchema, verifyJwtToken } from '#common/crypto/jwts.js' 4 import { normalizeError, ProtocolError } from '#common/errors.js' 5 + import { IdentBrand, preauthMessageSchema, RealmBrand } from '#common/protocol.js' 6 import { takeSocket } from '#common/socket.js' 7 8 + import * as protocol_types from '#common/protocol.js' 9 import * as realms from './state.js' 10 11 /** ··· 22 const combinedSignal = combineSignals(signal, timeout.signal) 23 24 try { 25 + const data = await takeSocket(ws, combinedSignal) 26 + 27 // if any of the parsing fails, it'll throw a zod error 28 const jwt = jwtSchema.parse(data) 29 + const msg = await preauthMessageSchema.parseAsync(jwt.payload) 30 + const identid = IdentBrand.parse(jwt.payload.iss) 31 const realmid = RealmBrand.parse(jwt.payload.aud) 32 33 + // if we're registering, make sure the realm exists 34 + if (msg.msg === 'preauth.register') { 35 + const registrantkey = await jwkImport.parseAsync(msg.pubkey) 36 + realms.ensureRegisteredRealm(realmid, identid, registrantkey) 37 } 38 + 39 + return authenticatePreauth(realmid, identid, jwt.token) 40 } 41 finally { 42 timeout.cancel() 43 } 44 } 45 + 46 + /** 47 + * @param {protocol_types.RealmID} realmid the realm id to lookup 48 + * @param {protocol_types.IdentID} identid the identity id to authenticate against 49 + * @param {string} token the (still encoded) JWT to verify 50 + * @returns {Promise<realms.AuthenticatedConnection>} an authenticated connection from this token 51 + * @throws {ProtocolError} when the token isn't validly signed or the identity is unrecognized 52 + */ 53 + async function authenticatePreauth(realmid, identid, token) { 54 + try { 55 + const realm = realms.realmMap.require(realmid) 56 + const pubkey = realm.identities.require(identid) 57 + 58 + // at this point we no langer care about the payload 59 + // but this throws as a side-effect if the token is invalid 60 + await verifyJwtToken(token, pubkey) 61 + return { realmid, realm, identid, pubkey } 62 + } 63 + catch (exc) { 64 + const err = normalizeError(exc) 65 + throw new ProtocolError('jwt verification failed', 401, { cause: err }) 66 + } 67 + }