personal web client for Bluesky
typescript
solidjs
bluesky
atcute
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