+1
-1
eslint.config.js
+1
-1
eslint.config.js
+36
src/cmd/register-ident.js
+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
+26
-54
src/common/crypto/jwks.js
···
2
2
3
3
import * as jose from 'jose'
4
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
-
})
5
+
import { CryptoError } from './errors.js'
25
6
26
-
const jwkOkpPrivateSchema = z.object({
27
-
...jwkOkpPublicSchema.shape,
28
-
d: z.string(),
29
-
})
7
+
const subtleSignAlgo = { name: 'ECDSA', namedCurve: 'P-256' }
8
+
const joseSignAlgo = { name: 'ES256' }
30
9
31
10
const jwkEcPublicSchema = z.object({
32
-
...jwkBaseSchema.shape,
11
+
kty: z.literal('EC'),
33
12
crv: z.string(),
34
13
x: z.string(),
35
14
y: z.string(),
···
40
19
d: z.string(),
41
20
})
42
21
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
22
/**
65
23
* a zod schema describing a JWK from jose
66
-
* EC, OKP, RSA and oct key types are supported
24
+
* we only support EC keys, to make life easier
67
25
*
68
26
* @see https://www.rfc-editor.org/rfc/rfc7517
69
27
* @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2
70
28
* @type {z.ZodType<jose.JWK>}
71
29
*/
72
30
export const jwkSchema = z.union([
73
-
jwkBaseSchema,
74
-
jwkOkpPublicSchema,
75
-
jwkOkpPrivateSchema,
76
31
jwkEcPublicSchema,
77
32
jwkEcPrivateSchema,
78
-
jwkRSAPublicSchema,
79
-
jwkRSAPrivateSchema,
80
-
jwkOctSchema,
81
33
])
82
34
83
35
/**
···
88
40
export const jwkImport = z.transform(async (val, ctx) => {
89
41
try {
90
42
if (typeof val === 'object' && val !== null) {
91
-
const key = await jose.importJWK(val, signAlgo.name)
43
+
const key = await jose.importJWK(val, joseSignAlgo.name)
92
44
if (key instanceof CryptoKey) {
93
45
return key
94
46
}
···
145
97
146
98
return z.NEVER
147
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
+2
-2
src/common/crypto/jwts.js
···
40
40
/** @typedef {Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>} VerifyOpts */
41
41
42
42
/**
43
-
* @param {JWTToken} jwt the (already decoded) token to verify
43
+
* @param {string} jwt the (still encoded) token to verify
44
44
* @param {CryptoKey} pubkey the key with which to verify the token
45
45
* @param {VerifyOpts} [options] the key with which to verify the token
46
46
* @returns {Promise<jose.JWTPayload>} a verified payload
···
48
48
*/
49
49
export async function verifyJwtToken(jwt, pubkey, options = {}) {
50
50
try {
51
-
const result = await jose.jwtVerify(jwt.token, pubkey, {
51
+
const result = await jose.jwtVerify(jwt, pubkey, {
52
52
algorithms: [signAlgo.name],
53
53
...options,
54
54
})
+1
src/common/errors.js
+1
src/common/errors.js
+7
-1
src/common/protocol.js
+7
-1
src/common/protocol.js
···
11
11
/** @typedef {z.infer<typeof RealmBrand.schema>} RealmID */
12
12
13
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 */
14
20
export const preauthAuthnMessageSchema = z.object({
15
21
msg: z.literal('preauth.authn'),
16
-
pubkey: jwkSchema,
17
22
})
18
23
19
24
/** zod schema for any `preauth` messages */
20
25
export const preauthMessageSchema = z.discriminatedUnion('msg', [
26
+
preauthRegisterMessageSchema,
21
27
preauthAuthnMessageSchema,
22
28
])
23
29
+1
-1
src/common/socket.js
+1
-1
src/common/socket.js
···
155
155
signal?.throwIfAborted()
156
156
157
157
const [event, value] = await queue.dequeue(signal)
158
-
if (queue.depth < backoffThresh) {
158
+
if (inBackoffMode && queue.depth < backoffThresh) {
159
159
console.log('message stream will stop dropping messages due to eased backpressure')
160
160
inBackoffMode = false
161
161
}
+35
-21
src/server/routes-socket/handler-preauth.js
+35
-21
src/server/routes-socket/handler-preauth.js
···
2
2
import { jwkImport } from '#common/crypto/jwks.js'
3
3
import { jwtSchema, verifyJwtToken } from '#common/crypto/jwts.js'
4
4
import { normalizeError, ProtocolError } from '#common/errors.js'
5
-
import { IdentBrand, preauthAuthnMessageSchema, RealmBrand } from '#common/protocol.js'
5
+
import { IdentBrand, preauthMessageSchema, RealmBrand } from '#common/protocol.js'
6
6
import { takeSocket } from '#common/socket.js'
7
7
8
+
import * as protocol_types from '#common/protocol.js'
8
9
import * as realms from './state.js'
9
10
10
11
/**
···
21
22
const combinedSignal = combineSignals(signal, timeout.signal)
22
23
23
24
try {
25
+
const data = await takeSocket(ws, combinedSignal)
26
+
24
27
// if any of the parsing fails, it'll throw a zod error
25
-
const data = await takeSocket(ws, combinedSignal)
26
28
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
-
29
+
const msg = await preauthMessageSchema.parseAsync(jwt.payload)
30
+
const identid = IdentBrand.parse(jwt.payload.iss)
32
31
const realmid = RealmBrand.parse(jwt.payload.aud)
33
-
const realm = realms.ensureRegisteredRealm(realmid, registrantid, registrantkey)
34
32
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 })
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)
48
37
}
38
+
39
+
return authenticatePreauth(realmid, identid, jwt.token)
49
40
}
50
41
finally {
51
42
timeout.cancel()
52
43
}
53
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
+
}