Module: NwcRuby::NIP44::Cipher

Defined in:
lib/nwc_ruby/nip44/cipher.rb

Overview

NIP-44 v2 encryption. The current Nostr DM encryption, and the one NWC wallet services advertise via ‘[“encryption”, “nip44_v2”]` on kind 13194.

Algorithm (verbatim from the spec):

1. shared_x = X coordinate of ECDH(priv, pub)  -- 32 bytes, no hashing
2. conversation_key = HKDF-extract(IKM=shared_x, salt="nip44-v2")
3. keys = HKDF-expand(PRK=conversation_key, info=nonce32, L=76)
   split into chacha_key[0..32] || chacha_nonce[32..44] || hmac_key[44..76]
4. Plaintext is prefixed with u16-BE length, then zero-padded to a
   power-of-two chosen by a specific ladder (min 32 bytes, max 65536).
5. Encrypt with plain ChaCha20 (NOT ChaCha20-Poly1305).
6. mac = HMAC-SHA256(hmac_key, ciphertext, aad=nonce)
7. payload = base64( 0x02 || nonce32 || ciphertext || mac32 )

Security-critical invariants:

- MAC is verified in constant time BEFORE decryption returns plaintext.
- Unknown version bytes are rejected.
- Padding is validated on decrypt (length prefix sanity + trailing zeros).

Reference vectors: github.com/paulmillr/nip44/blob/main/nip44.vectors.json

Constant Summary collapse

VERSION =
2
MIN_PLAINTEXT =
1
MAX_PLAINTEXT =
65_535
MIN_PADDED_LEN =
32
SALT =
'nip44-v2'

Class Method Summary collapse

Class Method Details

.calc_padded_len(unpadded_len) ⇒ Object

Spec’s ladder: minimum 32, then power-of-two chunks.



142
143
144
145
146
147
148
149
# File 'lib/nwc_ruby/nip44/cipher.rb', line 142

def calc_padded_len(unpadded_len)
  return MIN_PADDED_LEN if unpadded_len <= MIN_PADDED_LEN

  # next_power = 1 << (ceil(log2(unpadded_len - 1)))
  next_power = 1 << (unpadded_len - 1).bit_length
  chunk = next_power <= 256 ? 32 : next_power / 8
  (((unpadded_len - 1) / chunk) + 1) * chunk
end

.chacha20(key, nonce, data) ⇒ Object

Plain ChaCha20, 20 rounds, 96-bit nonce, 256-bit key.



123
124
125
126
127
128
129
130
# File 'lib/nwc_ruby/nip44/cipher.rb', line 123

def chacha20(key, nonce, data)
  cipher = OpenSSL::Cipher.new('chacha20')
  cipher.encrypt
  # OpenSSL's "chacha20" wants a 16-byte IV: 4-byte counter (little-endian, 0) + 12-byte nonce.
  cipher.key = key
  cipher.iv  = [0].pack('V') + nonce
  cipher.update(data) + cipher.final
end

.decrypt(payload, privkey_hex, pubkey_hex) ⇒ String

rubocop:disable Metrics/AbcSize

Parameters:

  • payload (String)

    base64 NIP-44 payload

Returns:

  • (String)

    UTF-8 plaintext

Raises:



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/nwc_ruby/nip44/cipher.rb', line 60

def decrypt(payload, privkey_hex, pubkey_hex)
  raise EncryptionError, 'payload is nil or empty' if payload.nil? || payload.empty?
  raise EncryptionError, "payload starts with '#' (not encrypted)" if payload.start_with?('#')

  raw = begin
    Base64.strict_decode64(payload)
  rescue ArgumentError
    raise EncryptionError, 'payload is not valid base64'
  end

  if raw.bytesize < 1 + 32 + MIN_PADDED_LEN + 32 || raw.bytesize > 1 + 32 + 65_536 + 32
    raise EncryptionError, 'payload length out of range'
  end

  version = raw.byteslice(0, 1).unpack1('C')
  raise EncryptionError, "unsupported NIP-44 version: #{version}" unless version == VERSION

  nonce      = raw.byteslice(1, 32)
  mac        = raw.byteslice(raw.bytesize - 32, 32)
  ciphertext = raw.byteslice(33, raw.bytesize - 33 - 32)

  conversation_key = derive_conversation_key(privkey_hex, pubkey_hex)
  chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation_key, nonce)

  expected_mac = OpenSSL::HMAC.digest('SHA256', hmac_key, nonce + ciphertext)
  raise EncryptionError, 'NIP-44 MAC verification failed' unless secure_compare(mac, expected_mac)

  padded = chacha20(chacha_key, chacha_nonce, ciphertext)
  unpad(padded).force_encoding('UTF-8')
end

.derive_conversation_key(privkey_hex, pubkey_hex) ⇒ Object

— Internals ——————————————————



94
95
96
97
# File 'lib/nwc_ruby/nip44/cipher.rb', line 94

def derive_conversation_key(privkey_hex, pubkey_hex)
  shared = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
  hkdf_extract(SALT.b, shared)
end

.derive_message_keys(conversation_key, nonce) ⇒ Object



99
100
101
102
# File 'lib/nwc_ruby/nip44/cipher.rb', line 99

def derive_message_keys(conversation_key, nonce)
  keys = hkdf_expand(conversation_key, nonce, 76)
  [keys.byteslice(0, 32), keys.byteslice(32, 12), keys.byteslice(44, 32)]
end

.encrypt(plaintext, privkey_hex, pubkey_hex, nonce: nil) ⇒ String

Returns base64 payload.

Parameters:

  • plaintext (String)

    UTF-8 plaintext (1..65535 bytes)

  • privkey_hex (String)

    our private key, 32-byte hex

  • pubkey_hex (String)

    their x-only pubkey, 32-byte hex

  • nonce (String, nil) (defaults to: nil)

    optional 32-byte nonce (for tests); random if nil

Returns:

  • (String)

    base64 payload

Raises:



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/nwc_ruby/nip44/cipher.rb', line 39

def encrypt(plaintext, privkey_hex, pubkey_hex, nonce: nil)
  pt_bytes = plaintext.to_s.dup.force_encoding('UTF-8').b
  if pt_bytes.bytesize < MIN_PLAINTEXT || pt_bytes.bytesize > MAX_PLAINTEXT
    raise EncryptionError, 'plaintext length must be 1..65535 bytes'
  end

  conversation_key = derive_conversation_key(privkey_hex, pubkey_hex)
  nonce ||= SecureRandom.bytes(32)
  raise EncryptionError, 'nonce must be 32 bytes' unless nonce.bytesize == 32

  chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation_key, nonce)
  padded     = pad(pt_bytes)
  ciphertext = chacha20(chacha_key, chacha_nonce, padded)
  mac        = OpenSSL::HMAC.digest('SHA256', hmac_key, nonce + ciphertext)

  Base64.strict_encode64([VERSION].pack('C') + nonce + ciphertext + mac)
end

.hkdf_expand(prk, info, length) ⇒ Object

HKDF-expand(PRK, info, L). RFC 5869.



110
111
112
113
114
115
116
117
118
119
120
# File 'lib/nwc_ruby/nip44/cipher.rb', line 110

def hkdf_expand(prk, info, length)
  out = String.new(encoding: Encoding::BINARY)
  t   = String.new(encoding: Encoding::BINARY)
  counter = 1
  while out.bytesize < length
    t = OpenSSL::HMAC.digest('SHA256', prk, t + info + [counter].pack('C'))
    out << t
    counter += 1
  end
  out.byteslice(0, length)
end

.hkdf_extract(salt, ikm) ⇒ Object

HKDF-extract(salt, IKM) = HMAC-SHA256(salt, IKM). Returns 32 bytes.



105
106
107
# File 'lib/nwc_ruby/nip44/cipher.rb', line 105

def hkdf_extract(salt, ikm)
  OpenSSL::HMAC.digest('SHA256', salt, ikm)
end

.pad(pt_bytes) ⇒ Object

Pad plaintext: prepend u16-BE length, then zero-pad to ‘calc_padded_len(n)`.



133
134
135
136
137
138
139
# File 'lib/nwc_ruby/nip44/cipher.rb', line 133

def pad(pt_bytes)
  n = pt_bytes.bytesize
  padded_len = calc_padded_len(n)
  prefix     = [n].pack('n') # u16 big-endian
  zeros      = "\x00".b * (padded_len - n)
  prefix + pt_bytes + zeros
end

.secure_compare(a, b) ⇒ Object

Constant-time byte comparison.



167
168
169
170
171
172
173
# File 'lib/nwc_ruby/nip44/cipher.rb', line 167

def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  acc = 0
  a.bytes.zip(b.bytes) { |x, y| acc |= x ^ y }
  acc.zero?
end

.unpad(padded) ⇒ Object

Raises:



151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/nwc_ruby/nip44/cipher.rb', line 151

def unpad(padded)
  raise EncryptionError, 'padded data too short' if padded.bytesize < 2 + MIN_PADDED_LEN - 2

  n = padded.byteslice(0, 2).unpack1('n')
  raise EncryptionError, 'invalid padding length' if n < MIN_PLAINTEXT || n > MAX_PLAINTEXT

  pt = padded.byteslice(2, n)
  raise EncryptionError, 'truncated padded plaintext' if pt.nil? || pt.bytesize != n

  expected_total = 2 + calc_padded_len(n)
  raise EncryptionError, 'padded length mismatch' unless padded.bytesize == expected_total

  pt
end