Skip to content

Commit d4574cd

Browse files
vveerrggclaude
andcommitted
fix: NIP-04 encryption format to match specification
HIGH: encrypt/decrypt used non-standard hex format (iv + ciphertext concatenated). Now uses NIP-04 standard: base64(ciphertext)?iv=base64(iv). Decrypt supports legacy hex format as backwards-compatible fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5819137 commit d4574cd

2 files changed

Lines changed: 45 additions & 20 deletions

File tree

src/crypto.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { sha256 } from '@noble/hashes/sha256';
2727
import { randomBytes } from '@noble/hashes/utils';
2828
import { KeyPair, PublicKeyDetails, NostrEvent, SignedNostrEvent, PublicKey } from './types/index';
2929
import { logger } from './utils/logger';
30+
import { bytesToBase64, base64ToBytes } from './encoding/base64';
3031

3132

3233
/**
@@ -305,12 +306,11 @@ export async function encrypt(
305306
data.buffer
306307
));
307308

308-
// Combine IV and ciphertext
309-
const combined = new Uint8Array(iv.length + encrypted.byteLength);
310-
combined.set(iv);
311-
combined.set(new Uint8Array(encrypted), iv.length);
309+
// NIP-04 standard format: base64(ciphertext) + "?iv=" + base64(iv)
310+
const ciphertextBase64 = bytesToBase64(new Uint8Array(encrypted));
311+
const ivBase64 = bytesToBase64(iv);
312312

313-
return bytesToHex(combined);
313+
return ciphertextBase64 + '?iv=' + ivBase64;
314314
} catch (error) {
315315
logger.error({ error }, 'Failed to encrypt message');
316316
throw error;
@@ -330,9 +330,22 @@ export async function decrypt(
330330
const sharedPoint = secp256k1.getSharedSecret(hexToBytes(recipientPrivKey), hexToBytes(senderPubKeyHex));
331331
const sharedX = sharedPoint.slice(1, 33);
332332

333-
const encrypted = hexToBytes(encryptedMessage);
334-
const iv = encrypted.slice(0, 16);
335-
const ciphertext = encrypted.slice(16);
333+
// Parse NIP-04 standard format: base64(ciphertext) + "?iv=" + base64(iv)
334+
// Also support legacy hex format (iv + ciphertext concatenated) as fallback
335+
let iv: Uint8Array;
336+
let ciphertext: Uint8Array;
337+
338+
if (encryptedMessage.includes('?iv=')) {
339+
// NIP-04 standard format
340+
const [ciphertextBase64, ivBase64] = encryptedMessage.split('?iv=');
341+
ciphertext = base64ToBytes(ciphertextBase64);
342+
iv = base64ToBytes(ivBase64);
343+
} else {
344+
// Legacy hex format fallback: first 16 bytes are IV, rest is ciphertext
345+
const encrypted = hexToBytes(encryptedMessage);
346+
iv = encrypted.slice(0, 16);
347+
ciphertext = encrypted.slice(16);
348+
}
336349

337350
const key = await customCrypto.getSubtle().then((subtle) => subtle.importKey(
338351
'raw',
@@ -345,7 +358,7 @@ export async function decrypt(
345358
const decrypted = await customCrypto.getSubtle().then((subtle) => subtle.decrypt(
346359
{ name: 'AES-CBC', iv },
347360
key,
348-
ciphertext.buffer
361+
ciphertext.buffer as ArrayBuffer
349362
));
350363

351364
return new TextDecoder().decode(decrypted);

src/nips/nip-04.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
*/
66

77
import { secp256k1 } from '@noble/curves/secp256k1';
8-
import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils';
8+
import { hexToBytes } from '@noble/curves/abstract/utils';
99
import { logger } from '../utils/logger';
10+
import { bytesToBase64, base64ToBytes } from '../encoding/base64';
1011
import type { CryptoSubtle } from '../crypto';
1112

1213

@@ -127,12 +128,11 @@ export async function encryptMessage(
127128
encoded.buffer
128129
);
129130

130-
// Combine IV and ciphertext
131-
const combined = new Uint8Array(iv.length + encrypted.byteLength);
132-
combined.set(iv);
133-
combined.set(new Uint8Array(encrypted), iv.length);
131+
// NIP-04 standard format: base64(ciphertext) + "?iv=" + base64(iv)
132+
const ciphertextBase64 = bytesToBase64(new Uint8Array(encrypted));
133+
const ivBase64 = bytesToBase64(iv);
134134

135-
return bytesToHex(combined);
135+
return ciphertextBase64 + '?iv=' + ivBase64;
136136
} catch (error) {
137137
logger.error({ error }, 'Failed to encrypt message');
138138
throw error;
@@ -179,16 +179,28 @@ export async function decryptMessage(
179179
['decrypt']
180180
);
181181

182-
// Split IV and ciphertext
183-
const encrypted = hexToBytes(encryptedMessage);
184-
const iv = encrypted.slice(0, 16);
185-
const ciphertext = encrypted.slice(16);
182+
// Parse NIP-04 standard format: base64(ciphertext) + "?iv=" + base64(iv)
183+
// Also support legacy hex format (iv + ciphertext concatenated) as fallback
184+
let iv: Uint8Array;
185+
let ciphertext: Uint8Array;
186+
187+
if (encryptedMessage.includes('?iv=')) {
188+
// NIP-04 standard format
189+
const [ciphertextBase64, ivBase64] = encryptedMessage.split('?iv=');
190+
ciphertext = base64ToBytes(ciphertextBase64);
191+
iv = base64ToBytes(ivBase64);
192+
} else {
193+
// Legacy hex format fallback: first 16 bytes are IV, rest is ciphertext
194+
const encrypted = hexToBytes(encryptedMessage);
195+
iv = encrypted.slice(0, 16);
196+
ciphertext = encrypted.slice(16);
197+
}
186198

187199
// Decrypt
188200
const decrypted = await (await cryptoImpl.getSubtle()).decrypt(
189201
{ name: 'AES-CBC', iv },
190202
sharedKey,
191-
ciphertext.buffer
203+
ciphertext.buffer as ArrayBuffer
192204
);
193205

194206
return new TextDecoder().decode(decrypted);

0 commit comments

Comments
 (0)