Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement NIP-44: secure versioned replacement for NIP4 #221

Merged
merged 25 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ assert(data.relays.length === 2)
### Encrypting and decrypting direct messages

```js
import { nip04, getPublicKey, generatePrivateKey } from 'nostr-tools'
import {nip44, getPublicKey, generatePrivateKey} from 'nostr-tools'

// sender
let sk1 = generatePrivateKey()
Expand All @@ -235,7 +235,8 @@ let pk2 = getPublicKey(sk2)

// on the sender side
let message = 'hello'
let ciphertext = await nip04.encrypt(sk1, pk2, message)
let key = nip44.getSharedSecret(sk1, pk2)
let ciphertext = nip44.encrypt(key, message)

let event = {
kind: 4,
Expand All @@ -250,8 +251,9 @@ sendEvent(event)
// on the receiver side
sub.on('event', async event => {
let sender = event.pubkey
pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
// pk1 === sender
let _key = nip44.getSharedSecret(sk2, pk1)
let plaintext = nip44.decrypt(_key, event.content)
})
```

Expand Down
86 changes: 70 additions & 16 deletions nip44.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,75 @@
import crypto from 'node:crypto'
import { hexToBytes } from '@noble/hashes/utils'
import { encrypt, decrypt, utils } from './nip44.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { v2 as vectors } from './nip44.vectors.json'
import { getPublicKey } from './keys.ts'

import { encrypt, decrypt, getSharedSecret } from './nip44.ts'
import { getPublicKey, generatePrivateKey } from './keys.ts'
test('NIP44: valid_sec', async () => {
for (const v of vectors.valid_sec) {
const pub2 = getPublicKey(v.sec2)
const key = utils.v2.getConversationKey(v.sec1, pub2)
expect(bytesToHex(key)).toEqual(v.shared)
const ciphertext = encrypt(key, v.plaintext, { salt: hexToBytes(v.salt) })
expect(ciphertext).toEqual(v.ciphertext)
const decrypted = decrypt(key, ciphertext)
expect(decrypted).toEqual(v.plaintext)
}
})

test('NIP44: valid_pub', async () => {
for (const v of vectors.valid_pub) {
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
expect(bytesToHex(key)).toEqual(v.shared)
const ciphertext = encrypt(key, v.plaintext, { salt: hexToBytes(v.salt) })
expect(ciphertext).toEqual(v.ciphertext)
const decrypted = decrypt(key, ciphertext)
expect(decrypted).toEqual(v.plaintext)
}
})

// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('NIP44: invalid', async () => {
for (const v of vectors.invalid) {
expect(() => {
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
const ciphertext = decrypt(key, v.plaintext)

Check failure on line 33 in nip44.test.ts

View workflow job for this annotation

GitHub Actions / format

'ciphertext' is assigned a value but never used. Allowed unused vars must match /^_/u
}).toThrowError()

Check failure on line 34 in nip44.test.ts

View workflow job for this annotation

GitHub Actions / format

Replace toThrowError() with its canonical name of toThrow()
}
})

test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
let sharedKey1 = getSharedSecret(sk1, pk2)
let sharedKey2 = getSharedSecret(sk2, pk1)
test('NIP44: invalid_conversation_key', async () => {
for (const v of vectors.invalid_conversation_key) {
expect(() => {
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
const ciphertext = encrypt(key, v.plaintext)

Check failure on line 42 in nip44.test.ts

View workflow job for this annotation

GitHub Actions / format

'ciphertext' is assigned a value but never used. Allowed unused vars must match /^_/u
}).toThrowError()

Check failure on line 43 in nip44.test.ts

View workflow job for this annotation

GitHub Actions / format

Replace toThrowError() with its canonical name of toThrow()
}
})

expect(decrypt(hexToBytes(sk1), encrypt(hexToBytes(sk1), 'hello'))).toEqual('hello')
expect(decrypt(sharedKey2, encrypt(sharedKey1, 'hello'))).toEqual('hello')
test('NIP44: v1 calcPadding', () => {
for (const [len, shouldBePaddedTo] of vectors.padding) {
const actual = utils.v2.calcPadding(len)
expect(actual).toEqual(shouldBePaddedTo)
}
})

// To re-generate vectors and produce new ones:
// Create regen.mjs with this content:
// import {getPublicKey, nip44} from './lib/esm/nostr.mjs'
// import {bytesToHex, hexToBytes} from '@noble/hashes/utils'
// import vectors from './nip44.vectors.json' assert { type: "json" };
// function genVectors(v) {
// const pub2 = v.pub2 ?? getPublicKey(v.sec2);
// let sharedKey = nip44.utils.v2.getConversationKey(v.sec1, pub2)
// let ciphertext = nip44.encrypt(sharedKey, v.plaintext, { salt: hexToBytes(v.salt) })
// console.log({
// sec1: v.sec1,
// pub2: pub2,
// sharedKey: bytesToHex(sharedKey),
// salt: v.salt,
// plaintext: v.plaintext,
// ciphertext
// })
// }
// for (let v of vectors.valid_sec) genVectors(v);
// for (let v of vectors.valid_pub) genVectors(v);
// const padded = concatBytes(utils.v2.pad(plaintext), new Uint8Array(250))
// const mac = randomBytes(32)
119 changes: 92 additions & 27 deletions nip44.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,105 @@
import { base64 } from '@scure/base'
import { randomBytes } from '@noble/hashes/utils'
import { chacha20 } from '@noble/ciphers/chacha'
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { hkdf } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256'
import { xchacha20 } from '@noble/ciphers/chacha'

import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'

export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
sha256(secp256k1.getSharedSecret(privkey, '02' + pubkey).subarray(1, 33))
export const utils = {
v2: {
maxPlaintextSize: 65536 - 128, // 64kb - 128
minCiphertextSize: 100, // should be 128 if min padded to 32b: base64(1+32+32+32)
maxCiphertextSize: 102400, // 100kb

getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
const key = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB)
return key.subarray(1, 33)
},

getMessageKeys(conversationKey: Uint8Array, salt: Uint8Array) {
const keys = hkdf(sha256, conversationKey, salt, 'nip44-v2', 76)
return {
encryption: keys.subarray(0, 32),
nonce: keys.subarray(32, 44),
auth: keys.subarray(44, 76),
}
},

calcPadding(len: number): number {
if (!Number.isSafeInteger(len) || len < 0) throw new Error('expected positive integer')
if (len <= 32) return 32
const nextpower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
const chunk = nextpower <= 256 ? 32 : nextpower / 8
return chunk * (Math.floor((len - 1) / chunk) + 1)
},

export function encrypt(key: Uint8Array, text: string, v = 1) {
if (v !== 1) {
throw new Error('NIP44: unknown encryption version')
}
pad(unpadded: string): Uint8Array {
const unpaddedB = utf8Encoder.encode(unpadded)
const len = unpaddedB.length
if (len < 1 || len >= utils.v2.maxPlaintextSize) throw new Error('plaintext should be between 1b and 64KB')
const paddedLen = utils.v2.calcPadding(len)
const zeros = new Uint8Array(paddedLen - len)
const lenBuf = new Uint8Array(2)
new DataView(lenBuf.buffer).setUint16(0, len)
return concatBytes(lenBuf, unpaddedB, zeros)
},

const nonce = randomBytes(24)
const plaintext = utf8Encoder.encode(text)
const ciphertext = xchacha20(key, nonce, plaintext)
unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
const unpadded = padded.subarray(2, 2 + unpaddedLen)
if (
unpaddedLen === 0 ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + utils.v2.calcPadding(unpaddedLen)
)
throw new Error('invalid padding')
return utf8Decoder.decode(unpadded)
},
},
}

export function encrypt(
key: Uint8Array,
plaintext: string,
options: { salt?: Uint8Array; version?: number } = {},
): string {
const version = options.version ?? 2
if (version !== 2) throw new Error('unknown encryption version ' + version)

const salt = options.salt ?? randomBytes(32)
ensureBytes(salt, 32)

const payload = new Uint8Array(25 + ciphertext.length)
payload.set([v], 0)
payload.set(nonce, 1)
payload.set(ciphertext, 25)
const keys = utils.v2.getMessageKeys(key, salt)
const padded = utils.v2.pad(plaintext)
const ciphertext = chacha20(keys.encryption, keys.nonce, padded)
const mac = hmac(sha256, keys.auth, ciphertext)

return base64.encode(payload)
return base64.encode(concatBytes(new Uint8Array([version]), salt, ciphertext, mac))
}

export function decrypt(key: Uint8Array, payload: string) {
let data = base64.decode(payload)
if (data[0] !== 1) {
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
}
export function decrypt(key: Uint8Array, ciphertext: string): string {
const clen = ciphertext.length

if (clen < utils.v2.minCiphertextSize || clen >= utils.v2.maxCiphertextSize)
throw new Error('ciphertext length is invalid')

const v = ciphertext[0]
if (v === '#') throw new Error('unknown encryption version')
const data = base64.decode(ciphertext)
const version = data.subarray(0, 1)[0]
if (version !== 2) throw new Error('unknown encryption version ' + version)

const salt = data.subarray(1, 33)
const ciphertext_ = data.subarray(33, -32)
const mac = data.subarray(-32)

const nonce = data.slice(1, 25)
const ciphertext = data.slice(25)
const plaintext = xchacha20(key, nonce, ciphertext)
const keys = utils.v2.getMessageKeys(key, salt)
const calculatedMac = hmac(sha256, keys.auth, ciphertext_)
if (!equalBytes(calculatedMac, mac)) throw new Error('encryption MAC does not match')

return utf8Decoder.decode(plaintext)
const plaintext = chacha20(keys.encryption, keys.nonce, ciphertext_)
return utils.v2.unpad(plaintext)
}
Loading
Loading