ns-auth-sdk 1.9.1 → 1.11.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/README.md CHANGED
@@ -126,36 +126,72 @@ const signedEvent = await authService.signEvent(event);
126
126
  #### Methods
127
127
 
128
128
  - `createPasskey(username?: string): Promise<Uint8Array>` - Create a new passkey
129
- - `createKey(credentialId?: Uint8Array, password?: string): Promise<KeyInfo>` - Create key from passkey (auto-detects PRF support, uses password fallback if needed)
129
+ - `createKey(credentialId?: Uint8Array, password?: string, options?: KeyOptions): Promise<KeyInfo>` - Create key from passkey (auto-detects PRF support, uses password fallback if needed)
130
130
  - `getPublicKey(): Promise<string>` - Get current public key
131
- - `signEvent(event: Event, options?: SignOptions): Promise<Event>` - Sign an event (password optional - only needed first time before key is cached)
131
+ - `signEvent(event: Event): Promise<Event>` - Sign an event
132
132
  - `getCurrentKeyInfo(): KeyInfo | null` - Get current key info
133
133
  - `setCurrentKeyInfo(keyInfo: KeyInfo): void` - Set current key info
134
134
  - `hasKeyInfo(): boolean` - Check if key info exists
135
135
  - `clearStoredKeyInfo(): void` - Clear stored key info
136
- - `checkPRFSupport(): Promise<boolean>` - Check if PRF is supported (returns false if password fallback needed)
136
+ - `checkPRFSupport(): Promise<boolean>` - Check if PRF is supported
137
137
 
138
- #### Types
138
+ #### Recovery Methods
139
+
140
+ - `addPasswordRecovery(password: string): Promise<KeyInfo>` - Add password recovery to an existing PRF key
141
+ - `activateWithPassword(password: string, newCredentialId: Uint8Array): Promise<KeyInfo>` - Recover using password with a new passkey credential ID from a new device
142
+ - `getRecoveryForKind0(): RecoveryData | null` - Get recovery data for publishing to kind-0
143
+
144
+ #### KeyOptions
139
145
 
140
146
  ```typescript
141
- // Password-protected key bundle (used when PRF unavailable)
142
- interface PasswordProtectedBundle {
143
- publicKeySpkiBase64: string;
144
- wrappedPrivateKeyBase64: string;
145
- saltBase64: string;
146
- ivBase64: string;
147
+ interface KeyOptions {
148
+ username?: string;
149
+ password?: string;
150
+ recoveryPassword?: string; // Password for recovery - enables recovery on new device
147
151
  }
152
+ ```
148
153
 
149
- // KeyInfo with optional password fallback
154
+ #### RecoveryData
155
+
156
+ ```typescript
157
+ interface RecoveryData {
158
+ recoveryPubkey: string;
159
+ recoverySalt: string;
160
+ createdAt?: number;
161
+ }
162
+ ```
163
+
164
+ #### Types
165
+
166
+ ```typescript
167
+ // KeyInfo with optional recovery
150
168
  interface KeyInfo {
151
169
  credentialId: string;
152
170
  pubkey: string;
153
171
  salt: string;
154
172
  username?: string;
155
- passwordProtectedBundle?: PasswordProtectedBundle;
173
+ recovery?: KeyRecovery;
156
174
  }
157
175
 
158
- // Sign options with password support
176
+ // Key recovery configuration
177
+ interface KeyRecovery {
178
+ recoveryPubkey: string;
179
+ recoverySalt: string;
180
+ createdAt?: number;
181
+ }
182
+ interface KeyRecovery {
183
+ recoveryPubkey: string;
184
+ recoverySalt: string;
185
+ nextSalt?: string;
186
+ nextRecoverySalt?: string;
187
+ nextPubkey?: string;
188
+ nextCredentialId?: string;
189
+ rotatedAt?: number;
190
+ rotatedBy?: 'passkey' | 'password';
191
+ activatedAt?: number;
192
+ }
193
+
194
+ // Sign options
159
195
  interface SignOptions {
160
196
  clearMemory?: boolean;
161
197
  tags?: string[][];
@@ -163,6 +199,31 @@ interface SignOptions {
163
199
  }
164
200
  ```
165
201
 
202
+ ### Recovery Flow
203
+
204
+ The SDK supports password-based recovery for passkey-protected keys. When creating a key, you can optionally provide a recovery password:
205
+
206
+ ```typescript
207
+ // Create key with recovery enabled
208
+ const keyInfo = await authService.createKey(credentialId, undefined, {
209
+ username: 'user@example.com',
210
+ recoveryPassword: 'my-recovery-password',
211
+ });
212
+
213
+ // The recovery data is stored in kind-0 tags:
214
+ // ["r", recoveryPubkey, recoverySalt, createdAt]
215
+ ```
216
+
217
+ **Recovery on a new device:**
218
+
219
+ ```typescript
220
+ // On new device - create new passkey first
221
+ const newCredentialId = await authService.createPasskey('user@example.com');
222
+
223
+ // Recover using password
224
+ const keyInfo = await authService.activateWithPassword('my-recovery-password', newCredentialId);
225
+ ```
226
+
166
227
  #### Configuration Options
167
228
 
168
229
  ```typescript
package/dist/index.cjs CHANGED
@@ -245,6 +245,15 @@ async function getPrfSecret(credentialId, options) {
245
245
 
246
246
  //#endregion
247
247
  //#region src/utils/prf-password-fallback.ts
248
+ /**
249
+ * PRF support check with a password-protected-key fallback.
250
+ * If the PRF WebAuthn extension is unavailable, callers can fall back to a
251
+ * password-protected private key. The private key is wrapped with a password-
252
+ * derived AES-GCM key and stored alongside the public key (SPKI).
253
+ *
254
+ * This is a minimal, browser-oriented implementation designed to provide a
255
+ * practical alternative while keeping crypto surface area focused and safe.
256
+ */
248
257
  function toBase64(bytes) {
249
258
  const arr = new Uint8Array(bytes);
250
259
  let binary = "";
@@ -358,6 +367,23 @@ async function importPublicKeyFromBundle(bundle) {
358
367
  namedCurve: "P-256"
359
368
  }, true, ["verify"]);
360
369
  }
370
+ const DEFAULT_SALT = "nostr-key-derivation";
371
+ async function deriveNostrPrivateKey(password, salt = DEFAULT_SALT) {
372
+ const cryptoObj = typeof window !== "undefined" && window.crypto || globalThis.crypto;
373
+ if (!cryptoObj || !cryptoObj.subtle) throw new Error("Web Crypto API not available");
374
+ const encoder = new TextEncoder();
375
+ const passwordKey = await cryptoObj.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
376
+ const derivedBits = await cryptoObj.subtle.deriveBits({
377
+ name: "PBKDF2",
378
+ salt: encoder.encode(salt),
379
+ iterations: 1e5,
380
+ hash: "SHA-256"
381
+ }, passwordKey, 256);
382
+ return new Uint8Array(derivedBits);
383
+ }
384
+ async function getPublicKeyFromPassword(password, salt = DEFAULT_SALT) {
385
+ return (0, applesauce_core_helpers.getPublicKey)(await deriveNostrPrivateKey(password, salt));
386
+ }
361
387
 
362
388
  //#endregion
363
389
  //#region src/utils/nosskey.ts
@@ -366,6 +392,24 @@ async function importPublicKeyFromBundle(bundle) {
366
392
  * @packageDocumentation
367
393
  */
368
394
  const STANDARD_SALT = "6e6f7374722d6b6579";
395
+ function parseRecoveryTag(tags) {
396
+ const recoveryTag = tags.find((tag) => tag[0] === "r");
397
+ if (!recoveryTag || recoveryTag.length < 3) return null;
398
+ const createdAt = recoveryTag[3] ? parseInt(recoveryTag[3], 10) : void 0;
399
+ return {
400
+ recoveryPubkey: recoveryTag[1],
401
+ recoverySalt: recoveryTag[2],
402
+ createdAt: createdAt || void 0
403
+ };
404
+ }
405
+ function createRecoveryTag(recovery) {
406
+ return [
407
+ "r",
408
+ recovery.recoveryPubkey,
409
+ recovery.recoverySalt,
410
+ recovery.createdAt?.toString() || ""
411
+ ];
412
+ }
369
413
  /**
370
414
  * Nosskey - Passkey-Derived Nostr Keys
371
415
  */
@@ -550,11 +594,16 @@ var NosskeyManager = class {
550
594
  * @param options
551
595
  */
552
596
  async createKey(credentialId, password, options = {}) {
553
- if (await this.checkPRFSupport()) return this.createPrfNostrKey(credentialId, options);
554
- else {
597
+ const prfSupported = await this.checkPRFSupport();
598
+ let keyInfo;
599
+ if (prfSupported) {
600
+ keyInfo = await this.createPrfNostrKey(credentialId, options);
601
+ if (options.recoveryPassword) keyInfo = await this.addPasswordRecovery(options.recoveryPassword, credentialId);
602
+ } else {
555
603
  if (!password) throw new Error("Password is required when PRF is not supported");
556
- return this.createPasswordProtectedNostrKey(password, options);
604
+ keyInfo = await this.createPasswordProtectedNostrKey(password, options);
557
605
  }
606
+ return keyInfo;
558
607
  }
559
608
  /**
560
609
  * Create Nostr key using PRF (standard passkey flow)
@@ -574,24 +623,18 @@ var NosskeyManager = class {
574
623
  return keyInfo;
575
624
  }
576
625
  /**
577
- * Create Nostr key using password-protected key (fallback when PRF unavailable)
578
- * @param password Password to encrypt the private key
626
+ * Create Nostr key using password-derived key (fallback when PRF unavailable)
627
+ * @param password Password to derive the private key
579
628
  * @param options
580
629
  */
581
630
  async createPasswordProtectedNostrKey(password, options = {}) {
582
- const bundle = await generatePasswordProtectedKey(password);
583
- const publicKey = await importPublicKeyFromBundle(bundle);
584
- const publicKeyHex = bytesToHex(await (typeof window !== "undefined" ? window.crypto : globalThis.crypto).subtle.exportKey("spki", publicKey));
585
- const keyInfo = {
631
+ const pubkey = await getPublicKeyFromPassword(password, STANDARD_SALT);
632
+ return {
586
633
  credentialId: bytesToHex((typeof window !== "undefined" ? window.crypto : globalThis.crypto).getRandomValues(new Uint8Array(16))),
587
- pubkey: publicKeyHex,
634
+ pubkey,
588
635
  salt: STANDARD_SALT,
589
- passwordProtectedBundle: bundle,
590
636
  ...options.username && { username: options.username }
591
637
  };
592
- const sk = await unwrapPasswordProtectedPrivateKey(bundle, password);
593
- if (this.#keyCache.isEnabled() && this.#keyCache.getCacheOptions().cacheOnCreation) this.#keyCache.setKey(keyInfo.credentialId, sk);
594
- return keyInfo;
595
638
  }
596
639
  /**
597
640
  * @param event Nostr
@@ -601,9 +644,16 @@ var NosskeyManager = class {
601
644
  async signEventWithKeyInfo(event, keyInfo, options = {}) {
602
645
  const { clearMemory = true, tags, password } = options;
603
646
  const shouldUseCache = this.#keyCache.isEnabled();
604
- const isPasswordProtected = !!keyInfo.passwordProtectedBundle;
647
+ const isPasswordDerived = !keyInfo.passwordProtectedBundle && keyInfo.salt;
605
648
  let sk;
606
- if (isPasswordProtected) {
649
+ if (isPasswordDerived) {
650
+ if (shouldUseCache) sk = this.#keyCache.getKey(keyInfo.credentialId);
651
+ if (!sk && password) {
652
+ sk = await deriveNostrPrivateKey(password, keyInfo.salt);
653
+ if (shouldUseCache) this.#keyCache.setKey(keyInfo.credentialId, sk);
654
+ }
655
+ if (!sk) throw new Error("Password required - key not in cache. Provide password to sign.");
656
+ } else if (keyInfo.passwordProtectedBundle) {
607
657
  if (shouldUseCache) sk = this.#keyCache.getKey(keyInfo.credentialId);
608
658
  if (!sk && password) {
609
659
  sk = await unwrapPasswordProtectedPrivateKey(keyInfo.passwordProtectedBundle, password);
@@ -638,6 +688,10 @@ var NosskeyManager = class {
638
688
  if (!options.password) throw new Error("Password is required for password-protected keys");
639
689
  return bytesToHex(await unwrapPasswordProtectedPrivateKey(keyInfo.passwordProtectedBundle, options.password));
640
690
  }
691
+ if (!keyInfo.passwordProtectedBundle && keyInfo.salt) {
692
+ if (!options.password) throw new Error("Password is required for password-derived keys");
693
+ return bytesToHex(await deriveNostrPrivateKey(options.password, keyInfo.salt));
694
+ }
641
695
  let usedCredentialId = credentialId;
642
696
  if (!usedCredentialId && keyInfo.credentialId) usedCredentialId = hexToBytes(keyInfo.credentialId);
643
697
  const { secret: sk } = await getPrfSecret(usedCredentialId, this.#prfOptions);
@@ -650,6 +704,73 @@ var NosskeyManager = class {
650
704
  return isPrfSupported();
651
705
  }
652
706
  /**
707
+ * Add password recovery to an existing PRF key
708
+ * @param password Password for recovery key
709
+ * @param currentCredentialId Current passkey credential ID
710
+ */
711
+ async addPasswordRecovery(password, currentCredentialId) {
712
+ const keyInfo = this.getCurrentKeyInfo();
713
+ if (!keyInfo) throw new Error("No current KeyInfo set");
714
+ if (keyInfo.passwordProtectedBundle) throw new Error("Password recovery already exists for password-derived key");
715
+ if (keyInfo.recovery) throw new Error("Recovery already configured");
716
+ if (!(currentCredentialId || (keyInfo.credentialId ? hexToBytes(keyInfo.credentialId) : void 0))) throw new Error("Credential ID required");
717
+ const recoverySalt = bytesToHex((typeof window !== "undefined" ? window.crypto : globalThis.crypto).getRandomValues(new Uint8Array(16)));
718
+ const recoveryPubkey = await getPublicKeyFromPassword(password, recoverySalt);
719
+ const updatedKeyInfo = {
720
+ ...keyInfo,
721
+ recovery: {
722
+ recoveryPubkey,
723
+ recoverySalt,
724
+ createdAt: Date.now()
725
+ }
726
+ };
727
+ this.setCurrentKeyInfo(updatedKeyInfo);
728
+ return updatedKeyInfo;
729
+ }
730
+ /**
731
+ * Activate recovery using password
732
+ * Requires new credential ID from new device
733
+ * @param password Password for recovery key
734
+ * @param newCredentialId New passkey credential ID (required)
735
+ */
736
+ async activateWithPassword(password, newCredentialId) {
737
+ const keyInfo = this.getCurrentKeyInfo();
738
+ if (!keyInfo) throw new Error("No current KeyInfo set");
739
+ if (!keyInfo.recovery) throw new Error("No recovery key configured");
740
+ if (!newCredentialId) throw new Error("New credential ID is required for recovery");
741
+ const { recoveryPubkey, recoverySalt } = keyInfo.recovery;
742
+ if (await getPublicKeyFromPassword(password, recoverySalt) !== recoveryPubkey) throw new Error("Invalid recovery password");
743
+ const { secret: newSk } = await getPrfSecret(newCredentialId, this.#prfOptions);
744
+ const newPubkey = (0, applesauce_core_helpers.getPublicKey)(newSk);
745
+ this.#clearKey(newSk);
746
+ const newRecoverySalt = bytesToHex((typeof window !== "undefined" ? window.crypto : globalThis.crypto).getRandomValues(new Uint8Array(16)));
747
+ const newRecoveryPubkey = await getPublicKeyFromPassword(password, newRecoverySalt);
748
+ const updatedKeyInfo = {
749
+ credentialId: bytesToHex(newCredentialId),
750
+ pubkey: newPubkey,
751
+ salt: keyInfo.salt,
752
+ recovery: {
753
+ recoveryPubkey: newRecoveryPubkey,
754
+ recoverySalt: newRecoverySalt,
755
+ createdAt: Date.now()
756
+ }
757
+ };
758
+ this.setCurrentKeyInfo(updatedKeyInfo);
759
+ return updatedKeyInfo;
760
+ }
761
+ /**
762
+ * Get the recovery data for publishing to kind-0
763
+ */
764
+ getRecoveryForKind0() {
765
+ const keyInfo = this.getCurrentKeyInfo();
766
+ if (!keyInfo || !keyInfo.recovery) return null;
767
+ return {
768
+ recoveryPubkey: keyInfo.recovery.recoveryPubkey,
769
+ recoverySalt: keyInfo.recovery.recoverySalt,
770
+ createdAt: keyInfo.recovery.createdAt
771
+ };
772
+ }
773
+ /**
653
774
  * @param key
654
775
  */
655
776
  #clearKey(key) {
@@ -821,9 +942,13 @@ var AuthService = class {
821
942
  /**
822
943
  * Create a new Nostr key from a credential ID
823
944
  * Automatically uses password fallback if PRF is not supported
945
+ * @param credentialId Passkey credential ID
946
+ * @param password Password (required if PRF not supported)
947
+ * @param options.username Username for the key
948
+ * @param options.recoveryPassword Password for recovery (enables recovery on new device)
824
949
  */
825
- async createKey(credentialId, password) {
826
- return await this.getManager().createKey(credentialId, password);
950
+ async createKey(credentialId, password, options) {
951
+ return await this.getManager().createKey(credentialId, password, options);
827
952
  }
828
953
  /**
829
954
  * Check if PRF is supported, otherwise password fallback is needed
@@ -873,6 +998,28 @@ var AuthService = class {
873
998
  async isPrfSupported() {
874
999
  return this.checkPRFSupport();
875
1000
  }
1001
+ /**
1002
+ * Add password recovery to an existing PRF key
1003
+ * @param password Password for recovery key
1004
+ */
1005
+ async addPasswordRecovery(password) {
1006
+ return await this.getManager().addPasswordRecovery(password);
1007
+ }
1008
+ /**
1009
+ * Activate recovery using password
1010
+ * Requires new credential ID from new device
1011
+ * @param password Password for recovery key
1012
+ * @param newCredentialId New passkey credential ID (required)
1013
+ */
1014
+ async activateWithPassword(password, newCredentialId) {
1015
+ return await this.getManager().activateWithPassword(password, newCredentialId);
1016
+ }
1017
+ /**
1018
+ * Get recovery data for kind-0
1019
+ */
1020
+ getRecoveryForKind0() {
1021
+ return this.getManager().getRecoveryForKind0();
1022
+ }
876
1023
  };
877
1024
 
878
1025
  //#endregion
@@ -1266,12 +1413,16 @@ exports.aesGcmEncrypt = aesGcmEncrypt;
1266
1413
  exports.bytesToHex = bytesToHex;
1267
1414
  exports.checkPRFSupport = checkPRFSupport;
1268
1415
  exports.createPasskey = createPasskey;
1416
+ exports.createRecoveryTag = createRecoveryTag;
1269
1417
  exports.deriveAesGcmKey = deriveAesGcmKey;
1418
+ exports.deriveNostrPrivateKey = deriveNostrPrivateKey;
1270
1419
  exports.generatePasswordProtectedKey = generatePasswordProtectedKey;
1271
1420
  exports.getPrfSecret = getPrfSecret;
1421
+ exports.getPublicKeyFromPassword = getPublicKeyFromPassword;
1272
1422
  exports.hexToBytes = hexToBytes;
1273
1423
  exports.importPublicKeyFromBundle = importPublicKeyFromBundle;
1274
1424
  exports.isPrfSupported = isPrfSupported;
1425
+ exports.parseRecoveryTag = parseRecoveryTag;
1275
1426
  exports.registerDummyPasskey = registerDummyPasskey;
1276
1427
  exports.unwrapPasswordProtectedPrivateKey = unwrapPasswordProtectedPrivateKey;
1277
1428
  var applesauce_core = require("applesauce-core");