nostr-keypair.ts raw

   1  import { bech32 } from '@scure/base';
   2  import * as utils from '@noble/curves/abstract/utils';
   3  import { getPublicKey, generateSecretKey } from 'nostr-tools';
   4  
   5  /**
   6   * Value object encapsulating a Nostr keypair.
   7   * Provides type-safe access to public key operations while protecting the private key.
   8   *
   9   * The private key is never exposed directly - all operations that need it
  10   * are performed through methods on this class.
  11   */
  12  export class NostrKeyPair {
  13    private readonly _privateKeyHex: string;
  14    private readonly _publicKeyHex: string;
  15  
  16    private constructor(privateKeyHex: string, publicKeyHex: string) {
  17      this._privateKeyHex = privateKeyHex;
  18      this._publicKeyHex = publicKeyHex;
  19    }
  20  
  21    /**
  22     * Generate a new random keypair.
  23     */
  24    static generate(): NostrKeyPair {
  25      const privateKeyBytes = generateSecretKey();
  26      const privateKeyHex = utils.bytesToHex(privateKeyBytes);
  27      const publicKeyHex = getPublicKey(privateKeyBytes);
  28      return new NostrKeyPair(privateKeyHex, publicKeyHex);
  29    }
  30  
  31    /**
  32     * Create a keypair from an existing private key.
  33     * Accepts either hex or nsec format.
  34     *
  35     * @throws InvalidNostrKeyError if the key is invalid
  36     */
  37    static fromPrivateKey(privateKey: string): NostrKeyPair {
  38      try {
  39        const hex = NostrKeyPair.normalizeToHex(privateKey);
  40        NostrKeyPair.validateHexKey(hex);
  41        const publicKeyHex = NostrKeyPair.derivePublicKey(hex);
  42        return new NostrKeyPair(hex, publicKeyHex);
  43      } catch (error) {
  44        throw new InvalidNostrKeyError(
  45          `Invalid private key: ${error instanceof Error ? error.message : 'unknown error'}`
  46        );
  47      }
  48    }
  49  
  50    /**
  51     * Reconstitute a keypair from storage.
  52     * Assumes the stored hex is valid (from trusted source).
  53     */
  54    static fromStorage(privateKeyHex: string): NostrKeyPair {
  55      const publicKeyHex = NostrKeyPair.derivePublicKey(privateKeyHex);
  56      return new NostrKeyPair(privateKeyHex, publicKeyHex);
  57    }
  58  
  59    /**
  60     * Get the public key in hex format.
  61     */
  62    get publicKeyHex(): string {
  63      return this._publicKeyHex;
  64    }
  65  
  66    /**
  67     * Get the public key in npub (bech32) format.
  68     */
  69    get npub(): string {
  70      const data = utils.hexToBytes(this._publicKeyHex);
  71      const words = bech32.toWords(data);
  72      return bech32.encode('npub', words, 5000);
  73    }
  74  
  75    /**
  76     * Get the private key in nsec (bech32) format.
  77     * Use with caution - only for display/export purposes.
  78     */
  79    get nsec(): string {
  80      const data = utils.hexToBytes(this._privateKeyHex);
  81      const words = bech32.toWords(data);
  82      return bech32.encode('nsec', words, 5000);
  83    }
  84  
  85    /**
  86     * Get the private key bytes for cryptographic operations.
  87     * Internal use only - required for signing and encryption.
  88     */
  89    getPrivateKeyBytes(): Uint8Array {
  90      return utils.hexToBytes(this._privateKeyHex);
  91    }
  92  
  93    /**
  94     * Get the private key hex for storage.
  95     * This should only be used when persisting to encrypted storage.
  96     */
  97    toStorageHex(): string {
  98      return this._privateKeyHex;
  99    }
 100  
 101    /**
 102     * Check if this keypair has the same public key as another.
 103     */
 104    hasSamePublicKey(other: NostrKeyPair): boolean {
 105      return this._publicKeyHex === other._publicKeyHex;
 106    }
 107  
 108    /**
 109     * Check if this keypair matches a given public key.
 110     */
 111    matchesPublicKey(publicKeyHex: string): boolean {
 112      return this._publicKeyHex === publicKeyHex;
 113    }
 114  
 115    /**
 116     * Value equality based on public key.
 117     * Two keypairs are equal if they represent the same identity.
 118     */
 119    equals(other: NostrKeyPair): boolean {
 120      return this._publicKeyHex === other._publicKeyHex;
 121    }
 122  
 123    // ─────────────────────────────────────────────────────────────────────────
 124    // Private helpers
 125    // ─────────────────────────────────────────────────────────────────────────
 126  
 127    private static normalizeToHex(privateKey: string): string {
 128      if (privateKey.startsWith('nsec')) {
 129        return NostrKeyPair.nsecToHex(privateKey);
 130      }
 131      return privateKey;
 132    }
 133  
 134    private static nsecToHex(nsec: string): string {
 135      const { prefix, words } = bech32.decode(nsec as `${string}1${string}`, 5000);
 136      if (prefix !== 'nsec') {
 137        throw new Error('Invalid nsec prefix');
 138      }
 139      const data = new Uint8Array(bech32.fromWords(words));
 140      return utils.bytesToHex(data);
 141    }
 142  
 143    private static validateHexKey(hex: string): void {
 144      if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
 145        throw new Error('Private key must be 64 hex characters');
 146      }
 147    }
 148  
 149    private static derivePublicKey(privateKeyHex: string): string {
 150      const privateKeyBytes = utils.hexToBytes(privateKeyHex);
 151      return getPublicKey(privateKeyBytes);
 152    }
 153  }
 154  
 155  /**
 156   * Error thrown when a Nostr key is invalid.
 157   */
 158  export class InvalidNostrKeyError extends Error {
 159    constructor(message: string) {
 160      super(message);
 161      this.name = 'InvalidNostrKeyError';
 162    }
 163  }
 164  
 165  /**
 166   * Utility functions for public key operations (no private key needed).
 167   */
 168  export class NostrPublicKey {
 169    private constructor(private readonly _hex: string) {}
 170  
 171    /**
 172     * Create from hex or npub format.
 173     */
 174    static from(publicKey: string): NostrPublicKey {
 175      if (publicKey.startsWith('npub')) {
 176        const hex = NostrPublicKey.npubToHex(publicKey);
 177        return new NostrPublicKey(hex);
 178      }
 179      NostrPublicKey.validateHex(publicKey);
 180      return new NostrPublicKey(publicKey);
 181    }
 182  
 183    get hex(): string {
 184      return this._hex;
 185    }
 186  
 187    get npub(): string {
 188      const data = utils.hexToBytes(this._hex);
 189      const words = bech32.toWords(data);
 190      return bech32.encode('npub', words, 5000);
 191    }
 192  
 193    /**
 194     * Get a shortened display version of the public key.
 195     */
 196    shortened(prefixLength = 8, suffixLength = 4): string {
 197      const npub = this.npub;
 198      return `${npub.slice(0, prefixLength)}...${npub.slice(-suffixLength)}`;
 199    }
 200  
 201    equals(other: NostrPublicKey): boolean {
 202      return this._hex === other._hex;
 203    }
 204  
 205    toString(): string {
 206      return this._hex;
 207    }
 208  
 209    private static npubToHex(npub: string): string {
 210      const { prefix, words } = bech32.decode(npub as `${string}1${string}`, 5000);
 211      if (prefix !== 'npub') {
 212        throw new InvalidNostrKeyError('Invalid npub prefix');
 213      }
 214      const data = new Uint8Array(bech32.fromWords(words));
 215      return utils.bytesToHex(data);
 216    }
 217  
 218    private static validateHex(hex: string): void {
 219      if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
 220        throw new InvalidNostrKeyError('Public key must be 64 hex characters');
 221      }
 222    }
 223  }
 224