nsec.signer.ts raw

   1  import { ISigner, TDraftEvent } from '@/types'
   2  import * as utils from '@noble/curves/abstract/utils'
   3  import { bech32 } from '@scure/base'
   4  import { finalizeEvent, getPublicKey as nGetPublicKey, nip04 } from 'nostr-tools'
   5  import * as nip44 from 'nostr-tools/nip44'
   6  
   7  /**
   8   * Convert nsec (bech32) to hex string
   9   */
  10  function nsecToHex(nsec: string): string {
  11    const { prefix, words } = bech32.decode(nsec as `${string}1${string}`, 5000)
  12    if (prefix !== 'nsec') {
  13      throw new Error('Invalid nsec prefix')
  14    }
  15    const data = new Uint8Array(bech32.fromWords(words))
  16    return utils.bytesToHex(data)
  17  }
  18  
  19  /**
  20   * Normalize a private key to hex format.
  21   * Accepts nsec (bech32) or hex string.
  22   */
  23  function normalizeToHex(privateKey: string): string {
  24    if (privateKey.startsWith('nsec')) {
  25      return nsecToHex(privateKey)
  26    }
  27    return privateKey
  28  }
  29  
  30  /**
  31   * Validate that a hex key is exactly 64 characters.
  32   */
  33  function validateHexKey(hex: string): void {
  34    if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
  35      throw new Error('Private key must be 64 hex characters')
  36    }
  37  }
  38  
  39  export class NsecSigner implements ISigner {
  40    private privkey: Uint8Array | null = null
  41    private pubkey: string | null = null
  42  
  43    login(nsecOrPrivkey: string | Uint8Array) {
  44      let privkey: Uint8Array
  45  
  46      if (typeof nsecOrPrivkey === 'string') {
  47        try {
  48          const hex = normalizeToHex(nsecOrPrivkey)
  49          validateHexKey(hex)
  50          privkey = utils.hexToBytes(hex)
  51        } catch (error) {
  52          throw new Error(
  53            `Invalid private key: ${error instanceof Error ? error.message : 'unknown error'}`
  54          )
  55        }
  56      } else {
  57        privkey = nsecOrPrivkey
  58      }
  59  
  60      this.privkey = privkey
  61      this.pubkey = nGetPublicKey(privkey)
  62      return this.pubkey
  63    }
  64  
  65    async getPublicKey() {
  66      if (!this.pubkey) {
  67        throw new Error('Not logged in')
  68      }
  69      return this.pubkey
  70    }
  71  
  72    async signEvent(draftEvent: TDraftEvent) {
  73      if (!this.privkey) {
  74        throw new Error('Not logged in')
  75      }
  76  
  77      return finalizeEvent(draftEvent, this.privkey)
  78    }
  79  
  80    async nip04Encrypt(pubkey: string, plainText: string) {
  81      if (!this.privkey) {
  82        throw new Error('Not logged in')
  83      }
  84      return nip04.encrypt(this.privkey, pubkey, plainText)
  85    }
  86  
  87    async nip04Decrypt(pubkey: string, cipherText: string) {
  88      if (!this.privkey) {
  89        throw new Error('Not logged in')
  90      }
  91      return nip04.decrypt(this.privkey, pubkey, cipherText)
  92    }
  93  
  94    async nip44Encrypt(pubkey: string, plainText: string) {
  95      if (!this.privkey) {
  96        throw new Error('Not logged in')
  97      }
  98      const conversationKey = nip44.v2.utils.getConversationKey(this.privkey, pubkey)
  99      return nip44.v2.encrypt(plainText, conversationKey)
 100    }
 101  
 102    async nip44Decrypt(pubkey: string, cipherText: string) {
 103      if (!this.privkey) {
 104        throw new Error('Not logged in')
 105      }
 106      const conversationKey = nip44.v2.utils.getConversationKey(this.privkey, pubkey)
 107      return nip44.v2.decrypt(cipherText, conversationKey)
 108    }
 109  }
 110