nuckle 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 00a442ba9e5d4c70e543b9f2ec156248c283881fcf2bb774dc592534bc0a79dc
4
+ data.tar.gz: 478ebf814c22348331fcbbb5692f302425bca6cb4c48365c4508c8c4426f30ed
5
+ SHA512:
6
+ metadata.gz: a291010aeaa3fce0a12479f2a8b40e0fd089125d295f1890801683350a3ffb7ea34c03ca527ed6e63738d539e305d7d46dce5294adc58b1c0494e26bf5e19835
7
+ data.tar.gz: '0886502547f3628973eab880936d6bec563bb48807f39b959e7bf390433acffa67d63d30e456ed8f51a5ccb547eb1c1c3739f43f23f098581d241444d003c263'
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2026, Patrik Wenger
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted, provided that the above
5
+ copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # 🤜 Nuckle
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/nuckle)](https://rubygems.org/gems/nuckle)
4
+ [![CI](https://github.com/paddor/nuckle/actions/workflows/ci.yml/badge.svg)](https://github.com/paddor/nuckle/actions/workflows/ci.yml)
5
+
6
+ NaCl for Knuckleheads — pure Ruby crypto primitives, no libsodium required.
7
+
8
+ ## Is it any good?
9
+
10
+ No. See [SECURITY.md](SECURITY.md).
11
+
12
+ ## ⚠️ Don't use this
13
+
14
+ Ruby's bignum arithmetic is not constant-time, so private keys leak
15
+ through timing side channels. Use
16
+ [rbnacl](https://github.com/RubyCrypto/rbnacl) for anything that matters.
17
+
18
+ ## 🤷 Why does this exist?
19
+
20
+ - Zero-dependency development and CI environments
21
+ - Seeing how well Ruby's YJIT performs at crypto ("just for fun")
22
+ - Educational purposes
23
+ - Environments where installing libsodium is impractical
24
+
25
+ ## Primitives
26
+
27
+ | Primitive | What it does |
28
+ |-----------|-------------|
29
+ | **Curve25519** | Elliptic-curve Diffie-Hellman (key agreement) |
30
+ | **XSalsa20** | Extended-nonce stream cipher (includes HSalsa20, Salsa20) |
31
+ | **Poly1305** | One-time message authenticator |
32
+ | **Box** | Public-key authenticated encryption (Curve25519-XSalsa20-Poly1305) |
33
+ | **SecretBox** | Symmetric authenticated encryption (XSalsa20-Poly1305) |
34
+
35
+ ## Usage
36
+
37
+ ```ruby
38
+ require "nuckle"
39
+
40
+ # Generate a keypair
41
+ sk = Nuckle::PrivateKey.generate
42
+ pk = sk.public_key
43
+
44
+ # Public-key encryption
45
+ alice = Nuckle::PrivateKey.generate
46
+ bob = Nuckle::PrivateKey.generate
47
+ nonce = Nuckle::Random.random_bytes(24)
48
+
49
+ box = Nuckle::Box.new(bob.public_key, alice)
50
+ ciphertext = box.encrypt(nonce, "hello")
51
+
52
+ box2 = Nuckle::Box.new(alice.public_key, bob)
53
+ plaintext = box2.decrypt(nonce, ciphertext)
54
+ # => "hello"
55
+
56
+ # Symmetric encryption
57
+ key = Nuckle::Random.random_bytes(32)
58
+ nonce = Nuckle::Random.random_bytes(24)
59
+ box = Nuckle::SecretBox.new(key)
60
+
61
+ ciphertext = box.encrypt(nonce, "hello")
62
+ plaintext = box.decrypt(nonce, ciphertext)
63
+ ```
64
+
65
+ ## API Compatibility
66
+
67
+ The API mirrors [rbnacl](https://github.com/RubyCrypto/rbnacl).
68
+ If you know what you're doing:
69
+
70
+ ```ruby
71
+ RbNaCl = Nuckle
72
+ ```
73
+
74
+ ## Verification
75
+
76
+ All primitives are tested against rbnacl/libsodium test vectors
77
+ (NaCl distribution, RFC 7748, RFC 8439) and cross-validated to
78
+ produce byte-identical output.
79
+
80
+ ## License
81
+
82
+ ISC
data/lib/nuckle/box.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ # Public-key authenticated encryption: Curve25519-XSalsa20-Poly1305.
5
+ #
6
+ # Compatible with NaCl crypto_box / libsodium crypto_box_curve25519xsalsa20poly1305.
7
+ #
8
+ class Box
9
+ NONCEBYTES = 24
10
+ PUBLICKEYBYTES = 32
11
+ PRIVATEKEYBYTES = 32
12
+ BEFORENMBYTES = 32
13
+ MACBYTES = 16
14
+
15
+ def initialize(public_key, private_key)
16
+ pk = extract_bytes(public_key, PUBLICKEYBYTES, "public key")
17
+ sk = extract_bytes(private_key, PRIVATEKEYBYTES, "private key")
18
+
19
+ # Compute shared secret via Curve25519 DH
20
+ shared = Internals::Curve25519.scalarmult(sk, pk)
21
+
22
+ # Derive symmetric key via HSalsa20 (the "beforenm" step)
23
+ key = Internals::Salsa20.hsalsa20(shared, "\x00" * 16)
24
+
25
+ @secret_box = SecretBox.new(key)
26
+ end
27
+
28
+ def nonce_bytes = NONCEBYTES
29
+
30
+ # Encrypt plaintext with 24-byte nonce.
31
+ def encrypt(nonce, plaintext)
32
+ @secret_box.encrypt(nonce, plaintext)
33
+ end
34
+
35
+ # Decrypt ciphertext with 24-byte nonce.
36
+ def decrypt(nonce, ciphertext)
37
+ @secret_box.decrypt(nonce, ciphertext)
38
+ end
39
+
40
+ alias box encrypt
41
+ alias open decrypt
42
+
43
+ private
44
+
45
+ def extract_bytes(key, expected_size, name)
46
+ bytes = case key
47
+ when PublicKey, PrivateKey then key.to_s
48
+ when String then key.b
49
+ else raise ArgumentError, "#{name} must be a Key or String"
50
+ end
51
+ raise ArgumentError, "#{name} must be #{expected_size} bytes (got #{bytes.bytesize})" unless bytes.bytesize == expected_size
52
+
53
+ bytes
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ class CryptoError < StandardError; end
5
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ module Internals
5
+ # Curve25519 elliptic-curve Diffie-Hellman.
6
+ #
7
+ # Montgomery curve: y^2 = x^3 + 486662*x^2 + x over GF(2^255 - 19).
8
+ # Uses the Montgomery ladder for scalar multiplication.
9
+ #
10
+ # Reference: RFC 7748
11
+ #
12
+ module Curve25519
13
+ P = (1 << 255) - 19
14
+ A24 = 121666 # (486662 + 2) / 4
15
+ BASE_U = 9
16
+
17
+ MASK64 = 0xFFFFFFFFFFFFFFFF
18
+
19
+ module_function
20
+
21
+ # Scalar multiplication: compute scalar * point on Curve25519.
22
+ #
23
+ # @param scalar [String] 32-byte scalar (private key)
24
+ # @param u_bytes [String] 32-byte u-coordinate (public key / base point)
25
+ # @return [String] 32-byte result u-coordinate
26
+ #
27
+ def scalarmult(scalar, u_bytes)
28
+ k = decode_scalar(scalar)
29
+ u = decode_u(u_bytes)
30
+
31
+ x_1 = u
32
+ x_2 = 1
33
+ z_2 = 0
34
+ x_3 = u
35
+ z_3 = 1
36
+
37
+ swap = 0
38
+
39
+ 254.downto(0) do |t|
40
+ k_t = (k >> t) & 1
41
+ swap ^= k_t
42
+ # Constant-time conditional swap (XOR mask)
43
+ mask = -swap
44
+ dummy = (x_2 ^ x_3) & mask; x_2 ^= dummy; x_3 ^= dummy
45
+ dummy = (z_2 ^ z_3) & mask; z_2 ^= dummy; z_3 ^= dummy
46
+ swap = k_t
47
+
48
+ a = (x_2 + z_2) % P
49
+ aa = (a * a) % P
50
+ b = (x_2 - z_2) % P
51
+ bb = (b * b) % P
52
+ e = (aa - bb) % P
53
+ c = (x_3 + z_3) % P
54
+ d = (x_3 - z_3) % P
55
+ da = (d * a) % P
56
+ cb = (c * b) % P
57
+
58
+ sum = (da + cb) % P; x_3 = (sum * sum) % P
59
+ dif = (da - cb) % P; z_3 = (x_1 * ((dif * dif) % P)) % P
60
+ x_2 = (aa * bb) % P
61
+ z_2 = (e * ((bb + A24 * e) % P)) % P
62
+ end
63
+
64
+ # Final cswap
65
+ mask = -swap
66
+ dummy = (x_2 ^ x_3) & mask; x_2 ^= dummy
67
+ dummy = (z_2 ^ z_3) & mask; z_2 ^= dummy
68
+
69
+ result = (x_2 * z_2.pow(P - 2, P)) % P
70
+ encode_u(result)
71
+ end
72
+
73
+ # Scalar multiplication with the standard base point (u=9).
74
+ #
75
+ # @param scalar [String] 32-byte scalar (private key)
76
+ # @return [String] 32-byte public key
77
+ #
78
+ def scalarmult_base(scalar)
79
+ scalarmult(scalar, "\x09".b + "\x00".b * 31)
80
+ end
81
+
82
+ # Decode a 32-byte scalar with clamping (per RFC 7748).
83
+ def decode_scalar(s)
84
+ buf = s.b.dup
85
+ buf.setbyte(0, buf.getbyte(0) & 248)
86
+ buf.setbyte(31, (buf.getbyte(31) & 127) | 64)
87
+ w = buf.unpack("Q<4")
88
+ w[0] | (w[1] << 64) | (w[2] << 128) | (w[3] << 192)
89
+ end
90
+
91
+ # Decode a u-coordinate from 32 bytes (little-endian, mask high bit).
92
+ def decode_u(u_bytes)
93
+ buf = u_bytes.b.dup
94
+ buf.setbyte(31, buf.getbyte(31) & 0x7F)
95
+ w = buf.unpack("Q<4")
96
+ w[0] | (w[1] << 64) | (w[2] << 128) | (w[3] << 192)
97
+ end
98
+
99
+ # Encode a field element to 32 bytes (little-endian).
100
+ def encode_u(u)
101
+ [u & MASK64, (u >> 64) & MASK64, (u >> 128) & MASK64, (u >> 192) & MASK64].pack("Q<4")
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ module Internals
5
+ # Poly1305 one-time message authenticator.
6
+ #
7
+ # Reference: https://cr.yp.to/mac/poly1305-20050329.pdf
8
+ # RFC 8439 Section 2.5
9
+ #
10
+ module Poly1305
11
+ P = (1 << 130) - 5
12
+
13
+ # Clamp mask for r (clear specific bits per spec)
14
+ R_CLAMP = 0x0ffffffc0ffffffc0ffffffc0fffffff
15
+
16
+ MASK128 = (1 << 128) - 1
17
+
18
+ module_function
19
+
20
+ # Compute Poly1305 MAC.
21
+ #
22
+ # @param key [String] 32-byte one-time key (r || s)
23
+ # @param message [String] message to authenticate
24
+ # @return [String] 16-byte authentication tag
25
+ #
26
+ def mac(key, message)
27
+ r = le_bytes_to_int(key, 0, 16) & R_CLAMP
28
+ s = le_bytes_to_int(key, 16, 16)
29
+
30
+ h = 0
31
+ msg = message.b
32
+ len = msg.bytesize
33
+ off = 0
34
+
35
+ while off < len
36
+ remaining = len - off
37
+ take = remaining < 16 ? remaining : 16
38
+ block = le_bytes_to_int(msg, off, take)
39
+
40
+ # Add high bit (2^(8*take)) to mark block as non-zero-padded
41
+ block |= 1 << (take * 8)
42
+
43
+ h = ((h + block) * r) % P
44
+ off += take
45
+ end
46
+
47
+ # Final: (h + s) mod 2^128
48
+ tag = (h + s) & MASK128
49
+ int_to_le_bytes(tag)
50
+ end
51
+
52
+ # Read little-endian integer from a string at offset, length bytes.
53
+ def le_bytes_to_int(bytes, offset, length)
54
+ if length == 16
55
+ lo, hi = bytes.unpack("@#{offset}Q<2")
56
+ lo | (hi << 64)
57
+ else
58
+ n = 0
59
+ length.times { |i| n |= bytes.getbyte(offset + i) << (8 * i) }
60
+ n
61
+ end
62
+ end
63
+
64
+ # Write a 128-bit integer as 16 little-endian bytes.
65
+ def int_to_le_bytes(n)
66
+ [n & 0xFFFFFFFFFFFFFFFF, (n >> 64) & 0xFFFFFFFFFFFFFFFF].pack("Q<2")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ module Internals
5
+ # Salsa20 stream cipher family: Salsa20 core, HSalsa20, XSalsa20.
6
+ #
7
+ # References:
8
+ # - https://cr.yp.to/snuffle/spec.pdf (Salsa20 specification)
9
+ # - https://cr.yp.to/snuffle/xsalsa-20110204.pdf (XSalsa20)
10
+ #
11
+ module Salsa20
12
+ MASK32 = 0xFFFFFFFF
13
+ SIGMA = "expand 32-byte k".b.freeze
14
+ SIGMA_V4 = SIGMA.unpack("V4").freeze
15
+
16
+ module_function
17
+
18
+ # Salsa20 core function: 20 rounds on 16 x 32-bit words.
19
+ # Returns the 64-byte output block.
20
+ #
21
+ # @param input [String] 64-byte input block
22
+ # @return [String] 64-byte output block
23
+ #
24
+ def core(input)
25
+ x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15 =
26
+ input.unpack("V16")
27
+
28
+ z0 = x0; z1 = x1; z2 = x2; z3 = x3
29
+ z4 = x4; z5 = x5; z6 = x6; z7 = x7
30
+ z8 = x8; z9 = x9; z10 = x10; z11 = x11
31
+ z12 = x12; z13 = x13; z14 = x14; z15 = x15
32
+
33
+ 10.times do
34
+ # Column rounds
35
+ t = (z0 + z12) & MASK32; z4 ^= ((t << 7) | (t >> 25)) & MASK32
36
+ t = (z4 + z0) & MASK32; z8 ^= ((t << 9) | (t >> 23)) & MASK32
37
+ t = (z8 + z4) & MASK32; z12 ^= ((t << 13) | (t >> 19)) & MASK32
38
+ t = (z12 + z8) & MASK32; z0 ^= ((t << 18) | (t >> 14)) & MASK32
39
+
40
+ t = (z5 + z1) & MASK32; z9 ^= ((t << 7) | (t >> 25)) & MASK32
41
+ t = (z9 + z5) & MASK32; z13 ^= ((t << 9) | (t >> 23)) & MASK32
42
+ t = (z13 + z9) & MASK32; z1 ^= ((t << 13) | (t >> 19)) & MASK32
43
+ t = (z1 + z13) & MASK32; z5 ^= ((t << 18) | (t >> 14)) & MASK32
44
+
45
+ t = (z10 + z6) & MASK32; z14 ^= ((t << 7) | (t >> 25)) & MASK32
46
+ t = (z14 + z10) & MASK32; z2 ^= ((t << 9) | (t >> 23)) & MASK32
47
+ t = (z2 + z14) & MASK32; z6 ^= ((t << 13) | (t >> 19)) & MASK32
48
+ t = (z6 + z2) & MASK32; z10 ^= ((t << 18) | (t >> 14)) & MASK32
49
+
50
+ t = (z15 + z11) & MASK32; z3 ^= ((t << 7) | (t >> 25)) & MASK32
51
+ t = (z3 + z15) & MASK32; z7 ^= ((t << 9) | (t >> 23)) & MASK32
52
+ t = (z7 + z3) & MASK32; z11 ^= ((t << 13) | (t >> 19)) & MASK32
53
+ t = (z11 + z7) & MASK32; z15 ^= ((t << 18) | (t >> 14)) & MASK32
54
+
55
+ # Row rounds
56
+ t = (z0 + z3) & MASK32; z1 ^= ((t << 7) | (t >> 25)) & MASK32
57
+ t = (z1 + z0) & MASK32; z2 ^= ((t << 9) | (t >> 23)) & MASK32
58
+ t = (z2 + z1) & MASK32; z3 ^= ((t << 13) | (t >> 19)) & MASK32
59
+ t = (z3 + z2) & MASK32; z0 ^= ((t << 18) | (t >> 14)) & MASK32
60
+
61
+ t = (z5 + z4) & MASK32; z6 ^= ((t << 7) | (t >> 25)) & MASK32
62
+ t = (z6 + z5) & MASK32; z7 ^= ((t << 9) | (t >> 23)) & MASK32
63
+ t = (z7 + z6) & MASK32; z4 ^= ((t << 13) | (t >> 19)) & MASK32
64
+ t = (z4 + z7) & MASK32; z5 ^= ((t << 18) | (t >> 14)) & MASK32
65
+
66
+ t = (z10 + z9) & MASK32; z11 ^= ((t << 7) | (t >> 25)) & MASK32
67
+ t = (z11 + z10) & MASK32; z8 ^= ((t << 9) | (t >> 23)) & MASK32
68
+ t = (z8 + z11) & MASK32; z9 ^= ((t << 13) | (t >> 19)) & MASK32
69
+ t = (z9 + z8) & MASK32; z10 ^= ((t << 18) | (t >> 14)) & MASK32
70
+
71
+ t = (z15 + z14) & MASK32; z12 ^= ((t << 7) | (t >> 25)) & MASK32
72
+ t = (z12 + z15) & MASK32; z13 ^= ((t << 9) | (t >> 23)) & MASK32
73
+ t = (z13 + z12) & MASK32; z14 ^= ((t << 13) | (t >> 19)) & MASK32
74
+ t = (z14 + z13) & MASK32; z15 ^= ((t << 18) | (t >> 14)) & MASK32
75
+ end
76
+
77
+ [
78
+ (z0 + x0) & MASK32, (z1 + x1) & MASK32, (z2 + x2) & MASK32, (z3 + x3) & MASK32,
79
+ (z4 + x4) & MASK32, (z5 + x5) & MASK32, (z6 + x6) & MASK32, (z7 + x7) & MASK32,
80
+ (z8 + x8) & MASK32, (z9 + x9) & MASK32, (z10 + x10) & MASK32, (z11 + x11) & MASK32,
81
+ (z12 + x12) & MASK32, (z13 + x13) & MASK32, (z14 + x14) & MASK32, (z15 + x15) & MASK32,
82
+ ].pack("V16")
83
+ end
84
+
85
+ # HSalsa20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
86
+ # Returns words [0,5,10,15,6,7,8,9] from the NON-added state.
87
+ #
88
+ # @param key [String] 32-byte key
89
+ # @param nonce [String] 16-byte nonce
90
+ # @return [String] 32-byte subkey
91
+ #
92
+ def hsalsa20(key, nonce)
93
+ k0, k1, k2, k3, k4, k5, k6, k7 = key.unpack("V8")
94
+ n0, n1, n2, n3 = nonce.unpack("V4")
95
+ s0, s1, s2, s3 = SIGMA_V4
96
+
97
+ z0 = s0; z1 = k0; z2 = k1; z3 = k2
98
+ z4 = k3; z5 = s1; z6 = n0; z7 = n1
99
+ z8 = n2; z9 = n3; z10 = s2; z11 = k4
100
+ z12 = k5; z13 = k6; z14 = k7; z15 = s3
101
+
102
+ 10.times do
103
+ t = (z0 + z12) & MASK32; z4 ^= ((t << 7) | (t >> 25)) & MASK32
104
+ t = (z4 + z0) & MASK32; z8 ^= ((t << 9) | (t >> 23)) & MASK32
105
+ t = (z8 + z4) & MASK32; z12 ^= ((t << 13) | (t >> 19)) & MASK32
106
+ t = (z12 + z8) & MASK32; z0 ^= ((t << 18) | (t >> 14)) & MASK32
107
+
108
+ t = (z5 + z1) & MASK32; z9 ^= ((t << 7) | (t >> 25)) & MASK32
109
+ t = (z9 + z5) & MASK32; z13 ^= ((t << 9) | (t >> 23)) & MASK32
110
+ t = (z13 + z9) & MASK32; z1 ^= ((t << 13) | (t >> 19)) & MASK32
111
+ t = (z1 + z13) & MASK32; z5 ^= ((t << 18) | (t >> 14)) & MASK32
112
+
113
+ t = (z10 + z6) & MASK32; z14 ^= ((t << 7) | (t >> 25)) & MASK32
114
+ t = (z14 + z10) & MASK32; z2 ^= ((t << 9) | (t >> 23)) & MASK32
115
+ t = (z2 + z14) & MASK32; z6 ^= ((t << 13) | (t >> 19)) & MASK32
116
+ t = (z6 + z2) & MASK32; z10 ^= ((t << 18) | (t >> 14)) & MASK32
117
+
118
+ t = (z15 + z11) & MASK32; z3 ^= ((t << 7) | (t >> 25)) & MASK32
119
+ t = (z3 + z15) & MASK32; z7 ^= ((t << 9) | (t >> 23)) & MASK32
120
+ t = (z7 + z3) & MASK32; z11 ^= ((t << 13) | (t >> 19)) & MASK32
121
+ t = (z11 + z7) & MASK32; z15 ^= ((t << 18) | (t >> 14)) & MASK32
122
+
123
+ t = (z0 + z3) & MASK32; z1 ^= ((t << 7) | (t >> 25)) & MASK32
124
+ t = (z1 + z0) & MASK32; z2 ^= ((t << 9) | (t >> 23)) & MASK32
125
+ t = (z2 + z1) & MASK32; z3 ^= ((t << 13) | (t >> 19)) & MASK32
126
+ t = (z3 + z2) & MASK32; z0 ^= ((t << 18) | (t >> 14)) & MASK32
127
+
128
+ t = (z5 + z4) & MASK32; z6 ^= ((t << 7) | (t >> 25)) & MASK32
129
+ t = (z6 + z5) & MASK32; z7 ^= ((t << 9) | (t >> 23)) & MASK32
130
+ t = (z7 + z6) & MASK32; z4 ^= ((t << 13) | (t >> 19)) & MASK32
131
+ t = (z4 + z7) & MASK32; z5 ^= ((t << 18) | (t >> 14)) & MASK32
132
+
133
+ t = (z10 + z9) & MASK32; z11 ^= ((t << 7) | (t >> 25)) & MASK32
134
+ t = (z11 + z10) & MASK32; z8 ^= ((t << 9) | (t >> 23)) & MASK32
135
+ t = (z8 + z11) & MASK32; z9 ^= ((t << 13) | (t >> 19)) & MASK32
136
+ t = (z9 + z8) & MASK32; z10 ^= ((t << 18) | (t >> 14)) & MASK32
137
+
138
+ t = (z15 + z14) & MASK32; z12 ^= ((t << 7) | (t >> 25)) & MASK32
139
+ t = (z12 + z15) & MASK32; z13 ^= ((t << 9) | (t >> 23)) & MASK32
140
+ t = (z13 + z12) & MASK32; z14 ^= ((t << 13) | (t >> 19)) & MASK32
141
+ t = (z14 + z13) & MASK32; z15 ^= ((t << 18) | (t >> 14)) & MASK32
142
+ end
143
+
144
+ # Return words [0,5,10,15,6,7,8,9] — the diagonal + second row
145
+ [z0, z5, z10, z15, z6, z7, z8, z9].pack("V8")
146
+ end
147
+
148
+ # XSalsa20 stream XOR: encrypts/decrypts message using 32-byte key and 24-byte nonce.
149
+ #
150
+ # @param key [String] 32-byte key
151
+ # @param nonce [String] 24-byte nonce
152
+ # @param message [String] plaintext/ciphertext
153
+ # @return [String] XOR'd output (same length as message)
154
+ #
155
+ def xsalsa20_xor(key, nonce, message)
156
+ subkey = hsalsa20(key, nonce.byteslice(0, 16))
157
+ sub_nonce = (nonce.byteslice(16, 8) + "\x00\x00\x00\x00\x00\x00\x00\x00").b
158
+ salsa20_xor(subkey, sub_nonce, message)
159
+ end
160
+
161
+ # Salsa20 stream XOR with 32-byte key and 16-byte nonce/counter.
162
+ #
163
+ # @param key [String] 32-byte key
164
+ # @param nonce [String] 16-byte (8-byte nonce + 8-byte counter, LE)
165
+ # @param message [String] plaintext/ciphertext
166
+ # @return [String] XOR'd output
167
+ #
168
+ def salsa20_xor(key, nonce, message)
169
+ k0, k1, k2, k3, k4, k5, k6, k7 = key.unpack("V8")
170
+ n0, n1, n2, n3 = nonce.unpack("V4")
171
+ s0, s1, s2, s3 = SIGMA_V4
172
+
173
+ msg = message.b
174
+ len = msg.bytesize
175
+ out = String.new(capacity: len, encoding: Encoding::BINARY)
176
+ offset = 0
177
+
178
+ while offset < len
179
+ # Build + run Salsa20 core inline
180
+ block = core([
181
+ s0, k0, k1, k2,
182
+ k3, s1, n0, n1,
183
+ n2, n3, s2, k4,
184
+ k5, k6, k7, s3,
185
+ ].pack("V16"))
186
+
187
+ # XOR message bytes with keystream block
188
+ remaining = len - offset
189
+ take = remaining < 64 ? remaining : 64
190
+
191
+ if take == 64
192
+ # Fast path: XOR 8 x uint64
193
+ m = msg.byteslice(offset, 64).unpack("Q<8")
194
+ b = block.unpack("Q<8")
195
+ out << [m[0] ^ b[0], m[1] ^ b[1], m[2] ^ b[2], m[3] ^ b[3],
196
+ m[4] ^ b[4], m[5] ^ b[5], m[6] ^ b[6], m[7] ^ b[7]].pack("Q<8")
197
+ else
198
+ # Tail: XOR byte-by-byte
199
+ m = msg.byteslice(offset, take).unpack("C*")
200
+ b = block.unpack("C*")
201
+ take.times { |i| m[i] ^= b[i] }
202
+ out << m.pack("C*")
203
+ end
204
+ offset += take
205
+
206
+ # Increment 64-bit counter (words n2, n3)
207
+ counter = n2 | (n3 << 32)
208
+ counter += 1
209
+ n2 = counter & MASK32
210
+ n3 = (counter >> 32) & MASK32
211
+ end
212
+
213
+ out
214
+ end
215
+
216
+ # Generate XSalsa20 keystream (no XOR, just the stream bytes).
217
+ #
218
+ # @param key [String] 32-byte key
219
+ # @param nonce [String] 24-byte nonce
220
+ # @param length [Integer] number of bytes to generate
221
+ # @return [String] keystream bytes
222
+ #
223
+ def xsalsa20_stream(key, nonce, length)
224
+ xsalsa20_xor(key, nonce, "\x00".b * length)
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ class PrivateKey
5
+ BYTES = 32
6
+
7
+ def self.generate
8
+ new(Random.random_bytes(BYTES))
9
+ end
10
+
11
+ def initialize(key)
12
+ key = key.to_s if key.respond_to?(:to_s) && !key.is_a?(String)
13
+ key = key.b
14
+ raise ArgumentError, "private key must be #{BYTES} bytes (got #{key.bytesize})" unless key.bytesize == BYTES
15
+
16
+ @key = key
17
+ end
18
+
19
+ def public_key
20
+ PublicKey.new(Internals::Curve25519.scalarmult_base(@key))
21
+ end
22
+
23
+ def to_bytes = @key
24
+ def to_s = @key
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ class PublicKey
5
+ BYTES = 32
6
+
7
+ def initialize(key)
8
+ key = key.to_s if key.respond_to?(:to_s) && !key.is_a?(String)
9
+ key = key.b
10
+ raise ArgumentError, "public key must be #{BYTES} bytes (got #{key.bytesize})" unless key.bytesize == BYTES
11
+
12
+ @key = key
13
+ end
14
+
15
+ def to_bytes = @key
16
+ def to_s = @key
17
+
18
+ def ==(other)
19
+ other.is_a?(PublicKey) && Util.verify32(@key, other.to_s)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Nuckle
6
+ module Random
7
+ def self.random_bytes(n)
8
+ SecureRandom.random_bytes(n)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ # Symmetric authenticated encryption: XSalsa20-Poly1305.
5
+ #
6
+ # Compatible with NaCl crypto_secretbox / libsodium crypto_secretbox_xsalsa20poly1305.
7
+ #
8
+ class SecretBox
9
+ KEYBYTES = 32
10
+ NONCEBYTES = 24
11
+ ZEROBYTES = 32
12
+ BOXZEROBYTES = 16
13
+ MACBYTES = 16
14
+
15
+ def initialize(key)
16
+ key = key.b
17
+ raise ArgumentError, "key must be #{KEYBYTES} bytes (got #{key.bytesize})" unless key.bytesize == KEYBYTES
18
+
19
+ @key = key
20
+ end
21
+
22
+ def nonce_bytes = NONCEBYTES
23
+ def key_bytes = KEYBYTES
24
+
25
+ # Encrypt plaintext with 24-byte nonce.
26
+ #
27
+ # @param nonce [String] 24-byte nonce
28
+ # @param plaintext [String]
29
+ # @return [String] authenticator (16 bytes) + ciphertext
30
+ #
31
+ def encrypt(nonce, plaintext)
32
+ nonce = nonce.b
33
+ plaintext = plaintext.b
34
+ raise ArgumentError, "nonce must be #{NONCEBYTES} bytes" unless nonce.bytesize == NONCEBYTES
35
+
36
+ # Pad with 32 zero bytes
37
+ padded = ("\x00" * ZEROBYTES + plaintext).b
38
+ c = Internals::Salsa20.xsalsa20_xor(@key, nonce, padded)
39
+
40
+ # First 32 bytes of c: bytes 0..31 of XSalsa20 keystream XOR'd with zeros
41
+ # → bytes 0..31 ARE the keystream. Use first 32 as Poly1305 one-time key.
42
+ poly_key = c.byteslice(0, ZEROBYTES)
43
+ mac = Internals::Poly1305.mac(poly_key, c.byteslice(ZEROBYTES..))
44
+
45
+ # Return: 16-byte MAC + ciphertext (skip first 16 zero bytes of c)
46
+ # Actually NaCl convention: c[0..15] are zeros, c[16..31] replaced by mac
47
+ mac + c.byteslice(ZEROBYTES..)
48
+ end
49
+
50
+ # Decrypt ciphertext with 24-byte nonce.
51
+ #
52
+ # @param nonce [String] 24-byte nonce
53
+ # @param ciphertext [String] authenticator (16 bytes) + ciphertext
54
+ # @return [String] plaintext
55
+ # @raise [CryptoError] on authentication failure
56
+ #
57
+ def decrypt(nonce, ciphertext)
58
+ nonce = nonce.b
59
+ ciphertext = ciphertext.b
60
+ raise ArgumentError, "nonce must be #{NONCEBYTES} bytes" unless nonce.bytesize == NONCEBYTES
61
+ raise CryptoError, "ciphertext too short" if ciphertext.bytesize < MACBYTES
62
+
63
+ mac = ciphertext.byteslice(0, MACBYTES)
64
+ ct = ciphertext.byteslice(MACBYTES..)
65
+
66
+ # Generate Poly1305 key from first 32 bytes of XSalsa20 keystream
67
+ poly_key = Internals::Salsa20.xsalsa20_stream(@key, nonce, ZEROBYTES)
68
+
69
+ # Verify MAC
70
+ expected_mac = Internals::Poly1305.mac(poly_key, ct)
71
+ raise CryptoError, "decryption failed" unless Util.verify16(mac, expected_mac)
72
+
73
+ # Decrypt
74
+ padded = ("\x00" * ZEROBYTES + ct).b
75
+ m = Internals::Salsa20.xsalsa20_xor(@key, nonce, padded)
76
+ m.byteslice(ZEROBYTES..)
77
+ end
78
+
79
+ # Aliases matching rbnacl API
80
+ alias box encrypt
81
+ alias open decrypt
82
+ end
83
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ module Util
5
+ # Compare two 16-byte strings.
6
+ def self.verify16(a, b)
7
+ verify(a, b, 16)
8
+ end
9
+
10
+ # Compare two 32-byte strings.
11
+ def self.verify32(a, b)
12
+ verify(a, b, 32)
13
+ end
14
+
15
+ # Compare two 64-byte strings.
16
+ def self.verify64(a, b)
17
+ verify(a, b, 64)
18
+ end
19
+
20
+ def self.verify(a, b, expected_size = nil)
21
+ a = a.b if a.encoding != Encoding::BINARY
22
+ b = b.b if b.encoding != Encoding::BINARY
23
+ return false if expected_size && (a.bytesize != expected_size || b.bytesize != expected_size)
24
+
25
+ a == b
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuckle
4
+ VERSION = "0.1.1"
5
+ end
data/lib/nuckle.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nuckle/version"
4
+ require_relative "nuckle/crypto_error"
5
+ require_relative "nuckle/random"
6
+ require_relative "nuckle/util"
7
+ require_relative "nuckle/internals/curve25519"
8
+ require_relative "nuckle/internals/salsa20"
9
+ require_relative "nuckle/internals/poly1305"
10
+ require_relative "nuckle/public_key"
11
+ require_relative "nuckle/private_key"
12
+ require_relative "nuckle/box"
13
+ require_relative "nuckle/secret_box"
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nuckle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Patrik Wenger
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Pure Ruby implementation of the NaCl crypto primitives (Curve25519, XSalsa20,
13
+ Poly1305). No C extensions, no FFI, no libsodium. Is it any good? No.
14
+ email:
15
+ - paddor@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/nuckle.rb
23
+ - lib/nuckle/box.rb
24
+ - lib/nuckle/crypto_error.rb
25
+ - lib/nuckle/internals/curve25519.rb
26
+ - lib/nuckle/internals/poly1305.rb
27
+ - lib/nuckle/internals/salsa20.rb
28
+ - lib/nuckle/private_key.rb
29
+ - lib/nuckle/public_key.rb
30
+ - lib/nuckle/random.rb
31
+ - lib/nuckle/secret_box.rb
32
+ - lib/nuckle/util.rb
33
+ - lib/nuckle/version.rb
34
+ homepage: https://github.com/paddor/nuckle
35
+ licenses:
36
+ - ISC
37
+ metadata: {}
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '3.3'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 4.0.6
53
+ specification_version: 4
54
+ summary: Pure Ruby NaCl crypto primitives
55
+ test_files: []