personal web client for Bluesky
typescript solidjs bluesky atcute
at trunk 4.6 kB view raw
1import * as v from '@badrap/valita'; 2 3import { fromBase64Url, toBase64Url } from '@atcute/multibase'; 4import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array'; 5 6export class MalformedJwtError extends Error { 7 name = 'MalformedJwtError'; 8 9 constructor(options?: ErrorOptions) { 10 super(`malformed JWT`, options); 11 } 12} 13 14export interface DecodedJwt<THeader, TPayload> { 15 header: THeader; 16 payload: TPayload; 17 message: Uint8Array<ArrayBuffer>; 18 signature: Uint8Array<ArrayBuffer>; 19} 20 21const decodeJwt = <THeader, TPayload>( 22 input: string, 23 schemas: { header: v.Type<THeader>; payload: v.Type<TPayload> }, 24): DecodedJwt<THeader, TPayload> => { 25 const parts = input.split('.'); 26 if (parts.length !== 3) { 27 throw new MalformedJwtError(); 28 } 29 30 const [headerString, payloadString, signatureString] = parts; 31 32 const header = decodeJwtPortion(schemas.header, headerString); 33 const payload = decodeJwtPortion(schemas.payload, payloadString); 34 const signature = decodeJwtSignature(signatureString); 35 36 return { 37 header: header, 38 payload: payload, 39 message: encodeUtf8(`${headerString}.${payloadString}`), 40 signature: signature, 41 }; 42}; 43 44const decodeJwtPortion = <T>(schema: v.Type<T>, input: string): T => { 45 try { 46 const raw = decodeUtf8From(fromBase64Url(input)); 47 const json = JSON.parse(raw); 48 49 return schema.parse(json, { mode: 'passthrough' }); 50 } catch (err) { 51 throw new MalformedJwtError({ cause: err }); 52 } 53}; 54 55const decodeJwtSignature = (input: string): Uint8Array<ArrayBuffer> => { 56 try { 57 return fromBase64Url(input); 58 } catch (err) { 59 throw new MalformedJwtError({ cause: err }); 60 } 61}; 62 63const encodeJwtPortion = (data: unknown): string => { 64 return toBase64Url(encodeUtf8(JSON.stringify(data))); 65}; 66 67const encodeJwtSignature = (data: Uint8Array): string => { 68 return toBase64Url(data); 69}; 70 71// #region DPoP 72export class InvalidDPoPError extends Error { 73 name = 'InvalidDPoPError'; 74} 75 76const dpopHeaderSchema = v.object({ 77 typ: v.literal('dpop+jwt'), 78 alg: v.literal('ES256'), 79 jwk: v.object({ 80 kty: v.literal('EC'), 81 crv: v.literal('P-256'), 82 x: v.string(), 83 y: v.string(), 84 }), 85}); 86 87const dpopPayloadSchema = v.object({ 88 htm: v.string(), 89 htu: v.string(), 90 iat: v.number(), 91 jti: v.string(), 92}); 93 94const calculateJwkThumbprint = async (jwk: JsonWebKey): Promise<string> => { 95 // For EC keys, thumbprint is SHA-256 of canonical JSON 96 // Members must be in lexicographic order 97 const canonical = JSON.stringify({ 98 crv: jwk.crv, 99 kty: jwk.kty, 100 x: jwk.x, 101 y: jwk.y, 102 }); 103 104 const hash = await crypto.subtle.digest('SHA-256', encodeUtf8(canonical)); 105 return toBase64Url(new Uint8Array(hash)); 106}; 107 108export const verifyDPoP = async (dpop: string | null, jkt: string): Promise<void> => { 109 if (!dpop) { 110 throw new InvalidDPoPError(`missing DPoP header`); 111 } 112 113 // Decode the DPoP JWT 114 let decoded; 115 try { 116 decoded = decodeJwt(dpop, { 117 header: dpopHeaderSchema, 118 payload: dpopPayloadSchema, 119 }); 120 } catch (err) { 121 throw new InvalidDPoPError(`malformed JWT`, { cause: err }); 122 } 123 124 const { header, message, signature } = decoded; 125 126 // Verify JWK thumbprint matches jkt 127 const thumbprint = await calculateJwkThumbprint(header.jwk); 128 if (thumbprint !== jkt) { 129 throw new InvalidDPoPError(`JWK thumbprint mismatch`); 130 } 131 132 // Import the public key for signature verification 133 let publicKey: CryptoKey; 134 try { 135 publicKey = await crypto.subtle.importKey( 136 'jwk', 137 header.jwk, 138 { name: 'ECDSA', namedCurve: 'P-256' }, 139 false, 140 ['verify'], 141 ); 142 } catch (err) { 143 throw new InvalidDPoPError(`failed to import JWK`, { cause: err }); 144 } 145 146 // Verify the signature 147 const isValid = await crypto.subtle.verify( 148 { name: 'ECDSA', hash: 'SHA-256' }, 149 publicKey, 150 signature, 151 message, 152 ); 153 154 if (!isValid) { 155 throw new InvalidDPoPError(`invalid DPoP signature`); 156 } 157}; 158 159// #endregion 160 161// #region Client assertions 162 163export const createClientAssertion = async (options: { 164 kid: string; 165 client_id: string; 166 aud: string; 167 privateKey: CryptoKey; 168}): Promise<string> => { 169 const { kid, client_id, aud, privateKey } = options; 170 171 const now = Math.floor(Date.now() / 1000); 172 173 const header = { 174 alg: 'ES256', 175 typ: 'JWT', 176 kid: kid, 177 }; 178 179 const payload = { 180 iss: client_id, 181 sub: client_id, 182 aud: aud, 183 jti: crypto.randomUUID(), 184 iat: now, 185 exp: now + 60, 186 }; 187 188 const message = `${encodeJwtPortion(header)}.${encodeJwtPortion(payload)}`; 189 190 const signature = encodeJwtSignature( 191 new Uint8Array( 192 await crypto.subtle.sign( 193 { 194 name: 'ECDSA', 195 hash: 'SHA-256', 196 }, 197 privateKey, 198 encodeUtf8(message), 199 ), 200 ), 201 ); 202 203 return `${message}.${signature}`; 204}; 205 206// #endregion