podcast manager
1import * as jose from 'jose'
2import {z} from 'zod/v4'
3import {CryptoError} from './errors'
4
5export type JWK = jose.JWK
6
7const subtleSignAlgo = {name: 'ECDSA', namedCurve: 'P-256'}
8const joseSignAlgo = {name: 'ES256'}
9
10const jwkEcPublicSchema = z.object({
11 kty: z.literal('EC'),
12 crv: z.string(),
13 x: z.string(),
14 y: z.string(),
15})
16
17const jwkEcPrivateSchema = z.object({
18 ...jwkEcPublicSchema.shape,
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 */
29export const jwkSchema: z.ZodType<jose.JWK> = z.union([jwkEcPublicSchema, jwkEcPrivateSchema])
30
31/**
32 * zod transform from JWK to CryptoKey
33 */
34export const jwkImport: z.ZodTransform<CryptoKey, jose.JWK> = z.transform(async (val, ctx) => {
35 try {
36 if (typeof val === 'object') {
37 const key = await jose.importJWK(val, joseSignAlgo.name)
38 if (key instanceof CryptoKey) {
39 return key
40 }
41
42 ctx.issues.push({
43 code: 'custom',
44 message: 'symmetric keys unsupported',
45 input: val,
46 })
47 } else {
48 ctx.issues.push({
49 code: 'custom',
50 message: 'not a valid JWK object',
51 input: val,
52 })
53 }
54 } catch (e) {
55 ctx.issues.push({
56 code: 'custom',
57 message: `could not import JWK object: ${e}`,
58 input: val,
59 })
60 }
61
62 return z.NEVER
63})
64
65/** zod transform from exportable CryptoKey to JWK */
66export const jwkExport: z.ZodTransform<jose.JWK, CryptoKey> = z.transform(async (val, ctx) => {
67 try {
68 if (val.extractable) {
69 return await jose.exportJWK(val)
70 }
71
72 ctx.issues.push({
73 code: 'custom',
74 message: 'non-extractable key!',
75 input: val,
76 })
77 } catch (e) {
78 ctx.issues.push({
79 code: 'custom',
80 message: `could not export JWK object: ${e}`,
81 input: val,
82 })
83 }
84
85 return z.NEVER
86})
87
88/**
89 * @returns a newly generated, signing compatible keypair
90 */
91export async function generateSigningJwkPair(): Promise<CryptoKeyPair> {
92 const pair = await crypto.subtle.generateKey(subtleSignAlgo, false, ['sign', 'verify'])
93 if (!('publicKey' in pair)) throw new CryptoError('keypair returned a single key!?')
94
95 return pair
96}
97
98/**
99 * @param payload - the payload to sign
100 * @returns a properly configured jwt signer, with the payload provided
101 */
102declare const _INFERRED: unique symbol
103type Inferred = typeof _INFERRED
104
105export function generateSignableJwt(payload: jose.JWTPayload): jose.SignJWT
106export function generateSignableJwt<T>(payload: jose.JWTPayload & {payload: T}): jose.SignJWT
107export function generateSignableJwt<T = Inferred>(
108 payload: T extends Inferred ? jose.JWTPayload : jose.JWTPayload & {payload: T},
109): jose.SignJWT {
110 return new jose.SignJWT(payload).setProtectedHeader({alg: joseSignAlgo.name})
111}