passkey-kit 0.1.0 → 0.2.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,6 @@
1
+ {
2
+ "rust-analyzer.linkedProjects": [
3
+ "./contracts/contract-webauthn-factory/Cargo.toml",
4
+ "./contracts/contract-webauthn-secp256r1/Cargo.toml",
5
+ ]
6
+ }
package/README.md CHANGED
@@ -10,4 +10,12 @@ To build:
10
10
 
11
11
  ```bash
12
12
  pnpm run build
13
- ```
13
+ ```
14
+
15
+ If you fiddle with contracts in `./contracts` you'll need to run the make commands. Just remember to update the `WEBAUTHN_FACTORY` and `WEBAUTHN_WASM` values from the `make deploy` command before running `make init`. Once you run `make init` you'll need to update all the `.env` site files with the new `PUBLIC_factoryContractId`.
16
+
17
+ Keep in mind the bindings here in `./packages` have been _heavily_ modified. Be careful when rebuilding and updating. Likely you'll only want to update the `src/index.ts` files in each respective package.
18
+
19
+ ## TODO
20
+ - [ ] Signer list should be paginated
21
+ - [ ] Attach some meaningful metadata to signers so you know which one belongs to which domain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passkey-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A helper library for creating and using passkey accounts on the Stellar blockchain.",
5
5
  "author": "Tyler van der Hoeven",
6
6
  "license": "MIT",
@@ -9,7 +9,6 @@
9
9
  "types": "types/index.d.ts",
10
10
  "dependencies": {
11
11
  "@simplewebauthn/browser": "^10.0.0",
12
- "@simplewebauthn/types": "^10.0.0",
13
12
  "@stellar/stellar-sdk": "12.0.1",
14
13
  "base64url": "^3.0.1",
15
14
  "bigint-conversion": "^2.4.3",
package/src/base.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { Networks, Transaction, Horizon, FeeBumpTransaction } from '@stellar/stellar-sdk'
2
+
3
+ export class PasskeyBase {
4
+ public networkPassphrase: Networks
5
+ public horizonUrl: string
6
+ public horizon: Horizon.Server
7
+ public feeBumpUrl: string | undefined
8
+ public feeBumpJwt: string | undefined
9
+
10
+ constructor(options: {
11
+ networkPassphrase: Networks,
12
+ horizonUrl: string,
13
+ feeBumpUrl?: string,
14
+ feeBumpJwt?: string,
15
+ }) {
16
+ const {
17
+ networkPassphrase,
18
+ horizonUrl,
19
+ feeBumpUrl,
20
+ feeBumpJwt,
21
+ } = options
22
+
23
+ this.networkPassphrase = networkPassphrase
24
+ this.horizonUrl = horizonUrl
25
+ this.horizon = new Horizon.Server(horizonUrl)
26
+
27
+ if (feeBumpUrl)
28
+ this.feeBumpUrl = feeBumpUrl
29
+
30
+ if (feeBumpJwt)
31
+ this.feeBumpJwt = feeBumpJwt
32
+ }
33
+
34
+ public async send(txn: Transaction, fee: number = 10_000) {
35
+ const data = new FormData();
36
+
37
+ data.set('xdr', txn.toXDR());
38
+ data.set('fee', fee.toString());
39
+
40
+ const bumptxn = await fetch(this.feeBumpUrl!, {
41
+ method: 'POST',
42
+ headers: {
43
+ authorization: `Bearer ${this.feeBumpJwt}`,
44
+ },
45
+ body: data
46
+ }).then(async (res) => {
47
+ if (res.ok)
48
+ return res.text()
49
+ else throw await res.json()
50
+ })
51
+
52
+ return this.horizon.submitTransaction(new FeeBumpTransaction(bumptxn, this.networkPassphrase))
53
+ }
54
+ }
package/src/index.ts CHANGED
@@ -1,492 +1,4 @@
1
- import { Client as PasskeyClient } from 'passkey-kit-sdk'
2
- import { Client as FactoryClient, networks } from 'passkey-factory-sdk'
3
- import { Address, Networks, StrKey, hash, xdr, Transaction, Horizon, FeeBumpTransaction, SorobanRpc, Operation, scValToNative, TransactionBuilder } from '@stellar/stellar-sdk'
4
- import { bufToBigint, bigintToBuf } from 'bigint-conversion'
5
- import base64url from 'base64url'
6
- import { startRegistration, startAuthentication } from "@simplewebauthn/browser"
7
- import { decode } from 'cbor-x/decode'
8
- import type { RegistrationResponseJSON } from '@simplewebauthn/types';
9
- import { Buffer } from 'buffer'
1
+ import { PasskeyBase } from "./base"
2
+ import { PasskeyKit } from "./kit"
10
3
 
11
- /* TODO
12
- - Clean up these params and the interface as a whole
13
- Might put wallet activities and maybe factory as well into the root of the class vs buried inside this.wallet and this.factory
14
- - Right now publicKey can mean a Stellar public key or a passkey public key, there should be a noted difference
15
- */
16
-
17
- export class PasskeyAccount {
18
- public keyId: string | undefined
19
- public sudoKeyId: string | undefined
20
- public wallet: PasskeyClient | undefined
21
- public factory: FactoryClient
22
- public sequencePublicKey: string
23
- public networkPassphrase: Networks
24
- public horizonUrl: string
25
- public horizon: Horizon.Server
26
- public rpcUrl: string
27
- public rpc: SorobanRpc.Server
28
- public feeBumpUrl: string
29
- public feeBumpJwt: string
30
- public factoryContractId: string = networks.testnet.contractId
31
-
32
- /* TODO
33
- - Consider adding the ability to pass in a keyId and maybe even a contractId in order to preconnect to a wallet
34
- If just a keyId call connectWallet in order to get the contractId
35
- If both keyId and contractId are passed in then we can skip the connectWallet call (though we won't get the sudoKeyId in that case)
36
- */
37
- constructor(options: {
38
- sequencePublicKey: string,
39
- networkPassphrase: Networks,
40
- horizonUrl: string,
41
- rpcUrl: string,
42
- feeBumpUrl: string,
43
- feeBumpJwt: string,
44
- factoryContractId?: string,
45
- }) {
46
- const {
47
- sequencePublicKey,
48
- networkPassphrase,
49
- horizonUrl,
50
- rpcUrl,
51
- feeBumpUrl,
52
- feeBumpJwt,
53
- factoryContractId
54
- } = options
55
-
56
- this.sequencePublicKey = sequencePublicKey
57
- this.networkPassphrase = networkPassphrase
58
- this.horizonUrl = horizonUrl
59
- this.horizon = new Horizon.Server(horizonUrl)
60
- this.rpcUrl = rpcUrl
61
- this.rpc = new SorobanRpc.Server(rpcUrl)
62
- this.feeBumpUrl = feeBumpUrl
63
- this.feeBumpJwt = feeBumpJwt
64
-
65
- if (factoryContractId)
66
- this.factoryContractId = factoryContractId
67
-
68
- this.factory = new FactoryClient({
69
- publicKey: sequencePublicKey,
70
- contractId: this.factoryContractId,
71
- networkPassphrase,
72
- rpcUrl
73
- })
74
- }
75
-
76
- public async createWallet(name: string, user: string) {
77
- const { keyId, publicKey } = await this.createKey(name, user)
78
-
79
- const { result, built } = await this.factory.deploy({
80
- id: keyId,
81
- pk: publicKey!
82
- })
83
-
84
- const contractId = result.unwrap() as string
85
-
86
- this.wallet = new PasskeyClient({
87
- publicKey: this.sequencePublicKey,
88
- contractId,
89
- networkPassphrase: this.networkPassphrase,
90
- rpcUrl: this.rpcUrl
91
- })
92
-
93
- return {
94
- keyId,
95
- contractId,
96
- xdr: built!.toXDR() as string
97
- }
98
- }
99
-
100
- public async createKey(name: string, user: string) {
101
- const startRegistrationResponse = await startRegistration({
102
- challenge: base64url("sorobanisbest"),
103
- rp: {
104
- // id: undefined,
105
- name,
106
- },
107
- user: {
108
- id: base64url(user),
109
- name: user,
110
- displayName: user,
111
- },
112
- authenticatorSelection: {
113
- requireResidentKey: false,
114
- residentKey: "preferred",
115
- userVerification: "discouraged",
116
- },
117
- pubKeyCredParams: [{ alg: -7, type: "public-key" }],
118
- attestation: "none",
119
- });
120
-
121
- if (!this.keyId) {
122
- this.keyId = startRegistrationResponse.id
123
-
124
- // If there was no keyId we're likely about to deploy a new wallet so we should set the sudoKeyId
125
- if (!this.sudoKeyId)
126
- this.sudoKeyId = startRegistrationResponse.id
127
- }
128
-
129
- const { publicKeyObject } = this.getPublicKeyObject(startRegistrationResponse.response.attestationObject);
130
-
131
- const publicKey = Buffer.from([
132
- 4, // (0x04 prefix) https://en.bitcoin.it/wiki/Elliptic_Curve_Digital_Signature_Algorithm
133
- ...publicKeyObject.get('-2')!,
134
- ...publicKeyObject.get('-3')!
135
- ])
136
-
137
- return {
138
- keyId: base64url.toBuffer(startRegistrationResponse.id),
139
- publicKey
140
- }
141
- }
142
-
143
- public async connectWallet(id?: string) {
144
- /* TODO
145
- - Support passing in a contractId as well as a keyId
146
- Maybe not as we wouldn't have a keyId which could have interesting consequences
147
- Also not sure what the use case would be for this where keyId wouldn't also be possible and better
148
- */
149
-
150
- // @ts-ignore
151
- // https://github.com/stellar/js-stellar-base/issues/750
152
- // if (id && StrKey.isValidContract(id)) {
153
-
154
- // } else {
155
-
156
- // }
157
-
158
- const startAuthenticationResponse = id
159
- ? { id }
160
- : await startAuthentication({
161
- challenge: base64url("sorobanisbest"),
162
- // rpId: undefined,
163
- userVerification: "discouraged",
164
- });
165
-
166
- if (!this.keyId)
167
- this.keyId = startAuthenticationResponse.id
168
-
169
- const keyIdBuffer = base64url.toBuffer(startAuthenticationResponse.id)
170
-
171
- // NOTE might not need this for derivation as all signers are stored in the factory and we can use that lookup as both primary and secondary
172
- let contractId = StrKey.encodeContract(hash(xdr.HashIdPreimage.envelopeTypeContractId(
173
- new xdr.HashIdPreimageContractId({
174
- networkId: hash(Buffer.from(this.networkPassphrase, 'utf-8')),
175
- contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress(
176
- new xdr.ContractIdPreimageFromAddress({
177
- address: Address.fromString(this.factoryContractId).toScAddress(),
178
- salt: hash(keyIdBuffer),
179
- })
180
- )
181
- })
182
- ).toXDR()));
183
-
184
- // attempt passkey id derivation
185
- try {
186
- await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance())
187
- }
188
- // if that fails look up from the factory mapper
189
- catch {
190
- const { val } = await this.rpc.getContractData(this.factoryContractId, xdr.ScVal.scvBytes(keyIdBuffer))
191
- contractId = scValToNative(val.contractData().val())
192
- }
193
-
194
- this.wallet = new PasskeyClient({
195
- publicKey: this.sequencePublicKey,
196
- contractId,
197
- networkPassphrase: this.networkPassphrase,
198
- rpcUrl: this.rpcUrl
199
- })
200
-
201
- // get and set the sudo signer
202
- await this.getData()
203
-
204
- return {
205
- keyId: keyIdBuffer,
206
- contractId
207
- }
208
- }
209
-
210
- public async sign(
211
- txn: Transaction | string,
212
- options?: {
213
- keyId?: 'any' | 'sudo' | string | Uint8Array
214
- ledgersToLive?: number
215
- }
216
- ) {
217
- // Default mirrors DEFAULT_TIMEOUT (currently 5 minutes) https://github.com/stellar/js-stellar-sdk/blob/master/src/contract/utils.ts#L7
218
- let { keyId, ledgersToLive = 60 } = options || {}
219
-
220
- // hack to ensure we don't stack fees when simulating and assembling multiple times
221
- txn = TransactionBuilder.cloneFrom(new Transaction(
222
- typeof txn === 'string'
223
- ? txn
224
- : txn.toXDR(),
225
- this.networkPassphrase
226
- ), { fee: '0' }).build()
227
-
228
- // NOTE hard coded to sign only Soroban transactions and only and always the first auth
229
- const op = txn.operations[0] as Operation.InvokeHostFunction
230
- const auth = op.auth![0]
231
- const lastLedger = await this.rpc.getLatestLedger().then(({ sequence }) => sequence)
232
- const authHash = hash(
233
- xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
234
- new xdr.HashIdPreimageSorobanAuthorization({
235
- networkId: hash(Buffer.from(this.networkPassphrase, 'utf-8')),
236
- nonce: auth.credentials().address().nonce(),
237
- signatureExpirationLedger: lastLedger + ledgersToLive,
238
- invocation: auth.rootInvocation()
239
- })
240
- ).toXDR()
241
- )
242
-
243
- const authenticationResponse = await startAuthentication(
244
- keyId === 'any' || (keyId === 'sudo' && !this.sudoKeyId) || (!keyId && !this.keyId)
245
- ? {
246
- challenge: base64url(authHash),
247
- // rpId: undefined,
248
- userVerification: "discouraged",
249
- }
250
- : {
251
- challenge: base64url(authHash),
252
- // rpId: undefined,
253
- allowCredentials: [
254
- {
255
- id: keyId === 'sudo'
256
- ? this.sudoKeyId!
257
- : keyId instanceof Uint8Array
258
- ? base64url(keyId)
259
- : keyId || this.keyId!,
260
- type: "public-key",
261
- },
262
- ],
263
- userVerification: "discouraged",
264
- }
265
- );
266
-
267
- // set sudo if this is a sudo request
268
- if (keyId === 'sudo')
269
- this.sudoKeyId = authenticationResponse.id
270
-
271
- // reset this.keyId to be the most recently used passkey
272
- this.keyId = authenticationResponse.id
273
-
274
- const signatureRaw = base64url.toBuffer(authenticationResponse.response.signature);
275
- const signature = this.convertEcdsaSignatureAsnToCompact(signatureRaw);
276
- const creds = auth.credentials().address();
277
-
278
- creds.signatureExpirationLedger(lastLedger + ledgersToLive)
279
- creds.signature(xdr.ScVal.scvMap([
280
- new xdr.ScMapEntry({
281
- key: xdr.ScVal.scvSymbol('authenticator_data'),
282
- val: xdr.ScVal.scvBytes(base64url.toBuffer(authenticationResponse.response.authenticatorData)),
283
- }),
284
- new xdr.ScMapEntry({
285
- key: xdr.ScVal.scvSymbol('client_data_json'),
286
- val: xdr.ScVal.scvBytes(base64url.toBuffer(authenticationResponse.response.clientDataJSON)),
287
- }),
288
- new xdr.ScMapEntry({
289
- key: xdr.ScVal.scvSymbol('id'),
290
- val: xdr.ScVal.scvBytes(base64url.toBuffer(authenticationResponse.id)),
291
- }),
292
- new xdr.ScMapEntry({
293
- key: xdr.ScVal.scvSymbol('signature'),
294
- val: xdr.ScVal.scvBytes(signature),
295
- }),
296
- ]))
297
-
298
- const sim = await this.rpc.simulateTransaction(txn)
299
-
300
- if (
301
- SorobanRpc.Api.isSimulationError(sim)
302
- || SorobanRpc.Api.isSimulationRestore(sim) // TODO handle restore flow
303
- ) throw sim
304
-
305
- return SorobanRpc.assembleTransaction(txn, sim).build().toXDR()
306
- }
307
-
308
- public async send(txn: Transaction, fee: number = 10_000) {
309
- const data = new FormData();
310
-
311
- data.set('xdr', txn.toXDR());
312
- data.set('fee', fee.toString());
313
-
314
- const bumptxn = await fetch(this.feeBumpUrl, {
315
- method: 'POST',
316
- headers: {
317
- authorization: `Bearer ${this.feeBumpJwt}`,
318
- },
319
- body: data
320
- }).then(async (res) => {
321
- if (res.ok)
322
- return res.text()
323
- else throw await res.json()
324
- })
325
-
326
- return this.horizon.submitTransaction(new FeeBumpTransaction(bumptxn, this.networkPassphrase))
327
- }
328
-
329
- public async getData() {
330
- const data: Map<string, any> = new Map()
331
-
332
- const { val } = await this.rpc.getContractData(
333
- this.wallet!.options.contractId,
334
- xdr.ScVal.scvLedgerKeyContractInstance(),
335
- );
336
-
337
- val.contractData()
338
- .val()
339
- .instance()
340
- .storage()
341
- ?.forEach((entry) => {
342
- data.set(
343
- scValToNative(entry.key()),
344
- scValToNative(entry.val()),
345
- );
346
- });
347
-
348
- this.sudoKeyId = base64url(data.get('sudo_sig'))
349
-
350
- return data
351
- }
352
-
353
- /* TODO
354
- - Add a getKeyInfo action to get info about a specific passkey
355
- Specifically looking for name, type, etc. data so a user could grok what signer mapped to what passkey
356
- @Later
357
- */
358
-
359
- private getPublicKeyObject(attestationObject: string) {
360
- const { authData } = decode(base64url.toBuffer(attestationObject));
361
- const authDataUint8Array = new Uint8Array(authData);
362
- const authDataView = new DataView(authDataUint8Array.buffer, 0, authDataUint8Array.length);
363
-
364
- let offset = 0;
365
-
366
- // RP ID Hash (32 bytes)
367
- const rpIdHash = authData.slice(offset, offset + 32);
368
- offset += 32;
369
-
370
- // Flags (1 byte)
371
- const flags = authDataView.getUint8(offset);
372
- offset += 1;
373
-
374
- // Sign Count (4 bytes, big endian)
375
- const signCount = authDataView.getUint32(offset, false);
376
- offset += 4;
377
-
378
- // Attested Credential Data, if present
379
- if (flags & 0x40) { // Checking the AT flag
380
- // AAGUID (16 bytes)
381
- const aaguid = authData.slice(offset, offset + 16);
382
- offset += 16;
383
-
384
- // Credential ID Length (2 bytes, big endian)
385
- const credIdLength = authDataView.getUint16(offset, false);
386
- offset += 2;
387
-
388
- // Credential ID (variable length)
389
- const credentialId = authData.slice(offset, offset + credIdLength);
390
- offset += credIdLength;
391
-
392
- // Credential Public Key - (77 bytes...I hope)
393
- const credentialPublicKey = authData.slice(offset, offset + 77);
394
- offset += 77;
395
-
396
- // Any leftover bytes. I found some when using a YubiKey
397
- const theRest = authData.slice(offset);
398
-
399
- // Decode the credential public key to COSE
400
- const publicKeyObject = new Map<string, any>(Object.entries(decode(credentialPublicKey)));
401
-
402
- return {
403
- rpIdHash,
404
- flags,
405
- signCount,
406
- aaguid,
407
- credIdLength,
408
- credentialId,
409
- credentialPublicKey,
410
- theRest,
411
- publicKeyObject
412
- };
413
- }
414
-
415
- throw new Error("Attested credential data not present in the flags.");
416
- }
417
-
418
- private convertEcdsaSignatureAsnToCompact(sig: Buffer) {
419
- // Define the order of the curve secp256k1
420
- // https://github.com/RustCrypto/elliptic-curves/blob/master/p256/src/lib.rs#L72
421
- const q = Buffer.from('ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551', 'hex')
422
-
423
- // ASN Sequence
424
- let offset = 0;
425
- if (sig[offset] != 0x30) {
426
- throw "signature is not a sequence";
427
- }
428
- offset += 1;
429
-
430
- // ASN Sequence Byte Length
431
- offset += 1;
432
-
433
- // ASN Integer (R)
434
- if (sig[offset] != 0x02) {
435
- throw "first element in sequence is not an integer";
436
- }
437
- offset += 1;
438
-
439
- // ASN Integer (R) Byte Length
440
- const rLen = sig[offset];
441
- offset += 1;
442
-
443
- // ASN Integer (R) Byte Value
444
- if (rLen >= 33) {
445
- if (rLen != 33 || sig[offset] != 0x00) {
446
- throw "can only handle larger than 32 byte R's that are len 33 and lead with zero";
447
- }
448
- offset += 1;
449
- }
450
- const r = sig.slice(offset, offset + 32);
451
- offset += 32;
452
-
453
- // ASN Integer (S)
454
- if (sig[offset] != 0x02) {
455
- throw "second element in sequence is not an integer";
456
- }
457
- offset += 1;
458
-
459
- // ASN Integer (S) Byte Length
460
- const sLen = sig[offset];
461
- offset += 1;
462
-
463
- // ASN Integer (S) Byte Value
464
- if (sLen >= 33) {
465
- if (sLen != 33 || sig[offset] != 0x00) {
466
- throw "can only handle larger than 32 byte R's that are len 33 and lead with zero";
467
- }
468
- offset += 1;
469
- }
470
-
471
- const s = sig.slice(offset, offset + 32);
472
-
473
- offset += 32;
474
-
475
- let signature64: Buffer
476
-
477
- // Force low S range
478
- // https://github.com/stellar/stellar-protocol/discussions/1435#discussioncomment-8809175
479
- // https://discord.com/channels/897514728459468821/1233048618571927693
480
- if (bufToBigint(s) > ((bufToBigint(q) - BigInt(1)) / BigInt(2))) {
481
- signature64 = Buffer.from([...r, ...Buffer.from(bigintToBuf(bufToBigint(q) - bufToBigint(s), true) as ArrayBuffer)]);
482
- } else {
483
- signature64 = Buffer.from([...r, ...s]);
484
- }
485
-
486
- return signature64;
487
- }
488
- }
489
-
490
- function isAuthentication(value: any): value is RegistrationResponseJSON {
491
- return value?.response?.attestationObject;
492
- }
4
+ export { PasskeyBase, PasskeyKit }
package/src/kit.ts ADDED
@@ -0,0 +1,487 @@
1
+ import { Client as PasskeyClient } from 'passkey-kit-sdk'
2
+ import { Client as FactoryClient, networks } from 'passkey-factory-sdk'
3
+ import { Address, Networks, StrKey, hash, xdr, Transaction, Horizon, SorobanRpc, Operation, scValToNative, TransactionBuilder } from '@stellar/stellar-sdk'
4
+ import { bufToBigint, bigintToBuf } from 'bigint-conversion'
5
+ import base64url from 'base64url'
6
+ import { startRegistration, startAuthentication } from "@simplewebauthn/browser"
7
+ import { decode } from 'cbor-x/decode'
8
+ import { Buffer } from 'buffer'
9
+ import { PasskeyBase } from './base'
10
+
11
+ /* TODO
12
+ - Clean up these params and the interface as a whole
13
+ Might put wallet activities and maybe factory as well into the root of the class vs buried inside this.wallet and this.factory
14
+ @Later
15
+
16
+ - Right now publicKey can mean a Stellar public key or a passkey public key, there should be a noted difference
17
+
18
+ - It's finally time to make a server and a client version of this package
19
+ Maybe. I want to support passing "bad" secret values on the client.
20
+ It's maybe only the server that could be smaller and cleaner by opting out out WebAuthN stuff
21
+ */
22
+
23
+ export class PasskeyKit extends PasskeyBase {
24
+ public keyId: string | undefined
25
+ public sudoKeyId: string | undefined
26
+ public wallet: PasskeyClient | undefined
27
+ public factory: FactoryClient
28
+ public sequencePublicKey: string
29
+ // public networkPassphrase: Networks
30
+ // public horizonUrl: string
31
+ public horizon: Horizon.Server
32
+ public rpcUrl: string
33
+ public rpc: SorobanRpc.Server
34
+ // public feeBumpUrl: string | undefined
35
+ // public feeBumpJwt: string | undefined
36
+ public factoryContractId: string = networks.testnet.contractId
37
+
38
+ /* TODO
39
+ - Consider adding the ability to pass in a keyId and maybe even a contractId in order to preconnect to a wallet
40
+ If just a keyId call `connectWallet` in order to get the contractId
41
+ If both keyId and contractId are passed in then we can skip the connectWallet call (though we won't get the sudoKeyId in that case)
42
+ We don't stictly _need_ this as a dev can just call `connectWallet` after class instantiation but it might be a nice convenience
43
+ @Later
44
+ */
45
+ constructor(options: {
46
+ sequencePublicKey: string,
47
+ networkPassphrase: Networks,
48
+ horizonUrl: string,
49
+ rpcUrl: string,
50
+ feeBumpUrl?: string,
51
+ feeBumpJwt?: string,
52
+ /* TODO
53
+ - Maybe remove this? The factory should likely be baked in a bit more tightly
54
+ On the other hand once we have a standard interface the factory interface only uses the `deploy` method right now inside the interface
55
+ @Later
56
+ */
57
+ factoryContractId?: string,
58
+ }) {
59
+ const {
60
+ sequencePublicKey,
61
+ networkPassphrase,
62
+ horizonUrl,
63
+ rpcUrl,
64
+ feeBumpUrl,
65
+ feeBumpJwt,
66
+ factoryContractId
67
+ } = options
68
+
69
+ super({
70
+ networkPassphrase,
71
+ horizonUrl,
72
+ feeBumpUrl,
73
+ feeBumpJwt,
74
+ })
75
+
76
+ this.sequencePublicKey = sequencePublicKey
77
+
78
+ this.horizon = new Horizon.Server(horizonUrl)
79
+ this.rpcUrl = rpcUrl
80
+ this.rpc = new SorobanRpc.Server(rpcUrl)
81
+
82
+ if (factoryContractId)
83
+ this.factoryContractId = factoryContractId
84
+
85
+ this.factory = new FactoryClient({
86
+ publicKey: sequencePublicKey,
87
+ contractId: this.factoryContractId,
88
+ networkPassphrase,
89
+ rpcUrl
90
+ })
91
+ }
92
+
93
+ public async createWallet(name: string, user: string) {
94
+ const { keyId, publicKey } = await this.createKey(name, user)
95
+
96
+ const { result, built } = await this.factory.deploy({
97
+ id: keyId,
98
+ pk: publicKey!
99
+ })
100
+
101
+ const contractId = result.unwrap() as string
102
+
103
+ this.wallet = new PasskeyClient({
104
+ publicKey: this.sequencePublicKey,
105
+ contractId,
106
+ networkPassphrase: this.networkPassphrase,
107
+ rpcUrl: this.rpcUrl
108
+ })
109
+
110
+ return {
111
+ keyId,
112
+ contractId,
113
+ xdr: built!.toXDR() as string
114
+ }
115
+ }
116
+
117
+ public async createKey(name: string, user: string) {
118
+ const startRegistrationResponse = await startRegistration({
119
+ challenge: base64url("stellaristhebetterblockchain"),
120
+ rp: {
121
+ // id: undefined,
122
+ name,
123
+ },
124
+ user: {
125
+ id: base64url(user),
126
+ name: user,
127
+ displayName: user,
128
+ },
129
+ authenticatorSelection: {
130
+ requireResidentKey: false,
131
+ residentKey: "preferred",
132
+ userVerification: "discouraged",
133
+ },
134
+ pubKeyCredParams: [{ alg: -7, type: "public-key" }],
135
+ attestation: "none",
136
+ });
137
+
138
+ if (!this.keyId) {
139
+ this.keyId = startRegistrationResponse.id
140
+
141
+ // If there was no keyId we're likely about to deploy a new wallet so we should set the sudoKeyId
142
+ if (!this.sudoKeyId)
143
+ this.sudoKeyId = startRegistrationResponse.id
144
+ }
145
+
146
+ const { publicKeyObject } = this.getPublicKeyObject(startRegistrationResponse.response.attestationObject);
147
+
148
+ const publicKey = Buffer.from([
149
+ 4, // (0x04 prefix) https://en.bitcoin.it/wiki/Elliptic_Curve_Digital_Signature_Algorithm
150
+ ...publicKeyObject.get('-2')!,
151
+ ...publicKeyObject.get('-3')!
152
+ ])
153
+
154
+ return {
155
+ keyId: base64url.toBuffer(startRegistrationResponse.id),
156
+ publicKey
157
+ }
158
+ }
159
+
160
+ public async connectWallet(id?: string) {
161
+ /* TODO
162
+ - Support passing in a contractId as well as a keyId
163
+ Maybe not as we wouldn't have a keyId which could have interesting consequences
164
+ Also not sure what the use case would be for this where keyId wouldn't also be possible and better
165
+ @No
166
+ */
167
+
168
+ // @ts-ignore
169
+ // https://github.com/stellar/js-stellar-base/issues/750
170
+ // if (id && StrKey.isValidContract(id)) {
171
+
172
+ // } else {
173
+
174
+ // }
175
+
176
+ const startAuthenticationResponse = id
177
+ ? { id }
178
+ : await startAuthentication({
179
+ challenge: base64url("stellaristhebetterblockchain"),
180
+ // rpId: undefined,
181
+ userVerification: "discouraged",
182
+ });
183
+
184
+ if (!this.keyId)
185
+ this.keyId = startAuthenticationResponse.id
186
+
187
+ const keyIdBuffer = base64url.toBuffer(startAuthenticationResponse.id)
188
+
189
+ // NOTE might not need this for derivation as all signers are stored in the factory and we can use that lookup as both primary and secondary
190
+ let contractId = StrKey.encodeContract(hash(xdr.HashIdPreimage.envelopeTypeContractId(
191
+ new xdr.HashIdPreimageContractId({
192
+ networkId: hash(Buffer.from(this.networkPassphrase, 'utf-8')),
193
+ contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress(
194
+ new xdr.ContractIdPreimageFromAddress({
195
+ address: Address.fromString(this.factoryContractId).toScAddress(),
196
+ salt: hash(keyIdBuffer),
197
+ })
198
+ )
199
+ })
200
+ ).toXDR()));
201
+
202
+ // attempt passkey id derivation
203
+ try {
204
+ await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance())
205
+ }
206
+ // if that fails look up from the factory mapper
207
+ catch {
208
+ const { val } = await this.rpc.getContractData(this.factoryContractId, xdr.ScVal.scvBytes(keyIdBuffer))
209
+ contractId = scValToNative(val.contractData().val())
210
+ }
211
+
212
+ this.wallet = new PasskeyClient({
213
+ publicKey: this.sequencePublicKey,
214
+ contractId,
215
+ networkPassphrase: this.networkPassphrase,
216
+ rpcUrl: this.rpcUrl
217
+ })
218
+
219
+ // get and set the sudo signer
220
+ await this.getData()
221
+
222
+ return {
223
+ keyId: keyIdBuffer,
224
+ contractId
225
+ }
226
+ }
227
+
228
+ // TODO is there anything to be done with using the TS bindings `signAuthEntries` logic? Likely not but worth exploring
229
+ public async sign(
230
+ txn: Transaction | string,
231
+ options?: {
232
+ keyId?: 'any' | 'sudo' | string | Uint8Array
233
+ ledgersToLive?: number
234
+ }
235
+ ) {
236
+ // Default mirrors DEFAULT_TIMEOUT (currently 5 minutes) https://github.com/stellar/js-stellar-sdk/blob/master/src/contract/utils.ts#L7
237
+ let { keyId, ledgersToLive = 60 } = options || {}
238
+
239
+ // hack to ensure we don't stack fees when simulating and assembling multiple times
240
+ txn = TransactionBuilder.cloneFrom(new Transaction(
241
+ typeof txn === 'string'
242
+ ? txn
243
+ : txn.toXDR(),
244
+ this.networkPassphrase
245
+ ), { fee: '0' }).build()
246
+
247
+ // NOTE hard coded to sign only Soroban transactions and only and always the first auth
248
+ // TODO switch to support signing multiple and specific auth entries. Possibly pass in auth entry vs the entire txn
249
+ const op = txn.operations[0] as Operation.InvokeHostFunction
250
+ const auth = op.auth![0]
251
+ const lastLedger = await this.rpc.getLatestLedger().then(({ sequence }) => sequence)
252
+ const authHash = hash(
253
+ xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
254
+ new xdr.HashIdPreimageSorobanAuthorization({
255
+ networkId: hash(Buffer.from(this.networkPassphrase, 'utf-8')),
256
+ nonce: auth.credentials().address().nonce(),
257
+ signatureExpirationLedger: lastLedger + ledgersToLive,
258
+ invocation: auth.rootInvocation()
259
+ })
260
+ ).toXDR()
261
+ )
262
+
263
+ const authenticationResponse = await startAuthentication(
264
+ keyId === 'any' || (keyId === 'sudo' && !this.sudoKeyId) || (!keyId && !this.keyId)
265
+ ? {
266
+ challenge: base64url(authHash),
267
+ // rpId: undefined,
268
+ userVerification: "discouraged",
269
+ }
270
+ : {
271
+ challenge: base64url(authHash),
272
+ // rpId: undefined,
273
+ allowCredentials: [
274
+ {
275
+ id: keyId === 'sudo'
276
+ ? this.sudoKeyId!
277
+ : keyId instanceof Uint8Array
278
+ ? base64url(keyId)
279
+ : keyId || this.keyId!,
280
+ type: "public-key",
281
+ },
282
+ ],
283
+ userVerification: "discouraged",
284
+ }
285
+ );
286
+
287
+ // set sudo if this is a sudo request
288
+ if (keyId === 'sudo')
289
+ this.sudoKeyId = authenticationResponse.id
290
+
291
+ // reset this.keyId to be the most recently used passkey
292
+ this.keyId = authenticationResponse.id
293
+
294
+ const signatureRaw = base64url.toBuffer(authenticationResponse.response.signature);
295
+ const signature = this.convertEcdsaSignatureAsnToCompact(signatureRaw);
296
+ const creds = auth.credentials().address();
297
+
298
+ creds.signatureExpirationLedger(lastLedger + ledgersToLive)
299
+ creds.signature(xdr.ScVal.scvMap([
300
+ new xdr.ScMapEntry({
301
+ key: xdr.ScVal.scvSymbol('authenticator_data'),
302
+ val: xdr.ScVal.scvBytes(base64url.toBuffer(authenticationResponse.response.authenticatorData)),
303
+ }),
304
+ new xdr.ScMapEntry({
305
+ key: xdr.ScVal.scvSymbol('client_data_json'),
306
+ val: xdr.ScVal.scvBytes(base64url.toBuffer(authenticationResponse.response.clientDataJSON)),
307
+ }),
308
+ new xdr.ScMapEntry({
309
+ key: xdr.ScVal.scvSymbol('id'),
310
+ val: xdr.ScVal.scvBytes(base64url.toBuffer(authenticationResponse.id)),
311
+ }),
312
+ new xdr.ScMapEntry({
313
+ key: xdr.ScVal.scvSymbol('signature'),
314
+ val: xdr.ScVal.scvBytes(signature),
315
+ }),
316
+ ]))
317
+
318
+ const sim = await this.rpc.simulateTransaction(txn)
319
+
320
+ if (
321
+ SorobanRpc.Api.isSimulationError(sim)
322
+ || SorobanRpc.Api.isSimulationRestore(sim) // TODO handle restore flow
323
+ ) throw sim
324
+
325
+ return SorobanRpc.assembleTransaction(txn, sim).build().toXDR()
326
+ }
327
+
328
+ public async getData() {
329
+ const data: Map<string, any> = new Map()
330
+
331
+ const { val } = await this.rpc.getContractData(
332
+ this.wallet!.options.contractId,
333
+ xdr.ScVal.scvLedgerKeyContractInstance(),
334
+ );
335
+
336
+ val.contractData()
337
+ .val()
338
+ .instance()
339
+ .storage()
340
+ ?.forEach((entry) => {
341
+ data.set(
342
+ scValToNative(entry.key()),
343
+ scValToNative(entry.val()),
344
+ );
345
+ });
346
+
347
+ this.sudoKeyId = base64url(data.get('sudo_sig'))
348
+
349
+ return data
350
+ }
351
+
352
+ /* TODO
353
+ - Add a getKeyInfo action to get info about a specific passkey
354
+ Specifically looking for name, type, etc. data so a user could grok what signer mapped to what passkey
355
+ @Later
356
+ */
357
+
358
+ private getPublicKeyObject(attestationObject: string) {
359
+ const { authData } = decode(base64url.toBuffer(attestationObject));
360
+ const authDataUint8Array = new Uint8Array(authData);
361
+ const authDataView = new DataView(authDataUint8Array.buffer, 0, authDataUint8Array.length);
362
+
363
+ let offset = 0;
364
+
365
+ // RP ID Hash (32 bytes)
366
+ const rpIdHash = authData.slice(offset, offset + 32);
367
+ offset += 32;
368
+
369
+ // Flags (1 byte)
370
+ const flags = authDataView.getUint8(offset);
371
+ offset += 1;
372
+
373
+ // Sign Count (4 bytes, big endian)
374
+ const signCount = authDataView.getUint32(offset, false);
375
+ offset += 4;
376
+
377
+ // Attested Credential Data, if present
378
+ if (flags & 0x40) { // Checking the AT flag
379
+ // AAGUID (16 bytes)
380
+ const aaguid = authData.slice(offset, offset + 16);
381
+ offset += 16;
382
+
383
+ // Credential ID Length (2 bytes, big endian)
384
+ const credIdLength = authDataView.getUint16(offset, false);
385
+ offset += 2;
386
+
387
+ // Credential ID (variable length)
388
+ const credentialId = authData.slice(offset, offset + credIdLength);
389
+ offset += credIdLength;
390
+
391
+ // Credential Public Key - (77 bytes...I hope)
392
+ const credentialPublicKey = authData.slice(offset, offset + 77);
393
+ offset += 77;
394
+
395
+ // Any leftover bytes. I found some when using a YubiKey
396
+ const theRest = authData.slice(offset);
397
+
398
+ // Decode the credential public key to COSE
399
+ const publicKeyObject = new Map<string, any>(Object.entries(decode(credentialPublicKey)));
400
+
401
+ return {
402
+ rpIdHash,
403
+ flags,
404
+ signCount,
405
+ aaguid,
406
+ credIdLength,
407
+ credentialId,
408
+ credentialPublicKey,
409
+ theRest,
410
+ publicKeyObject
411
+ };
412
+ }
413
+
414
+ throw new Error("Attested credential data not present in the flags.");
415
+ }
416
+
417
+ private convertEcdsaSignatureAsnToCompact(sig: Buffer) {
418
+ // Define the order of the curve secp256k1
419
+ // https://github.com/RustCrypto/elliptic-curves/blob/master/p256/src/lib.rs#L72
420
+ const q = Buffer.from('ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551', 'hex')
421
+
422
+ // ASN Sequence
423
+ let offset = 0;
424
+ if (sig[offset] != 0x30) {
425
+ throw "signature is not a sequence";
426
+ }
427
+ offset += 1;
428
+
429
+ // ASN Sequence Byte Length
430
+ offset += 1;
431
+
432
+ // ASN Integer (R)
433
+ if (sig[offset] != 0x02) {
434
+ throw "first element in sequence is not an integer";
435
+ }
436
+ offset += 1;
437
+
438
+ // ASN Integer (R) Byte Length
439
+ const rLen = sig[offset];
440
+ offset += 1;
441
+
442
+ // ASN Integer (R) Byte Value
443
+ if (rLen >= 33) {
444
+ if (rLen != 33 || sig[offset] != 0x00) {
445
+ throw "can only handle larger than 32 byte R's that are len 33 and lead with zero";
446
+ }
447
+ offset += 1;
448
+ }
449
+ const r = sig.slice(offset, offset + 32);
450
+ offset += 32;
451
+
452
+ // ASN Integer (S)
453
+ if (sig[offset] != 0x02) {
454
+ throw "second element in sequence is not an integer";
455
+ }
456
+ offset += 1;
457
+
458
+ // ASN Integer (S) Byte Length
459
+ const sLen = sig[offset];
460
+ offset += 1;
461
+
462
+ // ASN Integer (S) Byte Value
463
+ if (sLen >= 33) {
464
+ if (sLen != 33 || sig[offset] != 0x00) {
465
+ throw "can only handle larger than 32 byte R's that are len 33 and lead with zero";
466
+ }
467
+ offset += 1;
468
+ }
469
+
470
+ const s = sig.slice(offset, offset + 32);
471
+
472
+ offset += 32;
473
+
474
+ let signature64: Buffer
475
+
476
+ // Force low S range
477
+ // https://github.com/stellar/stellar-protocol/discussions/1435#discussioncomment-8809175
478
+ // https://discord.com/channels/897514728459468821/1233048618571927693
479
+ if (bufToBigint(s) > ((bufToBigint(q) - BigInt(1)) / BigInt(2))) {
480
+ signature64 = Buffer.from([...r, ...Buffer.from(bigintToBuf(bufToBigint(q) - bufToBigint(s), true) as ArrayBuffer)]);
481
+ } else {
482
+ signature64 = Buffer.from([...r, ...s]);
483
+ }
484
+
485
+ return signature64;
486
+ }
487
+ }
package/tsconfig.json CHANGED
@@ -26,5 +26,5 @@
26
26
  "noPropertyAccessFromIndexSignature": false,
27
27
  "strictNullChecks": true,
28
28
  },
29
- "exclude": ["./demo", "./packages", "./types"]
29
+ "exclude": ["./demo", "./packages", "./types", "./contracts"]
30
30
  }
@@ -0,0 +1,15 @@
1
+ import { Networks, Transaction, Horizon } from '@stellar/stellar-sdk';
2
+ export declare class PasskeyBase {
3
+ networkPassphrase: Networks;
4
+ horizonUrl: string;
5
+ horizon: Horizon.Server;
6
+ feeBumpUrl: string | undefined;
7
+ feeBumpJwt: string | undefined;
8
+ constructor(options: {
9
+ networkPassphrase: Networks;
10
+ horizonUrl: string;
11
+ feeBumpUrl?: string;
12
+ feeBumpJwt?: string;
13
+ });
14
+ send(txn: Transaction, fee?: number): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
15
+ }
package/types/index.d.ts CHANGED
@@ -1,49 +1,3 @@
1
- import { Client as PasskeyClient } from 'passkey-kit-sdk';
2
- import { Client as FactoryClient } from 'passkey-factory-sdk';
3
- import { Networks, Transaction, Horizon, SorobanRpc } from '@stellar/stellar-sdk';
4
- import { Buffer } from 'buffer';
5
- export declare class PasskeyAccount {
6
- keyId: string | undefined;
7
- sudoKeyId: string | undefined;
8
- wallet: PasskeyClient | undefined;
9
- factory: FactoryClient;
10
- sequencePublicKey: string;
11
- networkPassphrase: Networks;
12
- horizonUrl: string;
13
- horizon: Horizon.Server;
14
- rpcUrl: string;
15
- rpc: SorobanRpc.Server;
16
- feeBumpUrl: string;
17
- feeBumpJwt: string;
18
- factoryContractId: string;
19
- constructor(options: {
20
- sequencePublicKey: string;
21
- networkPassphrase: Networks;
22
- horizonUrl: string;
23
- rpcUrl: string;
24
- feeBumpUrl: string;
25
- feeBumpJwt: string;
26
- factoryContractId?: string;
27
- });
28
- createWallet(name: string, user: string): Promise<{
29
- keyId: Buffer;
30
- contractId: string;
31
- xdr: string;
32
- }>;
33
- createKey(name: string, user: string): Promise<{
34
- keyId: Buffer;
35
- publicKey: Buffer;
36
- }>;
37
- connectWallet(id?: string): Promise<{
38
- keyId: Buffer;
39
- contractId: string;
40
- }>;
41
- sign(txn: Transaction | string, options?: {
42
- keyId?: 'any' | 'sudo' | string | Uint8Array;
43
- ledgersToLive?: number;
44
- }): Promise<string>;
45
- send(txn: Transaction, fee?: number): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
46
- getData(): Promise<Map<string, any>>;
47
- private getPublicKeyObject;
48
- private convertEcdsaSignatureAsnToCompact;
49
- }
1
+ import { PasskeyBase } from "./base";
2
+ import { PasskeyKit } from "./kit";
3
+ export { PasskeyBase, PasskeyKit };
package/types/kit.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { Client as PasskeyClient } from 'passkey-kit-sdk';
2
+ import { Client as FactoryClient } from 'passkey-factory-sdk';
3
+ import { Networks, Transaction, Horizon, SorobanRpc } from '@stellar/stellar-sdk';
4
+ import { Buffer } from 'buffer';
5
+ import { PasskeyBase } from './base';
6
+ export declare class PasskeyKit extends PasskeyBase {
7
+ keyId: string | undefined;
8
+ sudoKeyId: string | undefined;
9
+ wallet: PasskeyClient | undefined;
10
+ factory: FactoryClient;
11
+ sequencePublicKey: string;
12
+ horizon: Horizon.Server;
13
+ rpcUrl: string;
14
+ rpc: SorobanRpc.Server;
15
+ factoryContractId: string;
16
+ constructor(options: {
17
+ sequencePublicKey: string;
18
+ networkPassphrase: Networks;
19
+ horizonUrl: string;
20
+ rpcUrl: string;
21
+ feeBumpUrl?: string;
22
+ feeBumpJwt?: string;
23
+ factoryContractId?: string;
24
+ });
25
+ createWallet(name: string, user: string): Promise<{
26
+ keyId: Buffer;
27
+ contractId: string;
28
+ xdr: string;
29
+ }>;
30
+ createKey(name: string, user: string): Promise<{
31
+ keyId: Buffer;
32
+ publicKey: Buffer;
33
+ }>;
34
+ connectWallet(id?: string): Promise<{
35
+ keyId: Buffer;
36
+ contractId: string;
37
+ }>;
38
+ sign(txn: Transaction | string, options?: {
39
+ keyId?: 'any' | 'sudo' | string | Uint8Array;
40
+ ledgersToLive?: number;
41
+ }): Promise<string>;
42
+ getData(): Promise<Map<string, any>>;
43
+ private getPublicKeyObject;
44
+ private convertEcdsaSignatureAsnToCompact;
45
+ }