@accesly/react 1.0.0-pre.1 → 1.0.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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Environment, CognitoConfig, AuthClient, SessionStorage, DeviceStore, TelemetrySink, TokenManager, AccesslyEndpoints, AuthStatus, CredentialRecord } from '@accesly/core';
1
+ import { Environment, CognitoConfig, AuthClient, SessionStorage, DeviceStore, TelemetrySink, TokenManager, AccesslyEndpoints, AuthStatus, CredentialRecord, EncryptedEnvelope } from '@accesly/core';
2
2
  import * as react from 'react';
3
3
  import { ReactNode } from 'react';
4
4
 
@@ -444,6 +444,88 @@ declare class NotImplementedYetError extends Error {
444
444
  * - Persiste new CredentialRecord local
445
445
  * - Zero-iza la seed
446
446
  */
447
+ interface ReconstructedSeed {
448
+ /** 32-byte ed25519 seed reconstruida vía Shamir(F2_recovery + F3). CALLER ZEROIZE. */
449
+ readonly privateSeed: Uint8Array;
450
+ /** 32-byte ed25519 public key derivada. */
451
+ readonly publicKey: Uint8Array;
452
+ /** 32-byte recoveryKey derivada del password — útil para re-cifrar F2'/F3' nuevos. */
453
+ readonly recoveryKey: Uint8Array;
454
+ /** Base64 32-byte salt que vino del backend. */
455
+ readonly recoverySalt: string;
456
+ }
457
+ /**
458
+ * Input para `recovery.finalize(...)` — orquestador end-to-end de la rotación
459
+ * de signers tras una recuperación por OTP.
460
+ *
461
+ * El caller debe pre-cumplir 2 pasos UI:
462
+ * a) `reconstructSeed(...)` — devolvió `privateSeed` (la VIEJA, va a firmar
463
+ * la auth entry) + `publicKey` (la VIEJA, no se rota acá).
464
+ * b) WebAuthn `navigator.credentials.create()` con PRF extension — devolvió
465
+ * `credentialId`, `secp256r1Pubkey`, y un output PRF que el caller
466
+ * derivó en 2 keys `newF1Key` + `newF2Key` (HKDF típicamente).
467
+ *
468
+ * El SDK hace todo lo demás client-side:
469
+ * 1. Genera fresh ed25519 seed (la NUEVA) + Shamir 2-of-3 split.
470
+ * 2. Cifra F1' con `newF1Key`, F2' con `newF2Key`, F3' con `recoveryKey`.
471
+ * 3. Cifra una segunda copia de F2' con `recoveryKey` (F2_recovery) para
472
+ * que la siguiente recovery siga siendo posible.
473
+ * 4. POST /recovery/simulate-rotate-signer → backend simula + devuelve material.
474
+ * 5. Firma la auth entry con la SEED VIEJA contra la regla `admin-cfg`.
475
+ * 6. POST /recovery/finalize con auth entry firmada + new fragments.
476
+ * 7. Persiste el nuevo `CredentialRecord` en `DeviceStore` (sustituye el viejo).
477
+ * 8. Zeroiza todas las llaves intermedias (la seed vieja la entrega el caller,
478
+ * su lifecycle es responsabilidad del caller).
479
+ */
480
+ interface FinalizeRecoveryInput {
481
+ /** Email del usuario (case-insensitive, se normaliza). */
482
+ readonly email: string;
483
+ /**
484
+ * Password de Cognito (UTF-8 bytes). El SDK la usa SOLO para derivar el
485
+ * `newRecoveryKey` con `PBKDF2(password, newRecoverySalt, 600k)`. Caller
486
+ * debe zeroizar tras la llamada.
487
+ */
488
+ readonly cognitoPassword: Uint8Array;
489
+ /** Token KMS-HMAC que devolvió `verifyOtp()` — TTL 5min. */
490
+ readonly recoveryJwt: string;
491
+ /**
492
+ * La seed VIEJA reconstruida por `reconstructSeed()`. Se usa SOLO para
493
+ * firmar la `SorobanAuthorizationEntry` de `rotate_signer` (la regla
494
+ * `admin-cfg` valida con el ed25519 pubkey actual del Smart Account, que
495
+ * es el del owner viejo). Tras firmar, el SDK la zeroiza internamente.
496
+ *
497
+ * IMPORTANTE: pasa `seedResult.privateSeed` tal cual te lo dio
498
+ * `reconstructSeed()`. Si ya lo zeroizaste, falla.
499
+ */
500
+ readonly oldReconstructedSeed: Uint8Array;
501
+ /**
502
+ * La pubkey VIEJA (32 bytes) — derivable de `oldReconstructedSeed`, pero
503
+ * la pedimos explícita para sanity-check y para empaquetar dentro del
504
+ * `Signer::External(verifier, pubkey)` del AuthPayload.
505
+ */
506
+ readonly oldOwnerPubkey: Uint8Array;
507
+ /** Credential ID del nuevo passkey (de `navigator.credentials.create`). */
508
+ readonly newCredentialId: Uint8Array;
509
+ /** Pubkey secp256r1 uncompressed (65 bytes) del nuevo passkey. */
510
+ readonly newSecp256r1Pubkey: Uint8Array;
511
+ /** Salt PRF del nuevo passkey (32 bytes random). */
512
+ readonly newPrfSalt: Uint8Array;
513
+ /** AES-256 key derivada del PRF output para cifrar F1'. */
514
+ readonly newF1Key: Uint8Array;
515
+ /** AES-256 key derivada del PRF output para cifrar F2' (PRF-bound, sign path). */
516
+ readonly newF2Key: Uint8Array;
517
+ /** Salt aleatorio (32 bytes) para el nuevo emailCommitment. */
518
+ readonly newEmailSalt: Uint8Array;
519
+ }
520
+ interface FinalizeRecoveryResult {
521
+ readonly walletAddress: string;
522
+ readonly txHash: string;
523
+ readonly status: string;
524
+ /** Pubkey ed25519 NUEVA (32 bytes). Útil para UI confirmación. */
525
+ readonly newPublicKey: Uint8Array;
526
+ /** Link al explorer. */
527
+ readonly explorerUrl: string;
528
+ }
447
529
  interface RecoveryNamespace {
448
530
  /** Pide OTP. Backend rate-limita; el caller debe respetar `cooldownSeconds`. */
449
531
  requestOtp(input: {
@@ -461,19 +543,42 @@ interface RecoveryNamespace {
461
543
  expiresAt: number;
462
544
  }>;
463
545
  /**
464
- * Cierra el flujo de recovery. Tras éxito el `walletAddress` rotó sus
465
- * signers on-chain y el dispositivo nuevo tiene los fragmentos
466
- * persistidos. Pasar el password de Cognito en plano (UTF-8).
546
+ * Descarga `/fragments/3`, descifra F2_recovery + F3 con la `recoveryKey`
547
+ * derivada del password y reconstruye la seed via Shamir.
548
+ *
549
+ * El caller DEBE zero-izar `result.privateSeed` y `result.recoveryKey`
550
+ * tras firmar la rotación + cifrar las nuevas F1'/F2'/F3'.
467
551
  *
468
- * El caller es responsable de zeroizar `cognitoPassword` después.
552
+ * El caller también es responsable de zeroizar `cognitoPassword` después.
469
553
  */
470
- finalize(input: {
471
- email: string;
554
+ reconstructSeed(input: {
472
555
  cognitoPassword: Uint8Array;
473
556
  recoveryJwt: string;
557
+ }): Promise<ReconstructedSeed>;
558
+ /**
559
+ * Orquestador completo de la rotación de signers para Recovery v2.
560
+ * Ver `FinalizeRecoveryInput` para los pre-requisitos.
561
+ */
562
+ finalize(input: FinalizeRecoveryInput): Promise<FinalizeRecoveryResult>;
563
+ /**
564
+ * Bajo nivel: submitea la rotación al backend tras que el caller haya
565
+ * armado el body manualmente. `finalize(...)` es el wrapper recomendado.
566
+ */
567
+ submitFinalize(input: {
568
+ recoveryJwt: string;
569
+ unsignedXdr: string;
570
+ signedAuthEntryXdr: string;
571
+ newSecp256r1Pubkey: string;
572
+ newFragmentF1Encrypted: EncryptedEnvelope;
573
+ newFragmentF2Encrypted: EncryptedEnvelope;
574
+ newFragmentF2Recovery: EncryptedEnvelope;
575
+ newFragmentF3Encrypted: EncryptedEnvelope;
576
+ newRecoverySalt: string;
577
+ newEmailCommitment: string;
474
578
  }): Promise<{
475
579
  walletAddress: string;
476
580
  txHash: string;
581
+ status: string;
477
582
  }>;
478
583
  }
479
584
  interface AcceslyHook {
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CognitoAuthClient, InMemorySessionStorage, InMemoryDeviceStore, TokenManager, AccesslyApiClient, AccesslyEndpoints, normalizeSecp256r1Pubkey, createWallet, generateRecoverySalt, decryptAesGcm, deriveRecoveryKey, encryptAesGcm, emailHashBytes, computeSmartAccountAddress, signTransaction, generateX25519Keypair, unwrapSessionFragment2, reconstructFromPlainAndEncrypted, signSorobanAuthEntry, AccesslyApiError } from '@accesly/core';
1
+ import { CognitoAuthClient, InMemorySessionStorage, InMemoryDeviceStore, TokenManager, AccesslyApiClient, AccesslyEndpoints, normalizeSecp256r1Pubkey, createWallet, generateRecoverySalt, decryptAesGcm, deriveRecoveryKey, encryptAesGcm, emailHashBytes, computeSmartAccountAddress, signTransaction, generateX25519Keypair, unwrapSessionFragment2, reconstructFromPlainAndEncrypted, signSorobanAuthEntry, reconstructKey, AccesslyApiError } from '@accesly/core';
2
2
  import { createContext, useMemo, useState, useRef, useEffect, useContext } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
@@ -190,6 +190,7 @@ function useAccesly() {
190
190
  secp256r1Pubkey: hexFromBytes(params.secp256r1Pubkey),
191
191
  fragmentF2: encodeFragmentToWire(params.fragmentF2),
192
192
  fragmentF3: encodeFragmentToWire(params.fragmentF3),
193
+ ...params.fragmentF2Recovery ? { fragmentF2Recovery: encodeFragmentToWire(params.fragmentF2Recovery) } : {},
193
194
  ...params.emailHash ? { emailHash: params.emailHash } : {},
194
195
  ...params.recoverySalt ? { recoverySalt: params.recoverySalt } : {}
195
196
  });
@@ -273,18 +274,22 @@ function useAccesly() {
273
274
  encryptionKeys: input.encryptionKeys
274
275
  });
275
276
  let fragmentF3ToSend = created.encryptedFragments[2];
277
+ let fragmentF2Recovery;
276
278
  let recoverySaltBase64;
277
279
  if (input.cognitoPassword) {
278
280
  const recoverySalt = generateRecoverySalt();
281
+ const f2Plain = decryptAesGcm(created.encryptedFragments[1], input.encryptionKeys[1]);
279
282
  const f3Plain = decryptAesGcm(created.encryptedFragments[2], input.encryptionKeys[2]);
280
283
  const recoveryKey = deriveRecoveryKey({
281
284
  password: input.cognitoPassword,
282
285
  salt: recoverySalt
283
286
  });
284
287
  try {
288
+ fragmentF2Recovery = encryptAesGcm(f2Plain, recoveryKey);
285
289
  fragmentF3ToSend = encryptAesGcm(f3Plain, recoveryKey);
286
290
  } finally {
287
291
  for (let i = 0; i < recoveryKey.length; i += 1) recoveryKey[i] = 0;
292
+ for (let i = 0; i < f2Plain.length; i += 1) f2Plain[i] = 0;
288
293
  for (let i = 0; i < f3Plain.length; i += 1) f3Plain[i] = 0;
289
294
  }
290
295
  recoverySaltBase64 = base64FromBytes(recoverySalt);
@@ -324,6 +329,7 @@ function useAccesly() {
324
329
  fragmentF2: created.encryptedFragments[1],
325
330
  fragmentF3: fragmentF3ToSend,
326
331
  emailHash: emailHashHex,
332
+ ...fragmentF2Recovery ? { fragmentF2Recovery } : {},
327
333
  ...recoverySaltBase64 ? { recoverySalt: recoverySaltBase64 } : {}
328
334
  });
329
335
  } catch (err) {
@@ -533,11 +539,147 @@ function useAccesly() {
533
539
  async verifyOtp(input) {
534
540
  return ctx.endpoints.verifyRecoveryOtp(input);
535
541
  },
536
- async finalize(_input) {
537
- throw new NotImplementedYetError("recovery", "finalize");
542
+ async reconstructSeed(input) {
543
+ const frag = await ctx.endpoints.getFragment3(input.recoveryJwt);
544
+ if (!frag.fragmentF2Recovery) {
545
+ throw new Error(
546
+ "recovery.reconstructSeed: la wallet fue creada antes de Fase 1 y no tiene F2 cipher-bound a recoveryKey. No es recuperable v\xEDa OTP."
547
+ );
548
+ }
549
+ const recoverySalt = base64ToBytes(frag.recoverySalt);
550
+ const recoveryKey = deriveRecoveryKey({
551
+ password: input.cognitoPassword,
552
+ salt: recoverySalt
553
+ });
554
+ const f2Envelope = {
555
+ ciphertext: base64ToBytes(frag.fragmentF2Recovery.ciphertext),
556
+ nonce: base64ToBytes(frag.fragmentF2Recovery.nonce)
557
+ };
558
+ const f3Envelope = {
559
+ ciphertext: base64ToBytes(frag.fragmentF3Encrypted.ciphertext),
560
+ nonce: base64ToBytes(frag.fragmentF3Encrypted.nonce)
561
+ };
562
+ const seedResult = reconstructKey({
563
+ fragments: [
564
+ { envelope: f2Envelope, key: recoveryKey },
565
+ { envelope: f3Envelope, key: recoveryKey }
566
+ ]
567
+ });
568
+ return {
569
+ privateSeed: seedResult.privateSeed,
570
+ publicKey: seedResult.publicKey,
571
+ recoveryKey,
572
+ recoverySalt: frag.recoverySalt
573
+ };
574
+ },
575
+ async finalize(input) {
576
+ const networkPassphrase = stellarConfig.networkPassphrase;
577
+ const verifierAddress = stellarConfig.ed25519VerifierAddress;
578
+ const explorerBase = networkPassphrase === "Public Global Stellar Network ; September 2015" ? "https://stellar.expert/explorer/public/tx/" : "https://stellar.expert/explorer/testnet/tx/";
579
+ if (input.oldReconstructedSeed.length !== 32) {
580
+ throw new Error(
581
+ `recovery.finalize: oldReconstructedSeed must be 32 bytes, got ${input.oldReconstructedSeed.length}`
582
+ );
583
+ }
584
+ if (input.oldOwnerPubkey.length !== 32) {
585
+ throw new Error(
586
+ `recovery.finalize: oldOwnerPubkey must be 32 bytes, got ${input.oldOwnerPubkey.length}`
587
+ );
588
+ }
589
+ if (input.newSecp256r1Pubkey.length !== 65) {
590
+ throw new Error(
591
+ `recovery.finalize: newSecp256r1Pubkey must be 65 bytes (uncompressed), got ${input.newSecp256r1Pubkey.length}`
592
+ );
593
+ }
594
+ const newRecoverySalt = generateRecoverySalt();
595
+ const newRecoveryKey = deriveRecoveryKey({
596
+ password: input.cognitoPassword,
597
+ salt: newRecoverySalt
598
+ });
599
+ const newRecoverySaltBase64 = base64FromBytes(newRecoverySalt);
600
+ const created = createWallet({
601
+ emailBytes: new TextEncoder().encode(input.email),
602
+ emailSalt: input.newEmailSalt,
603
+ encryptionKeys: [input.newF1Key, input.newF2Key, newRecoveryKey]
604
+ });
605
+ const f2PlainShare = decryptAesGcm(created.encryptedFragments[1], input.newF2Key);
606
+ let newFragmentF2Recovery;
607
+ try {
608
+ newFragmentF2Recovery = encryptAesGcm(f2PlainShare, newRecoveryKey);
609
+ } finally {
610
+ for (let i = 0; i < f2PlainShare.length; i += 1) f2PlainShare[i] = 0;
611
+ }
612
+ const newSecp256r1Canonical = normalizeSecp256r1Pubkey(input.newSecp256r1Pubkey);
613
+ const newOwnerHex = hexFromBytes(created.publicKey);
614
+ const newSecpHex = hexFromBytes(newSecp256r1Canonical);
615
+ const newEmailCommitHex = hexFromBytes(created.emailCommitment);
616
+ const sim = await ctx.endpoints.simulateRotateSigner(input.recoveryJwt, {
617
+ newOwnerEd25519Pubkey: newOwnerHex,
618
+ newSecp256r1Pubkey: newSecpHex,
619
+ newEmailCommitment: newEmailCommitHex
620
+ });
621
+ const { signedAuthEntryXdr } = await signSorobanAuthEntry({
622
+ signaturePayloadHashBase64: sim.signaturePayloadHashBase64,
623
+ contextRuleIds: [...sim.contextRuleIds],
624
+ placeholderAuthEntryXdr: sim.placeholderAuthEntryXdr,
625
+ ed25519Seed: input.oldReconstructedSeed,
626
+ ed25519VerifierAddress: verifierAddress,
627
+ ownerPubkey: input.oldOwnerPubkey
628
+ });
629
+ let finalize;
630
+ try {
631
+ finalize = await ctx.endpoints.finalizeRecovery(input.recoveryJwt, {
632
+ unsignedXdr: sim.unsignedXdr,
633
+ signedAuthEntryXdr,
634
+ newSecp256r1Pubkey: newSecpHex,
635
+ newFragmentF1Encrypted: encodeFragmentToWire(created.encryptedFragments[0]),
636
+ newFragmentF2Encrypted: encodeFragmentToWire(created.encryptedFragments[1]),
637
+ newFragmentF2Recovery: encodeFragmentToWire(newFragmentF2Recovery),
638
+ newFragmentF3Encrypted: encodeFragmentToWire(created.encryptedFragments[2]),
639
+ newRecoverySalt: newRecoverySaltBase64,
640
+ newEmailCommitment: newEmailCommitHex
641
+ });
642
+ } finally {
643
+ for (let i = 0; i < newRecoveryKey.length; i += 1) newRecoveryKey[i] = 0;
644
+ }
645
+ await ctx.deviceStore.saveCredential({
646
+ username: input.email,
647
+ credentialId: input.newCredentialId,
648
+ secp256r1Pubkey: newSecp256r1Canonical,
649
+ fragmentF1Encrypted: created.encryptedFragments[0],
650
+ fragmentF2Encrypted: created.encryptedFragments[1],
651
+ fragmentF3Encrypted: created.encryptedFragments[2],
652
+ publicKey: created.publicKey,
653
+ emailCommitment: created.emailCommitment,
654
+ prfSalt: input.newPrfSalt,
655
+ fallbackKeyMaterial: new Uint8Array(0),
656
+ walletAddress: finalize.walletAddress,
657
+ onChain: true,
658
+ createdAt: Date.now()
659
+ });
660
+ return {
661
+ walletAddress: finalize.walletAddress,
662
+ txHash: finalize.txHash,
663
+ status: finalize.status,
664
+ newPublicKey: created.publicKey,
665
+ explorerUrl: `${explorerBase}${finalize.txHash}`
666
+ };
667
+ },
668
+ async submitFinalize(input) {
669
+ return ctx.endpoints.finalizeRecovery(input.recoveryJwt, {
670
+ unsignedXdr: input.unsignedXdr,
671
+ signedAuthEntryXdr: input.signedAuthEntryXdr,
672
+ newSecp256r1Pubkey: input.newSecp256r1Pubkey,
673
+ newFragmentF1Encrypted: encodeFragmentToWire(input.newFragmentF1Encrypted),
674
+ newFragmentF2Encrypted: encodeFragmentToWire(input.newFragmentF2Encrypted),
675
+ newFragmentF2Recovery: encodeFragmentToWire(input.newFragmentF2Recovery),
676
+ newFragmentF3Encrypted: encodeFragmentToWire(input.newFragmentF3Encrypted),
677
+ newRecoverySalt: input.newRecoverySalt,
678
+ newEmailCommitment: input.newEmailCommitment
679
+ });
538
680
  }
539
681
  }),
540
- [ctx]
682
+ [ctx, stellarConfig, hexFromBytes]
541
683
  );
542
684
  const session = useMemo(
543
685
  () => ({