Skip to content
Open
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
2 changes: 2 additions & 0 deletions .changeset/vast-signs-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
116 changes: 39 additions & 77 deletions test/unit/utils/nip44.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,155 +18,121 @@ function pubkeyFromPrivkey(secHex: string): string {

const SEC1 = '0000000000000000000000000000000000000000000000000000000000000001'
const SEC2 = '0000000000000000000000000000000000000000000000000000000000000002'
const SEC3 = '0000000000000000000000000000000000000000000000000000000000000003'
const KNOWN_CONVERSATION_KEY = 'c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d'
const KNOWN_NONCE = '0000000000000000000000000000000000000000000000000000000000000001'
const KNOWN_PLAINTEXT = 'a'
const KNOWN_PAYLOAD =
'AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb'

let PUB1: string
let PUB2: string
let PUB3: string
let CONVERSATION_KEY: Buffer
let RECIPIENT_CONVERSATION_KEY: Buffer
let DIFFERENT_CONVERSATION_KEY: Buffer

// ---------------------------------------------------------------------------

describe('NIP-44', () => {
before(() => {
PUB1 = pubkeyFromPrivkey(SEC1)
PUB2 = pubkeyFromPrivkey(SEC2)
PUB3 = pubkeyFromPrivkey(SEC3)
CONVERSATION_KEY = getConversationKey(SEC1, PUB2)
RECIPIENT_CONVERSATION_KEY = getConversationKey(SEC2, PUB1)
DIFFERENT_CONVERSATION_KEY = getConversationKey(SEC1, PUB3)
})

describe('getConversationKey', () => {
it('derives the correct conversation key from sec1 and pub2', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const key = getConversationKey(SEC1, pub2)
expect(key.toString('hex')).to.equal(KNOWN_CONVERSATION_KEY)
expect(CONVERSATION_KEY.toString('hex')).to.equal(KNOWN_CONVERSATION_KEY)
})

it('is symmetric: conv(a, B) == conv(b, A)', () => {
const pub1 = pubkeyFromPrivkey(SEC1)
const pub2 = pubkeyFromPrivkey(SEC2)
const keyAB = getConversationKey(SEC1, pub2)
const keyBA = getConversationKey(SEC2, pub1)
expect(keyAB.toString('hex')).to.equal(keyBA.toString('hex'))
expect(CONVERSATION_KEY.toString('hex')).to.equal(RECIPIENT_CONVERSATION_KEY.toString('hex'))
})

it('produces different keys for different key pairs', () => {
const sec3 = '0000000000000000000000000000000000000000000000000000000000000003'
const pub2 = pubkeyFromPrivkey(SEC2)
const pub3 = pubkeyFromPrivkey(sec3)
const key12 = getConversationKey(SEC1, pub2)
const key13 = getConversationKey(SEC1, pub3)
expect(key12.toString('hex')).to.not.equal(key13.toString('hex'))
expect(CONVERSATION_KEY.toString('hex')).to.not.equal(DIFFERENT_CONVERSATION_KEY.toString('hex'))
})
})

describe('nip44Encrypt', () => {
it('produces the canonical payload from the NIP-44 spec test vector', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)
const nonce = Buffer.from(KNOWN_NONCE, 'hex')

const payload = nip44Encrypt(KNOWN_PLAINTEXT, conversationKey, nonce)
const payload = nip44Encrypt(KNOWN_PLAINTEXT, CONVERSATION_KEY, nonce)
expect(payload).to.equal(KNOWN_PAYLOAD)
})

it('produces a valid base64 string starting with version byte 0x02', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)

const payload = nip44Encrypt('hello', conversationKey)
const payload = nip44Encrypt('hello', CONVERSATION_KEY)
const decoded = Buffer.from(payload, 'base64')

expect(decoded[0]).to.equal(2) // version byte
expect(payload.length).to.be.within(132, 87472)
})

it('produces different ciphertexts for the same plaintext (random nonce)', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)

const payload1 = nip44Encrypt('same message', conversationKey)
const payload2 = nip44Encrypt('same message', conversationKey)
const payload1 = nip44Encrypt('same message', CONVERSATION_KEY)
const payload2 = nip44Encrypt('same message', CONVERSATION_KEY)

expect(payload1).to.not.equal(payload2)
})

it('throws for empty plaintext', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)

expect(() => nip44Encrypt('', conversationKey)).to.throw('invalid plaintext length')
expect(() => nip44Encrypt('', CONVERSATION_KEY)).to.throw('invalid plaintext length')
})

it('throws for plaintext exceeding 65535 bytes', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)

expect(() => nip44Encrypt('x'.repeat(65536), conversationKey)).to.throw('invalid plaintext length')
expect(() => nip44Encrypt('x'.repeat(65536), CONVERSATION_KEY)).to.throw('invalid plaintext length')
})
})

describe('nip44Decrypt', () => {
it('decrypts the canonical NIP-44 spec test vector', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)

const plaintext = nip44Decrypt(KNOWN_PAYLOAD, conversationKey)
const plaintext = nip44Decrypt(KNOWN_PAYLOAD, CONVERSATION_KEY)
expect(plaintext).to.equal(KNOWN_PLAINTEXT)
})

it('round-trips any plaintext through encrypt then decrypt', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)
const original = 'Hola, que tal? 🌍'

const payload = nip44Encrypt(original, conversationKey)
const recovered = nip44Decrypt(payload, conversationKey)
const payload = nip44Encrypt(original, CONVERSATION_KEY)
const recovered = nip44Decrypt(payload, CONVERSATION_KEY)

expect(recovered).to.equal(original)
})

it('works with the symmetric key (recipient decrypts sender message)', () => {
const pub1 = pubkeyFromPrivkey(SEC1)
const pub2 = pubkeyFromPrivkey(SEC2)

const senderKey = getConversationKey(SEC1, pub2)
const recipientKey = getConversationKey(SEC2, pub1)

const payload = nip44Encrypt('secret message', senderKey)
const plaintext = nip44Decrypt(payload, recipientKey)
const payload = nip44Encrypt('secret message', CONVERSATION_KEY)
const plaintext = nip44Decrypt(payload, RECIPIENT_CONVERSATION_KEY)

expect(plaintext).to.equal('secret message')
})

it('throws when MAC is tampered', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)
const payload = nip44Encrypt('tamper me', conversationKey)
const payload = nip44Encrypt('tamper me', CONVERSATION_KEY)

// Flip the last character of the base64 payload to corrupt the MAC
const tampered = payload.slice(0, -4) + 'AAAA'

expect(() => nip44Decrypt(tampered, conversationKey)).to.throw()
expect(() => nip44Decrypt(tampered, CONVERSATION_KEY)).to.throw()
})

it('throws for payload starting with # (unsupported future version)', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)

expect(() => nip44Decrypt('#not-base64', conversationKey)).to.throw('unknown version')
expect(() => nip44Decrypt('#not-base64', CONVERSATION_KEY)).to.throw('unknown version')
})

it('throws for payload that is too short', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)

expect(() => nip44Decrypt('dG9vc2hvcnQ=', conversationKey)).to.throw('invalid payload size')
expect(() => nip44Decrypt('dG9vc2hvcnQ=', CONVERSATION_KEY)).to.throw('invalid payload size')
})

it('throws for wrong conversation key', () => {
const sec3 = '0000000000000000000000000000000000000000000000000000000000000003'
const pub2 = pubkeyFromPrivkey(SEC2)
const pub3 = pubkeyFromPrivkey(sec3)

const senderKey = getConversationKey(SEC1, pub2)
const wrongKey = getConversationKey(SEC1, pub3)

const payload = nip44Encrypt('private', senderKey)
const payload = nip44Encrypt('private', CONVERSATION_KEY)

expect(() => nip44Decrypt(payload, wrongKey)).to.throw()
expect(() => nip44Decrypt(payload, DIFFERENT_CONVERSATION_KEY)).to.throw()
})
})

Expand All @@ -176,9 +142,7 @@ describe('NIP-44', () => {
})

it('returns undefined for a freshly encrypted payload', () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)
const payload = nip44Encrypt('hello', conversationKey)
const payload = nip44Encrypt('hello', CONVERSATION_KEY)

expect(validateNip44Payload(payload)).to.be.undefined
})
Expand Down Expand Up @@ -228,11 +192,9 @@ describe('NIP-44', () => {

for (const [unpaddedLen, expectedPaddedLen] of cases) {
it(`pads ${unpaddedLen} bytes to ${expectedPaddedLen} bytes`, () => {
const pub2 = pubkeyFromPrivkey(SEC2)
const conversationKey = getConversationKey(SEC1, pub2)
const plaintext = 'a'.repeat(unpaddedLen)

const payload = nip44Encrypt(plaintext, conversationKey)
const payload = nip44Encrypt(plaintext, CONVERSATION_KEY)
const decoded = Buffer.from(payload, 'base64')

// Layout: 1 (version) + 32 (nonce) + paddedLen + 2 (length prefix) + 32 (mac)
Expand Down
Loading