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
-
.calc_padded_len(unpadded_len) ⇒ Object
Spec’s ladder: minimum 32, then power-of-two chunks.
-
.chacha20(key, nonce, data) ⇒ Object
Plain ChaCha20, 20 rounds, 96-bit nonce, 256-bit key.
-
.decrypt(payload, privkey_hex, pubkey_hex) ⇒ String
rubocop:disable Metrics/AbcSize.
-
.derive_conversation_key(privkey_hex, pubkey_hex) ⇒ Object
— Internals ——————————————————.
- .derive_message_keys(conversation_key, nonce) ⇒ Object
-
.encrypt(plaintext, privkey_hex, pubkey_hex, nonce: nil) ⇒ String
Base64 payload.
-
.hkdf_expand(prk, info, length) ⇒ Object
HKDF-expand(PRK, info, L).
-
.hkdf_extract(salt, ikm) ⇒ Object
HKDF-extract(salt, IKM) = HMAC-SHA256(salt, IKM).
-
.pad(pt_bytes) ⇒ Object
Pad plaintext: prepend u16-BE length, then zero-pad to ‘calc_padded_len(n)`.
-
.secure_compare(a, b) ⇒ Object
Constant-time byte comparison.
- .unpad(padded) ⇒ Object
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
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 = (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 (conversation_key, nonce) keys = (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.
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 = (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 (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
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 |