@meith/accounts 0.16.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.
@@ -0,0 +1,268 @@
1
+ import { decodeBase64Url, encodeBase64Url } from '../crypto/base64url'
2
+
3
+ export type FixtureAlgorithm = 'ES256' | 'RS256' | 'EdDSA'
4
+
5
+ export function encodeCbor(value: unknown): Uint8Array {
6
+ if (typeof value === 'number') {
7
+ return value < 0 ? head(1, -1 - value) : head(0, value)
8
+ }
9
+ if (value instanceof Uint8Array) {
10
+ return concat(head(2, value.length), value)
11
+ }
12
+ if (typeof value === 'string') {
13
+ const bytes = new TextEncoder().encode(value)
14
+ return concat(head(3, bytes.length), bytes)
15
+ }
16
+ if (Array.isArray(value)) {
17
+ return concat(head(4, value.length), ...value.map(encodeCbor))
18
+ }
19
+ if (value instanceof Map) {
20
+ const parts: Uint8Array[] = [head(5, value.size)]
21
+ for (const [key, entry] of value) {
22
+ parts.push(encodeCbor(key), encodeCbor(entry))
23
+ }
24
+ return concat(...parts)
25
+ }
26
+ throw new Error('the fixture encoder does not know that shape')
27
+ }
28
+
29
+ function head(major: number, argument: number): Uint8Array {
30
+ if (argument < 24) return Uint8Array.from([(major << 5) | argument])
31
+ if (argument < 0x100) return Uint8Array.from([(major << 5) | 24, argument])
32
+ if (argument < 0x10000) {
33
+ return Uint8Array.from([(major << 5) | 25, argument >> 8, argument & 0xff])
34
+ }
35
+ return Uint8Array.from([
36
+ (major << 5) | 26,
37
+ (argument >>> 24) & 0xff,
38
+ (argument >>> 16) & 0xff,
39
+ (argument >>> 8) & 0xff,
40
+ argument & 0xff,
41
+ ])
42
+ }
43
+
44
+ export function concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
45
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
46
+ let offset = 0
47
+ for (const part of parts) {
48
+ out.set(part, offset)
49
+ offset += part.length
50
+ }
51
+ return out
52
+ }
53
+
54
+ export interface FixtureResponse {
55
+ readonly clientDataJSON: string
56
+ readonly attestationObject: string
57
+ }
58
+
59
+ export interface FixtureAssertion {
60
+ readonly clientDataJSON: string
61
+ readonly authenticatorData: string
62
+ readonly signature: string
63
+ }
64
+
65
+ export interface FixtureAuthenticator {
66
+ readonly credentialId: string
67
+ readonly publicKey: string
68
+ register(input: {
69
+ readonly challenge: string
70
+ readonly origin: string
71
+ readonly type?: string
72
+ readonly rpId?: string
73
+ readonly flags?: number
74
+ }): Promise<FixtureResponse>
75
+ assert(input: {
76
+ readonly challenge: string
77
+ readonly origin: string
78
+ readonly type?: string
79
+ readonly rpId?: string
80
+ readonly signCount?: number
81
+ readonly flags?: number
82
+ readonly tamper?: boolean
83
+ }): Promise<FixtureAssertion>
84
+ }
85
+
86
+ export async function createAuthenticator(input: {
87
+ readonly rpId: string
88
+ readonly algorithm?: FixtureAlgorithm
89
+ readonly credentialId?: Uint8Array
90
+ }): Promise<FixtureAuthenticator> {
91
+ const algorithm = input.algorithm ?? 'ES256'
92
+ const credentialId = input.credentialId ?? Uint8Array.from({ length: 16 }, (_, i) => i + 1)
93
+
94
+ const pair = (await crypto.subtle.generateKey(generateParams(algorithm), true, [
95
+ 'sign',
96
+ 'verify',
97
+ ])) as CryptoKeyPair
98
+
99
+ const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
100
+
101
+ const coseKey = encodeCbor(coseEntries(algorithm, jwk))
102
+
103
+ const clientData = (type: string, challenge: string, origin: string): string =>
104
+ encodeBase64Url(
105
+ new TextEncoder().encode(JSON.stringify({ type, challenge, origin, crossOrigin: false })),
106
+ )
107
+
108
+ const authenticatorData = async (
109
+ rpId: string,
110
+ flags: number,
111
+ signCount: number,
112
+ attested: Uint8Array | null,
113
+ ): Promise<Uint8Array> => {
114
+ const rpIdHash = new Uint8Array(
115
+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(rpId)),
116
+ )
117
+
118
+ const counter = new Uint8Array(4)
119
+ new DataView(counter.buffer).setUint32(0, signCount)
120
+
121
+ if (attested === null) {
122
+ return concat(rpIdHash, Uint8Array.from([flags]), counter)
123
+ }
124
+
125
+ const idLength = new Uint8Array(2)
126
+ new DataView(idLength.buffer).setUint16(0, credentialId.length)
127
+
128
+ return concat(
129
+ rpIdHash,
130
+ Uint8Array.from([flags]),
131
+ counter,
132
+ new Uint8Array(16),
133
+ idLength,
134
+ credentialId,
135
+ attested,
136
+ )
137
+ }
138
+
139
+ return {
140
+ credentialId: encodeBase64Url(credentialId),
141
+ publicKey: encodeBase64Url(coseKey),
142
+
143
+ async register(request): Promise<FixtureResponse> {
144
+ const authData = await authenticatorData(
145
+ request.rpId ?? input.rpId,
146
+ request.flags ?? 0b0100_0101,
147
+ 0,
148
+ coseKey,
149
+ )
150
+
151
+ return {
152
+ clientDataJSON: clientData(
153
+ request.type ?? 'webauthn.create',
154
+ request.challenge,
155
+ request.origin,
156
+ ),
157
+ attestationObject: encodeBase64Url(
158
+ encodeCbor(
159
+ new Map<string, unknown>([
160
+ ['fmt', 'none'],
161
+ ['attStmt', new Map()],
162
+ ['authData', authData],
163
+ ]),
164
+ ),
165
+ ),
166
+ }
167
+ },
168
+
169
+ async assert(request): Promise<FixtureAssertion> {
170
+ const authData = await authenticatorData(
171
+ request.rpId ?? input.rpId,
172
+ request.flags ?? 0b0000_0101,
173
+ request.signCount ?? 1,
174
+ null,
175
+ )
176
+
177
+ const json = clientData(request.type ?? 'webauthn.get', request.challenge, request.origin)
178
+
179
+ const clientDataHash = new Uint8Array(
180
+ await crypto.subtle.digest('SHA-256', decodeBase64Url(json)),
181
+ )
182
+
183
+ const raw = new Uint8Array(
184
+ await crypto.subtle.sign(
185
+ signParams(algorithm),
186
+ pair.privateKey,
187
+ concat(authData, clientDataHash),
188
+ ),
189
+ )
190
+
191
+ const signature = algorithm === 'ES256' ? derFromRaw(raw) : raw
192
+ if (request.tamper === true) {
193
+ signature[signature.length - 1] = (signature[signature.length - 1] ?? 0) ^ 0xff
194
+ }
195
+
196
+ return {
197
+ clientDataJSON: json,
198
+ authenticatorData: encodeBase64Url(authData),
199
+ signature: encodeBase64Url(signature),
200
+ }
201
+ },
202
+ }
203
+ }
204
+
205
+ function generateParams(
206
+ algorithm: FixtureAlgorithm,
207
+ ): EcKeyGenParams | RsaHashedKeyGenParams | Algorithm {
208
+ if (algorithm === 'ES256') return { name: 'ECDSA', namedCurve: 'P-256' }
209
+ if (algorithm === 'EdDSA') return { name: 'Ed25519' }
210
+ return {
211
+ name: 'RSASSA-PKCS1-v1_5',
212
+ modulusLength: 2048,
213
+ publicExponent: new Uint8Array([1, 0, 1]),
214
+ hash: 'SHA-256',
215
+ }
216
+ }
217
+
218
+ function signParams(algorithm: FixtureAlgorithm): EcdsaParams | Algorithm {
219
+ if (algorithm === 'ES256') return { name: 'ECDSA', hash: 'SHA-256' }
220
+ if (algorithm === 'EdDSA') return { name: 'Ed25519' }
221
+ return { name: 'RSASSA-PKCS1-v1_5' }
222
+ }
223
+
224
+ function coseEntries(algorithm: FixtureAlgorithm, jwk: JsonWebKey): Map<number, unknown> {
225
+ if (algorithm === 'ES256') {
226
+ return new Map<number, unknown>([
227
+ [1, 2],
228
+ [3, -7],
229
+ [-1, 1],
230
+ [-2, decodeBase64Url(jwk.x!)],
231
+ [-3, decodeBase64Url(jwk.y!)],
232
+ ])
233
+ }
234
+
235
+ if (algorithm === 'EdDSA') {
236
+ return new Map<number, unknown>([
237
+ [1, 1],
238
+ [3, -8],
239
+ [-1, 6],
240
+ [-2, decodeBase64Url(jwk.x!)],
241
+ ])
242
+ }
243
+
244
+ return new Map<number, unknown>([
245
+ [1, 3],
246
+ [3, -257],
247
+ [-1, decodeBase64Url(jwk.n!)],
248
+ [-2, decodeBase64Url(jwk.e!)],
249
+ ])
250
+ }
251
+
252
+ export function derFromRaw(raw: Uint8Array): Uint8Array {
253
+ const half = raw.length / 2
254
+ const r = trim(raw.slice(0, half))
255
+ const s = trim(raw.slice(half))
256
+
257
+ const body = concat(Uint8Array.from([0x02, r.length]), r, Uint8Array.from([0x02, s.length]), s)
258
+
259
+ return concat(Uint8Array.from([0x30, body.length]), body)
260
+ }
261
+
262
+ function trim(value: Uint8Array): Uint8Array {
263
+ let start = 0
264
+ while (start < value.length - 1 && value[start] === 0) start += 1
265
+
266
+ const trimmed = value.slice(start)
267
+ return trimmed[0]! > 0x7f ? concat(Uint8Array.from([0]), trimmed) : trimmed
268
+ }
@@ -0,0 +1,110 @@
1
+ export type CborValue =
2
+ | number
3
+ | bigint
4
+ | string
5
+ | boolean
6
+ | null
7
+ | Uint8Array
8
+ | readonly CborValue[]
9
+ | ReadonlyMap<CborValue, CborValue>
10
+
11
+ export interface CborRead {
12
+ readonly value: CborValue
13
+ readonly length: number
14
+ }
15
+
16
+ export function decodeCbor(bytes: Uint8Array, offset = 0): CborRead {
17
+ const start = offset
18
+ const initial = byteAt(bytes, offset)
19
+ offset += 1
20
+
21
+ const major = initial >> 5
22
+ const minor = initial & 0b11111
23
+
24
+ if (major === 7) {
25
+ if (minor === 20) return { value: false, length: offset - start }
26
+ if (minor === 21) return { value: true, length: offset - start }
27
+ if (minor === 22) return { value: null, length: offset - start }
28
+ if (minor === 23) return { value: null, length: offset - start }
29
+ throw new Error(`unsupported CBOR simple value ${minor}`)
30
+ }
31
+
32
+ const head = readArgument(bytes, offset, minor)
33
+ offset = head.offset
34
+ const argument = head.value
35
+
36
+ if (major === 0) return { value: argument, length: offset - start }
37
+ if (major === 1) return { value: -1 - argument, length: offset - start }
38
+
39
+ if (major === 2) {
40
+ const end = offset + argument
41
+ if (end > bytes.length) throw new Error('truncated CBOR byte string')
42
+ return { value: bytes.slice(offset, end), length: end - start }
43
+ }
44
+
45
+ if (major === 3) {
46
+ const end = offset + argument
47
+ if (end > bytes.length) throw new Error('truncated CBOR text string')
48
+ return {
49
+ value: new TextDecoder().decode(bytes.subarray(offset, end)),
50
+ length: end - start,
51
+ }
52
+ }
53
+
54
+ if (major === 4) {
55
+ const items: CborValue[] = []
56
+ for (let index = 0; index < argument; index += 1) {
57
+ const item = decodeCbor(bytes, offset)
58
+ items.push(item.value)
59
+ offset += item.length
60
+ }
61
+ return { value: items, length: offset - start }
62
+ }
63
+
64
+ if (major === 5) {
65
+ const entries = new Map<CborValue, CborValue>()
66
+ for (let index = 0; index < argument; index += 1) {
67
+ const key = decodeCbor(bytes, offset)
68
+ offset += key.length
69
+ const value = decodeCbor(bytes, offset)
70
+ offset += value.length
71
+ entries.set(key.value, value.value)
72
+ }
73
+ return { value: entries, length: offset - start }
74
+ }
75
+
76
+ throw new Error(`unsupported CBOR major type ${major}`)
77
+ }
78
+
79
+ function readArgument(
80
+ bytes: Uint8Array,
81
+ offset: number,
82
+ minor: number,
83
+ ): { value: number; offset: number } {
84
+ if (minor < 24) return { value: minor, offset }
85
+ if (minor === 24) return { value: byteAt(bytes, offset), offset: offset + 1 }
86
+ if (minor === 25) {
87
+ return { value: (byteAt(bytes, offset) << 8) | byteAt(bytes, offset + 1), offset: offset + 2 }
88
+ }
89
+ if (minor === 26) {
90
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 4)
91
+ return { value: view.getUint32(0), offset: offset + 4 }
92
+ }
93
+ throw new Error(`unsupported CBOR argument width ${minor}`)
94
+ }
95
+
96
+ function byteAt(bytes: Uint8Array, offset: number): number {
97
+ const value = bytes[offset]
98
+ if (value === undefined) throw new Error('truncated CBOR value')
99
+ return value
100
+ }
101
+
102
+ export function cborMap(value: CborValue): ReadonlyMap<CborValue, CborValue> {
103
+ if (!(value instanceof Map)) throw new Error('expected a CBOR map')
104
+ return value
105
+ }
106
+
107
+ export function cborBytes(value: CborValue | undefined): Uint8Array {
108
+ if (!(value instanceof Uint8Array)) throw new Error('expected a CBOR byte string')
109
+ return value
110
+ }
@@ -0,0 +1,130 @@
1
+ import { encodeBase64Url } from '../crypto/base64url'
2
+ import { type CborValue, cborMap, decodeCbor } from './cbor'
3
+
4
+ export type CoseAlgorithm = -7 | -8 | -257
5
+
6
+ export const SUPPORTED_ALGORITHMS: readonly CoseAlgorithm[] = [-7, -257, -8]
7
+
8
+ const KTY = 1
9
+ const ALG = 3
10
+ const CRV = -1
11
+ const X = -2
12
+ const Y = -3
13
+ const RSA_MODULUS = -1
14
+ const RSA_EXPONENT = -2
15
+
16
+ interface ImportedKey {
17
+ readonly jwk: JsonWebKey
18
+ readonly importParams: RsaHashedImportParams | EcKeyImportParams | AlgorithmIdentifier
19
+ readonly verifyParams: AlgorithmIdentifier | EcdsaParams
20
+ readonly derSignature: boolean
21
+ }
22
+
23
+ export function algorithmOf(publicKey: Uint8Array): CoseAlgorithm {
24
+ const key = cborMap(decodeCbor(publicKey).value)
25
+ const alg = key.get(ALG)
26
+ if (alg === -7 || alg === -8 || alg === -257) return alg
27
+ throw new Error('unsupported COSE algorithm')
28
+ }
29
+
30
+ export async function importCoseKey(publicKey: Uint8Array): Promise<{
31
+ readonly key: CryptoKey
32
+ readonly verifyParams: AlgorithmIdentifier | EcdsaParams
33
+ readonly derSignature: boolean
34
+ }> {
35
+ const described = describe(cborMap(decodeCbor(publicKey).value))
36
+
37
+ const key = await crypto.subtle.importKey('jwk', described.jwk, described.importParams, false, [
38
+ 'verify',
39
+ ])
40
+
41
+ return {
42
+ key,
43
+ verifyParams: described.verifyParams,
44
+ derSignature: described.derSignature,
45
+ }
46
+ }
47
+
48
+ function describe(key: ReadonlyMap<CborValue, CborValue>): ImportedKey {
49
+ const kty = key.get(KTY)
50
+ const alg = key.get(ALG)
51
+
52
+ if (kty === 2 && alg === -7) {
53
+ if (key.get(CRV) !== 1) throw new Error('unsupported elliptic curve')
54
+ return {
55
+ jwk: {
56
+ kty: 'EC',
57
+ crv: 'P-256',
58
+ x: coordinate(key.get(X)),
59
+ y: coordinate(key.get(Y)),
60
+ },
61
+ importParams: { name: 'ECDSA', namedCurve: 'P-256' },
62
+ verifyParams: { name: 'ECDSA', hash: 'SHA-256' },
63
+ derSignature: true,
64
+ }
65
+ }
66
+
67
+ if (kty === 3 && alg === -257) {
68
+ return {
69
+ jwk: {
70
+ kty: 'RSA',
71
+ n: coordinate(key.get(RSA_MODULUS)),
72
+ e: coordinate(key.get(RSA_EXPONENT)),
73
+ alg: 'RS256',
74
+ },
75
+ importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
76
+ verifyParams: { name: 'RSASSA-PKCS1-v1_5' },
77
+ derSignature: false,
78
+ }
79
+ }
80
+
81
+ if (kty === 1 && alg === -8) {
82
+ if (key.get(CRV) !== 6) throw new Error('unsupported edwards curve')
83
+ return {
84
+ jwk: { kty: 'OKP', crv: 'Ed25519', x: coordinate(key.get(X)) } as JsonWebKey,
85
+ importParams: { name: 'Ed25519' },
86
+ verifyParams: { name: 'Ed25519' },
87
+ derSignature: false,
88
+ }
89
+ }
90
+
91
+ throw new Error('unsupported COSE key type')
92
+ }
93
+
94
+ function coordinate(value: CborValue | undefined): string {
95
+ if (!(value instanceof Uint8Array)) throw new Error('malformed COSE key')
96
+ return encodeBase64Url(value)
97
+ }
98
+
99
+ export function rawSignatureFromDer(signature: Uint8Array, coordinateBytes = 32): Uint8Array {
100
+ if (signature[0] !== 0x30) throw new Error('malformed ECDSA signature')
101
+
102
+ let offset = 2
103
+ if (signature[1] !== undefined && signature[1] > 0x80) offset += signature[1] - 0x80
104
+
105
+ const readInteger = (): Uint8Array => {
106
+ if (signature[offset] !== 0x02) throw new Error('malformed ECDSA signature')
107
+ const length = signature[offset + 1]
108
+ if (length === undefined) throw new Error('malformed ECDSA signature')
109
+ const start = offset + 2
110
+ offset = start + length
111
+ return signature.slice(start, offset)
112
+ }
113
+
114
+ const r = readInteger()
115
+ const s = readInteger()
116
+
117
+ const out = new Uint8Array(coordinateBytes * 2)
118
+ out.set(pad(r, coordinateBytes), 0)
119
+ out.set(pad(s, coordinateBytes), coordinateBytes)
120
+ return out
121
+ }
122
+
123
+ function pad(value: Uint8Array, size: number): Uint8Array {
124
+ const trimmed = value[0] === 0 ? value.slice(1) : value
125
+ if (trimmed.length > size) throw new Error('malformed ECDSA signature')
126
+
127
+ const out = new Uint8Array(size)
128
+ out.set(trimmed, size - trimmed.length)
129
+ return out
130
+ }