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