@atcute/oauth-crypto 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +7 -0
- package/dist/client-assertion/create-client-assertion.d.ts +19 -0
- package/dist/client-assertion/create-client-assertion.d.ts.map +1 -0
- package/dist/client-assertion/create-client-assertion.js +34 -0
- package/dist/client-assertion/create-client-assertion.js.map +1 -0
- package/dist/client-assertion/generate-key.d.ts +11 -0
- package/dist/client-assertion/generate-key.d.ts.map +1 -0
- package/dist/client-assertion/generate-key.js +18 -0
- package/dist/client-assertion/generate-key.js.map +1 -0
- package/dist/client-assertion/index.d.ts +5 -0
- package/dist/client-assertion/index.d.ts.map +1 -0
- package/dist/client-assertion/index.js +4 -0
- package/dist/client-assertion/index.js.map +1 -0
- package/dist/client-assertion/keys.d.ts +14 -0
- package/dist/client-assertion/keys.d.ts.map +1 -0
- package/dist/client-assertion/keys.js +18 -0
- package/dist/client-assertion/keys.js.map +1 -0
- package/dist/client-assertion/types.d.ts +9 -0
- package/dist/client-assertion/types.d.ts.map +1 -0
- package/dist/client-assertion/types.js +2 -0
- package/dist/client-assertion/types.js.map +1 -0
- package/dist/dpop/fetch.d.ts +24 -0
- package/dist/dpop/fetch.d.ts.map +1 -0
- package/dist/dpop/fetch.js +102 -0
- package/dist/dpop/fetch.js.map +1 -0
- package/dist/dpop/generate-key.d.ts +9 -0
- package/dist/dpop/generate-key.d.ts.map +1 -0
- package/dist/dpop/generate-key.js +61 -0
- package/dist/dpop/generate-key.js.map +1 -0
- package/dist/dpop/index.d.ts +6 -0
- package/dist/dpop/index.d.ts.map +1 -0
- package/dist/dpop/index.js +5 -0
- package/dist/dpop/index.js.map +1 -0
- package/dist/dpop/proof.d.ts +9 -0
- package/dist/dpop/proof.d.ts.map +1 -0
- package/dist/dpop/proof.js +36 -0
- package/dist/dpop/proof.js.map +1 -0
- package/dist/dpop/types.d.ts +17 -0
- package/dist/dpop/types.d.ts.map +1 -0
- package/dist/dpop/types.js +2 -0
- package/dist/dpop/types.js.map +1 -0
- package/dist/dpop/verify.d.ts +42 -0
- package/dist/dpop/verify.d.ts.map +1 -0
- package/dist/dpop/verify.js +124 -0
- package/dist/dpop/verify.js.map +1 -0
- package/dist/hash/index.d.ts +3 -0
- package/dist/hash/index.d.ts.map +1 -0
- package/dist/hash/index.js +3 -0
- package/dist/hash/index.js.map +1 -0
- package/dist/hash/pkce.d.ts +12 -0
- package/dist/hash/pkce.d.ts.map +1 -0
- package/dist/hash/pkce.js +14 -0
- package/dist/hash/pkce.js.map +1 -0
- package/dist/hash/sha256.d.ts +8 -0
- package/dist/hash/sha256.d.ts.map +1 -0
- package/dist/hash/sha256.js +14 -0
- package/dist/hash/sha256.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/crypto.d.ts +7 -0
- package/dist/internal/crypto.d.ts.map +1 -0
- package/dist/internal/crypto.js +78 -0
- package/dist/internal/crypto.js.map +1 -0
- package/dist/internal/jwk.d.ts +10 -0
- package/dist/internal/jwk.d.ts.map +1 -0
- package/dist/internal/jwk.js +121 -0
- package/dist/internal/jwk.js.map +1 -0
- package/dist/internal/key-cache.d.ts +24 -0
- package/dist/internal/key-cache.d.ts.map +1 -0
- package/dist/internal/key-cache.js +36 -0
- package/dist/internal/key-cache.js.map +1 -0
- package/dist/jwk/compute-jkt.d.ts +9 -0
- package/dist/jwk/compute-jkt.d.ts.map +1 -0
- package/dist/jwk/compute-jkt.js +23 -0
- package/dist/jwk/compute-jkt.js.map +1 -0
- package/dist/jwk/index.d.ts +5 -0
- package/dist/jwk/index.d.ts.map +1 -0
- package/dist/jwk/index.js +4 -0
- package/dist/jwk/index.js.map +1 -0
- package/dist/jwk/keys.d.ts +9 -0
- package/dist/jwk/keys.d.ts.map +1 -0
- package/dist/jwk/keys.js +13 -0
- package/dist/jwk/keys.js.map +1 -0
- package/dist/jwk/types.d.ts +37 -0
- package/dist/jwk/types.d.ts.map +1 -0
- package/dist/jwk/types.js +2 -0
- package/dist/jwk/types.js.map +1 -0
- package/dist/jwt/index.d.ts +26 -0
- package/dist/jwt/index.d.ts.map +1 -0
- package/dist/jwt/index.js +56 -0
- package/dist/jwt/index.js.map +1 -0
- package/lib/client-assertion/create-client-assertion.ts +50 -0
- package/lib/client-assertion/generate-key.ts +26 -0
- package/lib/client-assertion/index.ts +4 -0
- package/lib/client-assertion/keys.ts +26 -0
- package/lib/client-assertion/types.ts +9 -0
- package/lib/dpop/fetch.ts +140 -0
- package/lib/dpop/generate-key.ts +72 -0
- package/lib/dpop/index.ts +11 -0
- package/lib/dpop/proof.ts +46 -0
- package/lib/dpop/types.ts +19 -0
- package/lib/dpop/verify.ts +169 -0
- package/lib/hash/index.ts +2 -0
- package/lib/hash/pkce.ts +18 -0
- package/lib/hash/sha256.ts +14 -0
- package/lib/index.ts +5 -0
- package/lib/internal/crypto.ts +92 -0
- package/lib/internal/jwk.ts +157 -0
- package/lib/internal/key-cache.ts +51 -0
- package/lib/jwk/compute-jkt.ts +27 -0
- package/lib/jwk/index.ts +12 -0
- package/lib/jwk/keys.ts +15 -0
- package/lib/jwk/types.ts +51 -0
- package/lib/jwt/index.ts +86 -0
- package/package.json +38 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { fromBase64Url } from '@atcute/multibase';
|
|
2
|
+
import { decodeUtf8From } from '@atcute/uint8array';
|
|
3
|
+
|
|
4
|
+
import * as v from '@badrap/valita';
|
|
5
|
+
|
|
6
|
+
import { getImportAlgorithm } from '../internal/crypto.js';
|
|
7
|
+
import { computeJktFromJwk } from '../jwk/compute-jkt.js';
|
|
8
|
+
import type { PublicJwk, SigningAlgorithm } from '../jwk/types.js';
|
|
9
|
+
import { verifyJwt } from '../jwt/index.js';
|
|
10
|
+
|
|
11
|
+
import type { Awaitable } from './types.js';
|
|
12
|
+
|
|
13
|
+
const dpopJwkSchema = v.union(
|
|
14
|
+
v.object({
|
|
15
|
+
kty: v.literal('EC'),
|
|
16
|
+
crv: v.union(v.literal('P-256'), v.literal('P-384'), v.literal('P-521')),
|
|
17
|
+
x: v.string(),
|
|
18
|
+
y: v.string(),
|
|
19
|
+
}),
|
|
20
|
+
v.object({
|
|
21
|
+
kty: v.literal('RSA'),
|
|
22
|
+
e: v.string(),
|
|
23
|
+
n: v.string(),
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const dpopHeaderSchema = v.object({
|
|
28
|
+
typ: v.literal('dpop+jwt'),
|
|
29
|
+
alg: v.string().assert((alg) => alg !== 'none', 'alg must not be "none"'),
|
|
30
|
+
jwk: dpopJwkSchema,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const dpopPayloadSchema = v.object({
|
|
34
|
+
htm: v.string(),
|
|
35
|
+
htu: v.string(),
|
|
36
|
+
iat: v.number(),
|
|
37
|
+
jti: v.string(),
|
|
38
|
+
nonce: v.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export type DpopClaims = v.Infer<typeof dpopPayloadSchema>;
|
|
42
|
+
type DpopJwk = v.Infer<typeof dpopJwkSchema>;
|
|
43
|
+
|
|
44
|
+
export interface DpopVerifyResult {
|
|
45
|
+
claims: DpopClaims;
|
|
46
|
+
jkt: string;
|
|
47
|
+
jwk: PublicJwk;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DpopVerifyOptions {
|
|
51
|
+
method: string;
|
|
52
|
+
url: string;
|
|
53
|
+
nonce?: { check(nonce: string): Awaitable<boolean> };
|
|
54
|
+
maxClockSkew?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* error thrown when dpop verification fails.
|
|
59
|
+
*/
|
|
60
|
+
export class DpopVerifyError extends Error {
|
|
61
|
+
constructor(
|
|
62
|
+
message: string,
|
|
63
|
+
public code: 'missing' | 'invalid' | 'expired' | 'nonce_required',
|
|
64
|
+
) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'DpopVerifyError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* verifies a dpop proof from a request header.
|
|
72
|
+
*
|
|
73
|
+
* @param dpopHeader dpop header value
|
|
74
|
+
* @param options verification options
|
|
75
|
+
* @returns verification result with claims and jwk thumbprint
|
|
76
|
+
* @throws {DpopVerifyError} if verification fails
|
|
77
|
+
*/
|
|
78
|
+
export const verifyDpopProof = async (
|
|
79
|
+
dpopHeader: string | null | undefined,
|
|
80
|
+
options: DpopVerifyOptions,
|
|
81
|
+
): Promise<DpopVerifyResult> => {
|
|
82
|
+
if (!dpopHeader) {
|
|
83
|
+
throw new DpopVerifyError(`missing dpop header`, 'missing');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { method, url, nonce: dpopNonce, maxClockSkew = 60 } = options;
|
|
87
|
+
const parts = dpopHeader.split('.');
|
|
88
|
+
if (parts.length !== 3) {
|
|
89
|
+
throw new DpopVerifyError(`invalid dpop proof format`, 'invalid');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let header: v.Infer<typeof dpopHeaderSchema>;
|
|
93
|
+
try {
|
|
94
|
+
header = dpopHeaderSchema.parse(decodeSegment(parts[0]), { mode: 'passthrough' });
|
|
95
|
+
} catch {
|
|
96
|
+
throw new DpopVerifyError(`invalid dpop header`, 'invalid');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { jwk, alg } = header;
|
|
100
|
+
if (!isSigningAlgorithm(alg)) {
|
|
101
|
+
throw new DpopVerifyError(`unsupported dpop alg`, 'invalid');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let payload: DpopClaims;
|
|
105
|
+
try {
|
|
106
|
+
const key = await importPublicKey(jwk, alg);
|
|
107
|
+
const raw = await verifyJwt(dpopHeader, { key, alg, typ: 'dpop+jwt' });
|
|
108
|
+
payload = dpopPayloadSchema.parse(raw, { mode: 'passthrough' });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err instanceof v.ValitaError) {
|
|
111
|
+
throw new DpopVerifyError(`invalid dpop payload`, 'invalid');
|
|
112
|
+
}
|
|
113
|
+
throw new DpopVerifyError(`dpop signature verification failed`, 'invalid');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (payload.htm !== method) {
|
|
117
|
+
throw new DpopVerifyError(`dpop htm mismatch: expected ${method}, got ${payload.htm}`, 'invalid');
|
|
118
|
+
}
|
|
119
|
+
if (payload.htu !== url) {
|
|
120
|
+
throw new DpopVerifyError(`dpop htu mismatch: expected ${url}, got ${payload.htu}`, 'invalid');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const now = Math.floor(Date.now() / 1000);
|
|
124
|
+
if (payload.iat > now + maxClockSkew) {
|
|
125
|
+
throw new DpopVerifyError(`dpop proof issued in the future`, 'invalid');
|
|
126
|
+
}
|
|
127
|
+
if (payload.iat < now - maxClockSkew) {
|
|
128
|
+
throw new DpopVerifyError(`dpop proof expired`, 'expired');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (dpopNonce) {
|
|
132
|
+
if (!payload.nonce || !(await dpopNonce.check(payload.nonce))) {
|
|
133
|
+
throw new DpopVerifyError(`invalid or missing dpop nonce`, 'nonce_required');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const jkt = await computeJktFromJwk(jwk as PublicJwk);
|
|
138
|
+
|
|
139
|
+
return { claims: payload, jwk: jwk as DpopJwk, jkt };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const importPublicKey = async (jwk: PublicJwk, alg: SigningAlgorithm): Promise<CryptoKey> => {
|
|
143
|
+
const algorithm = getImportAlgorithm(alg, jwk.kty === 'EC' ? jwk.crv : undefined);
|
|
144
|
+
const key = await crypto.subtle.importKey('jwk', jwk, algorithm, true, ['verify']);
|
|
145
|
+
if (!(key instanceof CryptoKey)) {
|
|
146
|
+
throw new Error(`expected asymmetric key, got symmetric`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return key;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const isSigningAlgorithm = (alg: string): alg is SigningAlgorithm => {
|
|
153
|
+
return (
|
|
154
|
+
alg === 'ES256' ||
|
|
155
|
+
alg === 'ES384' ||
|
|
156
|
+
alg === 'ES512' ||
|
|
157
|
+
alg === 'PS256' ||
|
|
158
|
+
alg === 'PS384' ||
|
|
159
|
+
alg === 'PS512' ||
|
|
160
|
+
alg === 'RS256' ||
|
|
161
|
+
alg === 'RS384' ||
|
|
162
|
+
alg === 'RS512'
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const decodeSegment = (segment: string): unknown => {
|
|
167
|
+
const bytes = fromBase64Url(segment);
|
|
168
|
+
return JSON.parse(decodeUtf8From(bytes));
|
|
169
|
+
};
|
package/lib/hash/pkce.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
|
|
3
|
+
import { sha256Base64Url } from './sha256.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* generates pkce verifier and challenge (s256).
|
|
7
|
+
*
|
|
8
|
+
* @param length verifier length (43-128 per rfc 7636)
|
|
9
|
+
* @returns pkce values
|
|
10
|
+
*/
|
|
11
|
+
export const generatePkce = async (
|
|
12
|
+
length = 64,
|
|
13
|
+
): Promise<{ verifier: string; challenge: string; method: 'S256' }> => {
|
|
14
|
+
const verifier = nanoid(length);
|
|
15
|
+
const challenge = await sha256Base64Url(verifier);
|
|
16
|
+
|
|
17
|
+
return { verifier, challenge, method: 'S256' };
|
|
18
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { toBase64Url } from '@atcute/multibase';
|
|
2
|
+
import { encodeUtf8, toSha256 } from '@atcute/uint8array';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* computes sha-256 hash and returns base64url-encoded result.
|
|
6
|
+
*
|
|
7
|
+
* @param input string to hash
|
|
8
|
+
* @returns base64url-encoded sha-256 hash
|
|
9
|
+
*/
|
|
10
|
+
export const sha256Base64Url = async (input: string): Promise<string> => {
|
|
11
|
+
const bytes = encodeUtf8(input);
|
|
12
|
+
const digest = await toSha256(bytes);
|
|
13
|
+
return toBase64Url(digest);
|
|
14
|
+
};
|
package/lib/index.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { SigningAlgorithm } from '../jwk/types.js';
|
|
2
|
+
|
|
3
|
+
const HASH_BY_ALG: Record<SigningAlgorithm, 'SHA-256' | 'SHA-384' | 'SHA-512'> = {
|
|
4
|
+
ES256: 'SHA-256',
|
|
5
|
+
ES384: 'SHA-384',
|
|
6
|
+
ES512: 'SHA-512',
|
|
7
|
+
PS256: 'SHA-256',
|
|
8
|
+
PS384: 'SHA-384',
|
|
9
|
+
PS512: 'SHA-512',
|
|
10
|
+
RS256: 'SHA-256',
|
|
11
|
+
RS384: 'SHA-384',
|
|
12
|
+
RS512: 'SHA-512',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const CURVE_BY_ALG: Record<SigningAlgorithm, 'P-256' | 'P-384' | 'P-521' | null> = {
|
|
16
|
+
ES256: 'P-256',
|
|
17
|
+
ES384: 'P-384',
|
|
18
|
+
ES512: 'P-521',
|
|
19
|
+
PS256: null,
|
|
20
|
+
PS384: null,
|
|
21
|
+
PS512: null,
|
|
22
|
+
RS256: null,
|
|
23
|
+
RS384: null,
|
|
24
|
+
RS512: null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const getHashName = (alg: SigningAlgorithm): 'SHA-256' | 'SHA-384' | 'SHA-512' => {
|
|
28
|
+
return HASH_BY_ALG[alg];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const getNamedCurve = (alg: SigningAlgorithm): 'P-256' | 'P-384' | 'P-521' | null => {
|
|
32
|
+
return CURVE_BY_ALG[alg];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const getSignAlgorithm = (alg: SigningAlgorithm): AlgorithmIdentifier | EcdsaParams | RsaPssParams => {
|
|
36
|
+
if (alg.startsWith('ES')) {
|
|
37
|
+
return { name: 'ECDSA', hash: { name: getHashName(alg) } };
|
|
38
|
+
}
|
|
39
|
+
if (alg.startsWith('PS')) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'RSA-PSS',
|
|
42
|
+
hash: { name: getHashName(alg) },
|
|
43
|
+
saltLength: getHashLength(getHashName(alg)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { name: 'RSASSA-PKCS1-v1_5' };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const getImportAlgorithm = (
|
|
50
|
+
alg: SigningAlgorithm,
|
|
51
|
+
curve?: 'P-256' | 'P-384' | 'P-521',
|
|
52
|
+
): EcKeyImportParams | RsaHashedImportParams => {
|
|
53
|
+
if (alg.startsWith('ES')) {
|
|
54
|
+
const namedCurve = curve ?? getNamedCurve(alg);
|
|
55
|
+
if (!namedCurve) {
|
|
56
|
+
throw new Error(`unable to determine curve for ${alg}`);
|
|
57
|
+
}
|
|
58
|
+
return { name: 'ECDSA', namedCurve };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (alg.startsWith('PS')) {
|
|
62
|
+
return { name: 'RSA-PSS', hash: { name: getHashName(alg) } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { name: 'RSASSA-PKCS1-v1_5', hash: { name: getHashName(alg) } };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const getGenerateAlgorithm = (alg: SigningAlgorithm): EcKeyGenParams | RsaHashedKeyGenParams => {
|
|
69
|
+
const curve = getNamedCurve(alg);
|
|
70
|
+
if (curve) {
|
|
71
|
+
return { name: 'ECDSA', namedCurve: curve };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hash = { name: getHashName(alg) };
|
|
75
|
+
return {
|
|
76
|
+
name: alg.startsWith('PS') ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5',
|
|
77
|
+
hash,
|
|
78
|
+
modulusLength: 2048,
|
|
79
|
+
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const getHashLength = (hash: 'SHA-256' | 'SHA-384' | 'SHA-512'): number => {
|
|
84
|
+
switch (hash) {
|
|
85
|
+
case 'SHA-256':
|
|
86
|
+
return 32;
|
|
87
|
+
case 'SHA-384':
|
|
88
|
+
return 48;
|
|
89
|
+
case 'SHA-512':
|
|
90
|
+
return 64;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { fromBase64Pad, toBase64Pad } from '@atcute/multibase';
|
|
2
|
+
|
|
3
|
+
import type { PrivateJwk, PublicJwk, SigningAlgorithm } from '../jwk/types.js';
|
|
4
|
+
|
|
5
|
+
import { getImportAlgorithm } from './crypto.js';
|
|
6
|
+
|
|
7
|
+
const SIGNING_ALGORITHMS: readonly SigningAlgorithm[] = [
|
|
8
|
+
'ES256',
|
|
9
|
+
'ES384',
|
|
10
|
+
'ES512',
|
|
11
|
+
'PS256',
|
|
12
|
+
'PS384',
|
|
13
|
+
'PS512',
|
|
14
|
+
'RS256',
|
|
15
|
+
'RS384',
|
|
16
|
+
'RS512',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const CURVE_TO_ALG: Record<string, SigningAlgorithm> = {
|
|
20
|
+
'P-256': 'ES256',
|
|
21
|
+
'P-384': 'ES384',
|
|
22
|
+
'P-521': 'ES512',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const isSigningAlgorithm = (alg: string): alg is SigningAlgorithm => {
|
|
26
|
+
return (SIGNING_ALGORITHMS as readonly string[]).includes(alg);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const parsePrivateJwkInput = (input: PrivateJwk | string): PrivateJwk => {
|
|
30
|
+
if (typeof input === 'string') {
|
|
31
|
+
try {
|
|
32
|
+
const jwk = JSON.parse(input) as PrivateJwk;
|
|
33
|
+
return jwk;
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`invalid JSON string`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof input === 'object' && input !== null && 'kty' in input) {
|
|
40
|
+
return input;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error(`invalid input: expected JWK object or JSON string`);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const resolveSigningAlgorithm = (
|
|
47
|
+
jwk: PrivateJwk,
|
|
48
|
+
override?: SigningAlgorithm,
|
|
49
|
+
): SigningAlgorithm | undefined => {
|
|
50
|
+
if (override) {
|
|
51
|
+
return override;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const alg = jwk.alg;
|
|
55
|
+
if (alg && isSigningAlgorithm(alg)) {
|
|
56
|
+
return alg;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (jwk.kty === 'EC') {
|
|
60
|
+
const inferred = CURVE_TO_ALG[jwk.crv];
|
|
61
|
+
if (inferred) {
|
|
62
|
+
return inferred;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return undefined;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const derivePublicJwk = (privateJwk: PrivateJwk, kid?: string, alg?: SigningAlgorithm): PublicJwk => {
|
|
70
|
+
if (privateJwk.kty === 'EC') {
|
|
71
|
+
const { crv, x, y } = privateJwk;
|
|
72
|
+
return { kty: 'EC', crv, x, y, kid, alg, use: 'sig' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (privateJwk.kty === 'RSA') {
|
|
76
|
+
const { n, e } = privateJwk;
|
|
77
|
+
return { kty: 'RSA', n, e, kid, alg, use: 'sig' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error(`unsupported key type`);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const importPrivateKeyFromJwk = async (jwk: PrivateJwk, alg: SigningAlgorithm): Promise<CryptoKey> => {
|
|
84
|
+
if (!('d' in jwk) || !jwk.d) {
|
|
85
|
+
throw new Error(`expected a private key (missing 'd' parameter)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (jwk.kty === 'EC' && !alg.startsWith('ES')) {
|
|
89
|
+
throw new Error(`algorithm ${alg} does not match ec key`);
|
|
90
|
+
}
|
|
91
|
+
if (jwk.kty === 'RSA' && alg.startsWith('ES')) {
|
|
92
|
+
throw new Error(`algorithm ${alg} does not match rsa key`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const algorithm = getImportAlgorithm(alg, jwk.kty === 'EC' ? jwk.crv : undefined);
|
|
96
|
+
const key = await crypto.subtle.importKey('jwk', jwk, algorithm, true, ['sign']);
|
|
97
|
+
|
|
98
|
+
if (!(key instanceof CryptoKey)) {
|
|
99
|
+
throw new Error(`expected asymmetric key, got symmetric`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return key;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const exportPrivateJwkFromKey = async (
|
|
106
|
+
key: CryptoKey,
|
|
107
|
+
alg: SigningAlgorithm,
|
|
108
|
+
kid?: string,
|
|
109
|
+
): Promise<PrivateJwk> => {
|
|
110
|
+
const jwk = (await crypto.subtle.exportKey('jwk', key)) as PrivateJwk;
|
|
111
|
+
jwk.alg = alg;
|
|
112
|
+
if (kid) {
|
|
113
|
+
jwk.kid = kid;
|
|
114
|
+
}
|
|
115
|
+
return jwk;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const importPkcs8PrivateKey = async (pem: string, alg: SigningAlgorithm): Promise<CryptoKey> => {
|
|
119
|
+
const bytes = parsePkcs8Pem(pem);
|
|
120
|
+
const algorithm = getImportAlgorithm(alg);
|
|
121
|
+
|
|
122
|
+
const key = await crypto.subtle.importKey('pkcs8', bytes, algorithm, true, ['sign']);
|
|
123
|
+
|
|
124
|
+
if (!(key instanceof CryptoKey)) {
|
|
125
|
+
throw new Error(`expected asymmetric key, got symmetric`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return key;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const exportPkcs8PrivateKey = async (key: CryptoKey): Promise<string> => {
|
|
132
|
+
const pkcs8 = await crypto.subtle.exportKey('pkcs8', key);
|
|
133
|
+
const bytes = new Uint8Array(pkcs8);
|
|
134
|
+
const base64 = toBase64Pad(bytes);
|
|
135
|
+
|
|
136
|
+
return ['-----BEGIN PRIVATE KEY-----', ...chunk64(base64), '-----END PRIVATE KEY-----', ''].join('\n');
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const parsePkcs8Pem = (pem: string): ArrayBuffer => {
|
|
140
|
+
const match = pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/);
|
|
141
|
+
if (!match) {
|
|
142
|
+
throw new Error(`invalid pkcs8 pem`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const base64 = match[1].replace(/\s+/g, '');
|
|
146
|
+
const bytes = fromBase64Pad(base64);
|
|
147
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
148
|
+
return buffer;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const chunk64 = (input: string): string[] => {
|
|
152
|
+
const chunks: string[] = [];
|
|
153
|
+
for (let i = 0; i < input.length; i += 64) {
|
|
154
|
+
chunks.push(input.slice(i, i + 64));
|
|
155
|
+
}
|
|
156
|
+
return chunks;
|
|
157
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PrivateJwk, PublicJwk } from '../jwk/types.js';
|
|
2
|
+
|
|
3
|
+
import { derivePublicJwk, importPrivateKeyFromJwk } from './jwk.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* cached key material for a JWK.
|
|
7
|
+
*/
|
|
8
|
+
export interface CachedKeyMaterial {
|
|
9
|
+
cryptoKey: CryptoKey;
|
|
10
|
+
publicJwk: PublicJwk;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* cache for imported keys.
|
|
15
|
+
* uses WeakMap so entries are garbage collected when JWK objects are no longer referenced.
|
|
16
|
+
*/
|
|
17
|
+
const keyCache = new WeakMap<PrivateJwk, CachedKeyMaterial>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* retrieves or creates cached key material for a JWK.
|
|
21
|
+
*
|
|
22
|
+
* @param jwk private JWK to get material for
|
|
23
|
+
* @returns cached key material (CryptoKey and derived public JWK)
|
|
24
|
+
*/
|
|
25
|
+
export const getCachedKeyMaterial = async (jwk: PrivateJwk): Promise<CachedKeyMaterial> => {
|
|
26
|
+
const cached = keyCache.get(jwk);
|
|
27
|
+
if (cached) {
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { alg } = jwk;
|
|
32
|
+
const cryptoKey = await importPrivateKeyFromJwk(jwk, alg);
|
|
33
|
+
const publicJwk = derivePublicJwk(jwk, jwk.kid, alg);
|
|
34
|
+
const material: CachedKeyMaterial = { cryptoKey, publicJwk };
|
|
35
|
+
|
|
36
|
+
keyCache.set(jwk, material);
|
|
37
|
+
|
|
38
|
+
return material;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* pre-populates the cache with already-imported key material.
|
|
43
|
+
* useful for PKCS8 imports where we already have the CryptoKey.
|
|
44
|
+
*
|
|
45
|
+
* @param jwk private JWK to cache for
|
|
46
|
+
* @param cryptoKey already-imported CryptoKey
|
|
47
|
+
*/
|
|
48
|
+
export const setCachedKeyMaterial = (jwk: PrivateJwk, cryptoKey: CryptoKey): void => {
|
|
49
|
+
const publicJwk = derivePublicJwk(jwk, jwk.kid, jwk.alg);
|
|
50
|
+
keyCache.set(jwk, { cryptoKey, publicJwk });
|
|
51
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { toBase64Url } from '@atcute/multibase';
|
|
2
|
+
import { encodeUtf8, toSha256 } from '@atcute/uint8array';
|
|
3
|
+
|
|
4
|
+
import type { PublicJwk } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* computes the jwk thumbprint (rfc 7638) for a public key.
|
|
8
|
+
*
|
|
9
|
+
* @param jwk public jwk
|
|
10
|
+
* @returns base64url-encoded sha-256 thumbprint
|
|
11
|
+
*/
|
|
12
|
+
export const computeJktFromJwk = async (jwk: PublicJwk): Promise<string> => {
|
|
13
|
+
let canonical: Record<string, string>;
|
|
14
|
+
|
|
15
|
+
if (jwk.kty === 'EC') {
|
|
16
|
+
const { crv, x, y } = jwk;
|
|
17
|
+
canonical = { crv, kty: jwk.kty, x, y };
|
|
18
|
+
} else {
|
|
19
|
+
const { e, n } = jwk;
|
|
20
|
+
canonical = { e, kty: jwk.kty, n };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const serialized = JSON.stringify(canonical);
|
|
24
|
+
const hash = await toSha256(encodeUtf8(serialized));
|
|
25
|
+
|
|
26
|
+
return toBase64Url(hash);
|
|
27
|
+
};
|
package/lib/jwk/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { computeJktFromJwk } from './compute-jkt.js';
|
|
2
|
+
export { derivePublicJwk } from '../internal/jwk.js';
|
|
3
|
+
export { exportPkcs8PrivateKey } from './keys.js';
|
|
4
|
+
export type {
|
|
5
|
+
EcPrivateJwk,
|
|
6
|
+
EcPublicJwk,
|
|
7
|
+
PrivateJwk,
|
|
8
|
+
PublicJwk,
|
|
9
|
+
RsaPrivateJwk,
|
|
10
|
+
RsaPublicJwk,
|
|
11
|
+
SigningAlgorithm,
|
|
12
|
+
} from './types.js';
|
package/lib/jwk/keys.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { exportPkcs8PrivateKey as exportPkcs8 } from '../internal/jwk.js';
|
|
2
|
+
import { getCachedKeyMaterial } from '../internal/key-cache.js';
|
|
3
|
+
|
|
4
|
+
import type { PrivateJwk } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* exports a private JWK to PKCS8 PEM format.
|
|
8
|
+
*
|
|
9
|
+
* @param jwk private JWK to export
|
|
10
|
+
* @returns PKCS8 PEM string
|
|
11
|
+
*/
|
|
12
|
+
export const exportPkcs8PrivateKey = async (jwk: PrivateJwk): Promise<string> => {
|
|
13
|
+
const { cryptoKey } = await getCachedKeyMaterial(jwk);
|
|
14
|
+
return exportPkcs8(cryptoKey);
|
|
15
|
+
};
|
package/lib/jwk/types.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signing algorithms supported by atproto oauth.
|
|
3
|
+
*/
|
|
4
|
+
export type SigningAlgorithm =
|
|
5
|
+
| 'ES256'
|
|
6
|
+
| 'ES384'
|
|
7
|
+
| 'ES512'
|
|
8
|
+
| 'PS256'
|
|
9
|
+
| 'PS384'
|
|
10
|
+
| 'PS512'
|
|
11
|
+
| 'RS256'
|
|
12
|
+
| 'RS384'
|
|
13
|
+
| 'RS512';
|
|
14
|
+
|
|
15
|
+
export interface EcPublicJwk {
|
|
16
|
+
kty: 'EC';
|
|
17
|
+
crv: 'P-256' | 'P-384' | 'P-521';
|
|
18
|
+
x: string;
|
|
19
|
+
y: string;
|
|
20
|
+
alg?: SigningAlgorithm;
|
|
21
|
+
use?: 'sig';
|
|
22
|
+
kid?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RsaPublicJwk {
|
|
26
|
+
kty: 'RSA';
|
|
27
|
+
n: string;
|
|
28
|
+
e: string;
|
|
29
|
+
alg?: SigningAlgorithm;
|
|
30
|
+
use?: 'sig';
|
|
31
|
+
kid?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PublicJwk = EcPublicJwk | RsaPublicJwk;
|
|
35
|
+
|
|
36
|
+
export interface EcPrivateJwk extends EcPublicJwk {
|
|
37
|
+
alg: SigningAlgorithm;
|
|
38
|
+
d: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RsaPrivateJwk extends RsaPublicJwk {
|
|
42
|
+
alg: SigningAlgorithm;
|
|
43
|
+
d: string;
|
|
44
|
+
p?: string;
|
|
45
|
+
q?: string;
|
|
46
|
+
dp?: string;
|
|
47
|
+
dq?: string;
|
|
48
|
+
qi?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type PrivateJwk = EcPrivateJwk | RsaPrivateJwk;
|