hide-protocol 0.5.0

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: c2a953028799de42c33b2d4500308cdc35dd51f0ab3a421135869f9b7f22659f
4
+ data.tar.gz: b666943d4bed7075eb7899e3ac5a5b9c7475db0cd6cca222e7ed29cca8b158ee
5
+ SHA512:
6
+ metadata.gz: b4e375e687c8c6dbdfec9bc487fbf0b8aedc32cf1e72f7139d018e5922ed9bf11f4951dd9cef88893b9cdd4642f46ef82e4c796a4f365135ed354228d25665aa
7
+ data.tar.gz: 99059cec81d10c21228681a9ea36a2985f944e3e34379e03921cc35a3a3a6106733ed26899d7ec701280e48ab897356b2a0a9acf040b299cf1f6d307400a2a25
data/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ The full license text is available at the URL above.
data/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # hide-protocol (Ruby)
2
+
3
+ Ruby bindings to the HIDE core: hybrid post-quantum encryption for files and
4
+ messages (X25519 + ML-KEM-768).
5
+
6
+ ## Read this before using it
7
+
8
+ **This is experimental and unaudited.** Do not use it to protect data you
9
+ cannot afford to lose or expose. The format may change.
10
+
11
+ **A successful decryption proves the data was not altered. It does *not* prove
12
+ who sent it.** Anyone holding your public key can produce a container that
13
+ decrypts for you. If you need to know the sender, sign the message with
14
+ `Hide::SigningIdentity` and verify it separately.
15
+
16
+ A decrypted filename is attacker-controlled. Never use it to choose an output
17
+ path.
18
+
19
+ ## How it binds
20
+
21
+ There is no Ruby cryptography here, and no compiled extension. The gem calls
22
+ the same Rust core as the CLI through its C ABI, using stdlib `fiddle` — so
23
+ installing it needs no compiler and adds no runtime gem dependency.
24
+
25
+ The native library is found in this order:
26
+
27
+ 1. `HIDE_LIBRARY`, if set — the full path to the shared library.
28
+ 2. Beside the gem, in `lib/hide_protocol/`, where the release workflow puts it.
29
+ 3. The system loader.
30
+
31
+ Platform names are `hide_ffi.dll` on Windows, `libhide_ffi.dylib` on macOS and
32
+ `libhide_ffi.so` elsewhere.
33
+
34
+ ### Running against a local build
35
+
36
+ ```sh
37
+ cargo build -p hide-ffi
38
+ ```
39
+
40
+ Then point `HIDE_LIBRARY` at the result:
41
+
42
+ ```sh
43
+ # Linux
44
+ export HIDE_LIBRARY="$PWD/target/debug/libhide_ffi.so"
45
+ # macOS
46
+ export HIDE_LIBRARY="$PWD/target/debug/libhide_ffi.dylib"
47
+ ```
48
+
49
+ ```powershell
50
+ # Windows
51
+ $env:HIDE_LIBRARY = "$PWD\target\debug\hide_ffi.dll"
52
+ ```
53
+
54
+ ## Use
55
+
56
+ ```ruby
57
+ require "hide_protocol"
58
+
59
+ Hide::SecretKey.generate do |secret|
60
+ box = Hide.encrypt(
61
+ File.binread("salarii.csv"),
62
+ recipients: [secret.public_key],
63
+ filename: "salarii.csv",
64
+ media_type: "text/csv"
65
+ )
66
+
67
+ opened = Hide.decrypt(box, secret)
68
+ opened.plaintext # => the original bytes
69
+ opened.filename # => "salarii.csv"
70
+ opened.media_type # => "text/csv"
71
+ end
72
+ ```
73
+
74
+ The block form always closes the key, including when the block raises. Without
75
+ a block, call `#close` yourself.
76
+
77
+ ### Keys
78
+
79
+ ```ruby
80
+ secret = Hide::SecretKey.generate
81
+ public_key = secret.public_key # 1216 bytes, binary
82
+
83
+ sealed = secret.protect("a long passphrase") # for writing to disk
84
+ Hide.inspect_key(sealed) # => "protected"
85
+
86
+ reopened = Hide::SecretKey.open(sealed, "a long passphrase")
87
+ ```
88
+
89
+ A passphrase must be at least `Hide::MIN_PASSPHRASE_LEN` (8) characters. A
90
+ forgotten passphrase cannot be recovered: there is no escrow.
91
+
92
+ Secret key material never crosses into Ruby. `inspect` and `to_s` report only
93
+ whether the key is open or closed, and a closed key raises on any use.
94
+
95
+ ### Sharing a public key
96
+
97
+ ```ruby
98
+ text = Hide.armor_public_key(public_key) # "hide-public-key:..."
99
+ Hide.dearmor_public_key(text) # back to bytes
100
+ ```
101
+
102
+ ### Signing
103
+
104
+ Encryption and signing share one seed, so there is a single thing to back up.
105
+
106
+ ```ruby
107
+ sealed = Hide::SigningIdentity.generate("a long passphrase") # write this to disk
108
+
109
+ Hide::SigningIdentity.open(sealed, "a long passphrase") do |signer|
110
+ public_key = signer.public_key # 1984 bytes, shareable
111
+ signature = signer.sign("myapp/v1 release", bytes) # 3373 bytes
112
+ Hide.verify(public_key, "myapp/v1 release", bytes, signature)
113
+ end
114
+ ```
115
+
116
+ `verify` returns `nil` and raises on failure, rather than returning a boolean a
117
+ caller could forget to test. The context string separates uses of one identity:
118
+ never let a remote party choose it.
119
+
120
+ ### Proving possession live
121
+
122
+ A detached signature proves possession at some point, to nobody in particular,
123
+ and can be replayed. A challenge binds a nonce, an audience and an expiry.
124
+
125
+ ```ruby
126
+ challenge = Hide.new_challenge("ssh://host.example", Time.now.to_i, 60)
127
+ answer = signer.answer(challenge)
128
+
129
+ spent = Hide::SpentNonces.new # must outlive the request
130
+ spent.accept(challenge, answer, public_key, Time.now.to_i)
131
+ ```
132
+
133
+ The second acceptance of the same answer raises `ChallengeReplayedError`, and
134
+ one presented after the window raises `ChallengeExpiredError`.
135
+
136
+ ## API
137
+
138
+ | | |
139
+ |---|---|
140
+ | `Hide.version` | version of the native core |
141
+ | `Hide.encrypt(plaintext, recipients:, filename: nil, media_type: nil)` | binary String |
142
+ | `Hide.decrypt(container, secret)` | `Decrypted` with `plaintext`, `filename`, `media_type` |
143
+ | `Hide.armor_public_key` / `Hide.dearmor_public_key` | text form of a public key |
144
+ | `Hide.inspect_key(bytes)` | `"raw"` or `"protected"` |
145
+ | `Hide::SecretKey.generate` / `.open(bytes, passphrase = nil)` | keys |
146
+ | `Hide::SigningIdentity.generate(passphrase)` | sealed key file bytes |
147
+ | `Hide::SigningIdentity.open(bytes, passphrase = nil)` | `#public_key`, `#sign`, `#answer`, `#close` |
148
+ | `Hide.verify(public_key, context, message, signature)` | raises unless it verifies |
149
+ | `Hide.new_challenge(audience, now, valid_for)` | challenge bytes |
150
+ | `Hide::SpentNonces#accept(challenge, signature, public_key, now)` | accepts once |
151
+ | `Hide::PUBLIC_KEY_LEN` (1216), `Hide::MIN_PASSPHRASE_LEN` (8) | constants |
152
+ | `Hide::SIGNATURE_LEN` (3373), `Hide::VERIFYING_KEY_LEN` (1984), `Hide::NONCE_LEN` (32) | constants |
153
+
154
+ Encryption takes between 1 and 64 recipients, each public key exactly
155
+ `Hide::PUBLIC_KEY_LEN` bytes.
156
+
157
+ All binary values in and out are ASCII-8BIT (binary) Strings. Metadata comes
158
+ back as UTF-8.
159
+
160
+ ### Errors
161
+
162
+ Everything raises a subclass of `Hide::Error`:
163
+
164
+ `InvalidArgumentError`, `AuthenticationError` (altered data, or not a
165
+ container), `WrongPassphraseError`, `NoMatchingRecipientError` (this key was
166
+ not a recipient), `NotAKeyError`, `TooLargeError`, `ClosedKeyError`,
167
+ `ChallengeExpiredError`, `ChallengeReplayedError`.
168
+
169
+ ## Tests
170
+
171
+ ```sh
172
+ HIDE_LIBRARY=/path/to/libhide_ffi.so ruby -Ilib -Itest test/test_hide.rb
173
+ ```
174
+
175
+ ## Licence
176
+
177
+ Apache-2.0.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "rbconfig"
5
+
6
+ module Hide
7
+ # Raw access to the HIDE C core.
8
+ #
9
+ # fiddle rather than the ffi gem: fiddle ships with Ruby, so installing this
10
+ # gem needs no compiler and adds no runtime dependency, and the ABI is
11
+ # described in exactly one place.
12
+ module Binding
13
+ OK = 0
14
+ ERR_INVALID_ARGUMENT = 1
15
+ ERR_WRONG_PASSPHRASE = 2
16
+ ERR_NOT_A_KEY = 3
17
+ ERR_AUTHENTICATION = 4
18
+ ERR_NO_MATCHING_RECIPIENT = 5
19
+ ERR_TOO_LARGE = 7
20
+ ERR_MALFORMED = 6
21
+ ERR_CHALLENGE_EXPIRED = 8
22
+ ERR_CHALLENGE_REPLAYED = 9
23
+ ERR_PANIC = 98
24
+ ERR_INTERNAL = 99
25
+
26
+ KEY_RAW = 0
27
+ KEY_PROTECTED = 1
28
+
29
+ PUBLIC_KEY_LEN = 1216
30
+ SIGNATURE_LEN = 3373
31
+ VERIFYING_KEY_LEN = 1984
32
+ NONCE_LEN = 32
33
+ MIN_PASSPHRASE_LEN = 8
34
+
35
+ WORD = Fiddle::SIZEOF_VOIDP
36
+ # HideBuffer is { uint8_t *data; size_t len; size_t capacity; }.
37
+ BUFFER_SIZE = WORD * 3
38
+ # size_t and a pointer are the same width on every platform Ruby builds on.
39
+ WORD_PACK = WORD == 8 ? "J" : "L"
40
+
41
+ class LibraryNotFound < StandardError; end
42
+
43
+ def self.library_names
44
+ case RbConfig::CONFIG["host_os"]
45
+ when /mswin|mingw|cygwin/ then ["hide_ffi.dll"]
46
+ when /darwin/ then ["libhide_ffi.dylib"]
47
+ else ["libhide_ffi.so"]
48
+ end
49
+ end
50
+
51
+ # Mirrors the Python binding: an explicit override, then the copy shipped
52
+ # beside the gem, then whatever the system loader can find.
53
+ def self.load_library
54
+ here = File.dirname(__FILE__)
55
+ candidates = library_names.map { |name| File.join(here, name) }
56
+
57
+ override = ENV["HIDE_LIBRARY"]
58
+ candidates.unshift(override) if override && !override.empty?
59
+
60
+ candidates.each do |candidate|
61
+ return Fiddle.dlopen(candidate) if File.exist?(candidate)
62
+ end
63
+
64
+ begin
65
+ Fiddle.dlopen(library_names.first)
66
+ rescue Fiddle::DLError
67
+ raise LibraryNotFound,
68
+ "the HIDE native library was not found. Install a gem that " \
69
+ "bundles it, or set HIDE_LIBRARY to the path of " \
70
+ "#{library_names.first} built by `cargo build -p hide-ffi`."
71
+ end
72
+ end
73
+
74
+ LIB = load_library
75
+
76
+ VOIDP = Fiddle::TYPE_VOIDP
77
+ INT32 = Fiddle::TYPE_INT
78
+ SIZE_T = Fiddle::TYPE_SIZE_T
79
+ VOID = Fiddle::TYPE_VOID
80
+ UINT64 = Fiddle::TYPE_LONG_LONG
81
+
82
+ SIGNATURES = {
83
+ hide_error_message: [[INT32], VOIDP],
84
+ hide_version: [[], VOIDP],
85
+ # A 24-byte struct return is passed through a hidden out-pointer by both
86
+ # the SysV and the Windows x64 ABI, so binding it this way is the
87
+ # portable spelling; see empty_buffer for the fallback.
88
+ hide_buffer_empty: [[VOIDP], VOID],
89
+ hide_buffer_free: [[VOIDP], VOID],
90
+ hide_keypair_generate: [[VOIDP, VOIDP], INT32],
91
+ hide_inspect_key: [[VOIDP, SIZE_T, VOIDP], INT32],
92
+ hide_secret_key_open: [[VOIDP, SIZE_T, VOIDP, VOIDP], INT32],
93
+ hide_secret_key_protect: [[VOIDP, VOIDP, VOIDP], INT32],
94
+ hide_secret_key_public: [[VOIDP, VOIDP], INT32],
95
+ hide_secret_key_free: [[VOIDP], VOID],
96
+ hide_public_key_armor: [[VOIDP, SIZE_T, VOIDP], INT32],
97
+ hide_public_key_dearmor: [[VOIDP, VOIDP], INT32],
98
+ hide_encrypt: [[VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, VOIDP, VOIDP], INT32],
99
+ hide_decrypt: [[VOIDP, SIZE_T, VOIDP, VOIDP, VOIDP, VOIDP], INT32],
100
+ hide_identity_generate: [[VOIDP, VOIDP], INT32],
101
+ hide_signing_identity_open: [[VOIDP, SIZE_T, VOIDP, VOIDP], INT32],
102
+ hide_signing_identity_public: [[VOIDP, VOIDP], INT32],
103
+ hide_signing_identity_free: [[VOIDP], VOID],
104
+ hide_sign_message: [[VOIDP, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT32],
105
+ hide_verify_message: [[VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T], INT32],
106
+ hide_challenge_new: [[VOIDP, UINT64, UINT64, VOIDP], INT32],
107
+ hide_challenge_answer: [[VOIDP, VOIDP, SIZE_T, VOIDP], INT32],
108
+ hide_spent_nonces_new: [[], VOIDP],
109
+ hide_spent_nonces_free: [[VOIDP], VOID],
110
+ hide_challenge_accept: [[VOIDP, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T, UINT64], INT32]
111
+ }.freeze
112
+
113
+ FUNCTIONS = SIGNATURES.each_with_object({}) do |(name, (args, ret)), acc|
114
+ acc[name] = Fiddle::Function.new(LIB[name.to_s], args, ret, name: name.to_s)
115
+ end.freeze
116
+
117
+ def self.call(name, *args)
118
+ FUNCTIONS.fetch(name).call(*args)
119
+ end
120
+
121
+ # A static C string owned by the library; never freed by us.
122
+ def self.static_string(pointer)
123
+ return "" if pointer.null?
124
+
125
+ Fiddle::Pointer.new(pointer.to_i).to_s.force_encoding(Encoding::UTF_8)
126
+ end
127
+
128
+ # Scratch space for one HideBuffer, zeroed the way hide_buffer_empty zeroes it.
129
+ def self.empty_buffer
130
+ slot = Fiddle::Pointer.malloc(BUFFER_SIZE, Fiddle::RUBY_FREE)
131
+ slot[0, BUFFER_SIZE] = "\x00" * BUFFER_SIZE
132
+ call(:hide_buffer_empty, slot)
133
+ slot
134
+ end
135
+
136
+ def self.read_word(slot, index)
137
+ slot[index * WORD, WORD].unpack1(WORD_PACK)
138
+ end
139
+
140
+ # Copies a native buffer out and frees the original. The length field is
141
+ # the only thing consulted: text from this library is length-prefixed, not
142
+ # NUL-terminated, because metadata is attacker-controlled and a NUL in it
143
+ # would silently truncate a C-string read.
144
+ def self.take(slot)
145
+ data = read_word(slot, 0)
146
+ len = read_word(slot, 1)
147
+ bytes =
148
+ if data.zero? || len.zero?
149
+ +""
150
+ else
151
+ Fiddle::Pointer.new(data, len)[0, len]
152
+ end
153
+ bytes.force_encoding(Encoding::BINARY)
154
+ ensure
155
+ call(:hide_buffer_free, slot)
156
+ end
157
+
158
+ # Metadata arrives as length-prefixed UTF-8; empty means absent.
159
+ def self.take_text(slot)
160
+ raw = take(slot)
161
+ return nil if raw.empty?
162
+
163
+ raw.dup.force_encoding(Encoding::UTF_8).scrub
164
+ end
165
+
166
+ # A slot holding one pointer-sized out-parameter.
167
+ def self.pointer_slot
168
+ slot = Fiddle::Pointer.malloc(WORD, Fiddle::RUBY_FREE)
169
+ slot[0, WORD] = "\x00" * WORD
170
+ slot
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,543 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hide_protocol/binding"
4
+
5
+ # HIDE — encrypt to a person, not to a key.
6
+ #
7
+ # EXPERIMENTAL AND UNAUDITED. Do not protect data you cannot afford to lose or
8
+ # expose. A successful decryption proves the data was not altered; it does
9
+ # *not* prove who created it.
10
+ #
11
+ # secret = Hide::SecretKey.generate
12
+ # box = Hide.encrypt("hello", recipients: [secret.public_key])
13
+ # Hide.decrypt(box, secret).plaintext # => "hello"
14
+ module Hide
15
+ PUBLIC_KEY_LEN = Binding::PUBLIC_KEY_LEN
16
+ SIGNATURE_LEN = Binding::SIGNATURE_LEN
17
+ VERIFYING_KEY_LEN = Binding::VERIFYING_KEY_LEN
18
+ NONCE_LEN = Binding::NONCE_LEN
19
+ MIN_PASSPHRASE_LEN = Binding::MIN_PASSPHRASE_LEN
20
+
21
+ MAX_RECIPIENTS = 64
22
+
23
+ # Base class for every failure this library reports.
24
+ class Error < StandardError; end
25
+
26
+ # An argument this library refused before it reached the core.
27
+ class InvalidArgumentError < Error; end
28
+
29
+ # The data was altered, or is not a HIDE container.
30
+ class AuthenticationError < Error; end
31
+
32
+ # The passphrase is wrong, or the key file was modified.
33
+ class WrongPassphraseError < Error; end
34
+
35
+ # This key was not one of the recipients.
36
+ class NoMatchingRecipientError < Error; end
37
+
38
+ # The bytes are not a HIDE key.
39
+ class NotAKeyError < Error; end
40
+
41
+ # More data than the format admits.
42
+ class TooLargeError < Error; end
43
+
44
+ # The key has been closed and its material released.
45
+ class ClosedKeyError < Error; end
46
+
47
+ # The challenge expired before it was answered.
48
+ class ChallengeExpiredError < Error; end
49
+
50
+ # This challenge was already answered. Almost certainly a replay.
51
+ class ChallengeReplayedError < Error; end
52
+
53
+ ERRORS = {
54
+ Binding::ERR_INVALID_ARGUMENT => InvalidArgumentError,
55
+ Binding::ERR_WRONG_PASSPHRASE => WrongPassphraseError,
56
+ Binding::ERR_NOT_A_KEY => NotAKeyError,
57
+ Binding::ERR_AUTHENTICATION => AuthenticationError,
58
+ Binding::ERR_NO_MATCHING_RECIPIENT => NoMatchingRecipientError,
59
+ Binding::ERR_MALFORMED => AuthenticationError,
60
+ Binding::ERR_TOO_LARGE => TooLargeError,
61
+ Binding::ERR_CHALLENGE_EXPIRED => ChallengeExpiredError,
62
+ Binding::ERR_CHALLENGE_REPLAYED => ChallengeReplayedError
63
+ }.freeze
64
+
65
+ class << self
66
+ def version
67
+ Binding.static_string(Binding.call(:hide_version))
68
+ end
69
+
70
+ # Encrypts for 1..64 recipient public keys. Returns binary bytes.
71
+ def encrypt(plaintext, recipients:, filename: nil, media_type: nil)
72
+ joined = join_recipients(recipients)
73
+ plain = binary(plaintext, "plaintext")
74
+
75
+ out = Binding.empty_buffer
76
+ check(Binding.call(
77
+ :hide_encrypt,
78
+ buffer_arg(plain), plain.bytesize,
79
+ joined, recipients.length,
80
+ cstring(filename), cstring(media_type),
81
+ out
82
+ ))
83
+ Binding.take(out)
84
+ end
85
+
86
+ # Decrypts and verifies. Nothing is returned unless the whole payload
87
+ # authenticates, so a caller cannot act on unverified data.
88
+ def decrypt(container, secret)
89
+ raise TypeError, "secret must be a Hide::SecretKey" unless secret.is_a?(SecretKey)
90
+
91
+ bytes = binary(container, "container")
92
+ out = Binding.empty_buffer
93
+ filename = Binding.empty_buffer
94
+ media_type = Binding.empty_buffer
95
+ begin
96
+ check(Binding.call(
97
+ :hide_decrypt,
98
+ buffer_arg(bytes), bytes.bytesize,
99
+ secret.__handle,
100
+ out, filename, media_type
101
+ ))
102
+ rescue StandardError
103
+ # On failure the core leaves the out-parameters untouched, so these are
104
+ # still the empty buffers we created and freeing them is what releases
105
+ # the scratch space.
106
+ Binding.take(out)
107
+ Binding.take(filename)
108
+ Binding.take(media_type)
109
+ raise
110
+ end
111
+
112
+ Decrypted.new(
113
+ Binding.take(out),
114
+ Binding.take_text(filename),
115
+ Binding.take_text(media_type)
116
+ )
117
+ end
118
+
119
+ # Renders a public key as pasteable text.
120
+ def armor_public_key(public_key)
121
+ bytes = binary(public_key, "public key")
122
+ out = Binding.empty_buffer
123
+ check(Binding.call(:hide_public_key_armor, buffer_arg(bytes), bytes.bytesize, out))
124
+ Binding.take_text(out) || ""
125
+ end
126
+
127
+ def dearmor_public_key(text)
128
+ out = Binding.empty_buffer
129
+ check(Binding.call(:hide_public_key_dearmor, cstring(text.to_s), out))
130
+ Binding.take(out)
131
+ end
132
+
133
+ # Returns "raw" or "protected" without needing the passphrase.
134
+ def inspect_key(data)
135
+ bytes = binary(data, "key")
136
+ slot = Binding.pointer_slot
137
+ check(Binding.call(:hide_inspect_key, buffer_arg(bytes), bytes.bytesize, slot))
138
+ kind = slot[0, Binding::WORD].unpack1(Binding::WORD_PACK) & 0xFFFFFFFF
139
+ kind == Binding::KEY_PROTECTED ? "protected" : "raw"
140
+ end
141
+
142
+ def sign(identity, context, message)
143
+ identity.sign(context, message)
144
+ end
145
+
146
+ # Raises unless both the Ed25519 and the ML-DSA half verify. Nothing is
147
+ # returned: a caller who forgot to test a boolean would read every failure
148
+ # as a pass.
149
+ def verify(public_key, context, message, signature)
150
+ key = binary(public_key, "public key")
151
+ ctx = binary(context, "context")
152
+ msg = binary(message, "message")
153
+ sig = binary(signature, "signature")
154
+ check(Binding.call(
155
+ :hide_verify_message,
156
+ buffer_arg(key), key.bytesize,
157
+ buffer_arg(ctx), ctx.bytesize,
158
+ buffer_arg(msg), msg.bytesize,
159
+ buffer_arg(sig), sig.bytesize
160
+ ))
161
+ nil
162
+ end
163
+
164
+ # A detached signature proves possession at some point, to nobody in
165
+ # particular, and can be replayed. A challenge binds a random nonce, an
166
+ # audience and an expiry, so an answer is good once, here, now.
167
+ def new_challenge(audience, now, valid_for)
168
+ out = Binding.empty_buffer
169
+ check(Binding.call(
170
+ :hide_challenge_new,
171
+ cstring(audience), Integer(now), Integer(valid_for), out
172
+ ))
173
+ Binding.take(out)
174
+ end
175
+
176
+ def check(code)
177
+ return if code == Binding::OK
178
+
179
+ message = Binding.static_string(Binding.call(:hide_error_message, code))
180
+ raise ERRORS.fetch(code, Error), message
181
+ end
182
+
183
+ private
184
+
185
+ def join_recipients(recipients)
186
+ unless recipients.is_a?(Array)
187
+ raise InvalidArgumentError, "recipients must be an array of public keys"
188
+ end
189
+ unless recipients.length.between?(1, MAX_RECIPIENTS)
190
+ raise InvalidArgumentError, "there must be between 1 and #{MAX_RECIPIENTS} recipients"
191
+ end
192
+
193
+ recipients.each_with_object(+"") do |key, acc|
194
+ bytes = binary(key, "public key")
195
+ if bytes.bytesize != PUBLIC_KEY_LEN
196
+ raise InvalidArgumentError,
197
+ "a public key is #{PUBLIC_KEY_LEN} bytes, got #{bytes.bytesize}"
198
+ end
199
+ acc << bytes
200
+ end.force_encoding(Encoding::BINARY)
201
+ end
202
+
203
+ def binary(value, what)
204
+ raise InvalidArgumentError, "#{what} must be a String" unless value.is_a?(String)
205
+
206
+ value.dup.force_encoding(Encoding::BINARY)
207
+ end
208
+
209
+ # Fiddle passes a String as a pointer to its bytes, but a zero-length
210
+ # String has no address the callee may read, so give it one it can ignore.
211
+ def buffer_arg(bytes)
212
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes
213
+ end
214
+
215
+ # NULL for absent, UTF-8 plus a terminator otherwise.
216
+ def cstring(value)
217
+ return nil if value.nil?
218
+
219
+ text = value.to_s
220
+ if text.include?("\x00")
221
+ raise InvalidArgumentError, "text passed to the core must not contain NUL"
222
+ end
223
+
224
+ "#{text.encode(Encoding::UTF_8)}\x00".force_encoding(Encoding::BINARY)
225
+ end
226
+ end
227
+
228
+ # A verified payload. Holding one of these means it authenticated.
229
+ #
230
+ # The filename is attacker-controlled: never use it to choose an output path.
231
+ class Decrypted
232
+ attr_reader :plaintext, :filename, :media_type
233
+
234
+ def initialize(plaintext, filename, media_type)
235
+ @plaintext = plaintext
236
+ @filename = filename
237
+ @media_type = media_type
238
+ freeze
239
+ end
240
+
241
+ alias data plaintext
242
+ end
243
+
244
+ # Argument coercion shared by the classes that pass byte strings to the core.
245
+ module Bytes
246
+ private
247
+
248
+ def binary(value, what)
249
+ raise InvalidArgumentError, "#{what} must be a String" unless value.is_a?(String)
250
+
251
+ value.dup.force_encoding(Encoding::BINARY)
252
+ end
253
+
254
+ # Fiddle passes a String as a pointer to its bytes, but a zero-length
255
+ # String has no address the callee may read, so give it one it can ignore.
256
+ def arg(bytes)
257
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes
258
+ end
259
+ end
260
+
261
+ # A signing key. The seed stays inside the native library and is never
262
+ # exposed to Ruby; there is deliberately no accessor for it.
263
+ class SigningIdentity
264
+ include Bytes
265
+
266
+ class << self
267
+ # Creates an identity and returns the sealed key file to store. One seed
268
+ # backs both encryption and signing, so there is one thing to back up.
269
+ def generate(passphrase)
270
+ text = passphrase.to_s
271
+ if text.length < MIN_PASSPHRASE_LEN
272
+ raise InvalidArgumentError,
273
+ "the passphrase must be at least #{MIN_PASSPHRASE_LEN} characters"
274
+ end
275
+
276
+ out = Binding.empty_buffer
277
+ Hide.check(Binding.call(:hide_identity_generate, "#{text}\x00".b, out))
278
+ Binding.take(out)
279
+ end
280
+
281
+ # Loads a signing identity. A key file written before signatures existed
282
+ # carries no signing seed and fails rather than being downgraded.
283
+ def open(data, passphrase = nil, &block)
284
+ raise InvalidArgumentError, "key data must be a String" unless data.is_a?(String)
285
+
286
+ bytes = data.dup.force_encoding(Encoding::BINARY)
287
+ handle = Binding.pointer_slot
288
+ Hide.check(Binding.call(
289
+ :hide_signing_identity_open,
290
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes,
291
+ bytes.bytesize,
292
+ passphrase.nil? ? nil : "#{passphrase}\x00".b,
293
+ handle
294
+ ))
295
+ identity = new(handle[0, Binding::WORD].unpack1(Binding::WORD_PACK))
296
+ return identity unless block
297
+
298
+ begin
299
+ block.call(identity)
300
+ ensure
301
+ identity.close
302
+ end
303
+ end
304
+
305
+ alias load open
306
+ end
307
+
308
+ def initialize(address)
309
+ @address = address
310
+ end
311
+
312
+ # The shareable verifying key, for others to check signatures with.
313
+ def public_key
314
+ alive!
315
+ out = Binding.empty_buffer
316
+ Hide.check(Binding.call(:hide_signing_identity_public, handle, out))
317
+ Binding.take(out)
318
+ end
319
+
320
+ # context separates uses of one identity, so a signature made for one
321
+ # purpose cannot be replayed as another. Never let a remote party choose it.
322
+ def sign(context, message)
323
+ alive!
324
+ ctx = binary(context, "context")
325
+ msg = binary(message, "message")
326
+ out = Binding.empty_buffer
327
+ Hide.check(Binding.call(
328
+ :hide_sign_message,
329
+ handle,
330
+ arg(ctx), ctx.bytesize,
331
+ arg(msg), msg.bytesize,
332
+ out
333
+ ))
334
+ Binding.take(out)
335
+ end
336
+
337
+ # Answers a challenge, proving possession to whoever issued it.
338
+ def answer(challenge)
339
+ alive!
340
+ bytes = binary(challenge, "challenge")
341
+ out = Binding.empty_buffer
342
+ Hide.check(Binding.call(:hide_challenge_answer, handle, arg(bytes), bytes.bytesize, out))
343
+ Binding.take(out)
344
+ end
345
+
346
+ def close
347
+ return if @address.nil? || @address.zero?
348
+
349
+ Binding.call(:hide_signing_identity_free, Fiddle::Pointer.new(@address))
350
+ @address = nil
351
+ nil
352
+ end
353
+
354
+ def closed?
355
+ @address.nil? || @address.zero?
356
+ end
357
+
358
+ # Never render key material, not even a fingerprint of it.
359
+ def inspect
360
+ "#<Hide::SigningIdentity #{closed? ? "closed" : "open"}>"
361
+ end
362
+
363
+ alias to_s inspect
364
+
365
+ private
366
+
367
+ def handle
368
+ Fiddle::Pointer.new(@address)
369
+ end
370
+
371
+ def alive!
372
+ raise ClosedKeyError, "this identity has been closed" if closed?
373
+ end
374
+ end
375
+
376
+ # The verifier's record of answered challenges.
377
+ #
378
+ # Replay can only be detected by the verifier: a replayed answer is a genuine
379
+ # signature and nothing about it is invalid on its own. This must therefore
380
+ # outlive a single request.
381
+ class SpentNonces
382
+ include Bytes
383
+
384
+ def self.open
385
+ record = new
386
+ return record unless block_given?
387
+
388
+ begin
389
+ yield record
390
+ ensure
391
+ record.close
392
+ end
393
+ end
394
+
395
+ def initialize
396
+ pointer = Binding.call(:hide_spent_nonces_new)
397
+ raise Error, "could not allocate the nonce record" if pointer.null?
398
+
399
+ @address = pointer.to_i
400
+ end
401
+
402
+ # Accepts an answer exactly once: raises ChallengeReplayedError the second
403
+ # time, ChallengeExpiredError after the window, AuthenticationError if it
404
+ # does not verify.
405
+ def accept(challenge, signature, public_key, now)
406
+ raise ClosedKeyError, "this record has been closed" if closed?
407
+
408
+ chal = binary(challenge, "challenge")
409
+ sig = binary(signature, "signature")
410
+ key = binary(public_key, "public key")
411
+ Hide.check(Binding.call(
412
+ :hide_challenge_accept,
413
+ Fiddle::Pointer.new(@address),
414
+ arg(chal), chal.bytesize,
415
+ arg(sig), sig.bytesize,
416
+ arg(key), key.bytesize,
417
+ Integer(now)
418
+ ))
419
+ nil
420
+ end
421
+
422
+ def close
423
+ return if closed?
424
+
425
+ Binding.call(:hide_spent_nonces_free, Fiddle::Pointer.new(@address))
426
+ @address = nil
427
+ nil
428
+ end
429
+
430
+ def closed?
431
+ @address.nil? || @address.zero?
432
+ end
433
+ end
434
+
435
+ # A secret key. The bytes stay inside the native library and are never
436
+ # exposed to Ruby; there is deliberately no accessor for them.
437
+ class SecretKey
438
+ class << self
439
+ def generate(&block)
440
+ handle = Binding.pointer_slot
441
+ public_key = Binding.empty_buffer
442
+ Hide.check(Binding.call(:hide_keypair_generate, handle, public_key))
443
+ Binding.take(public_key)
444
+ wrap(handle, &block)
445
+ end
446
+
447
+ # Loads a key file. A protected key without its passphrase fails.
448
+ def open(data, passphrase = nil, &block)
449
+ unless data.is_a?(String)
450
+ raise InvalidArgumentError, "key data must be a String"
451
+ end
452
+
453
+ bytes = data.dup.force_encoding(Encoding::BINARY)
454
+ handle = Binding.pointer_slot
455
+ Hide.check(Binding.call(
456
+ :hide_secret_key_open,
457
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes,
458
+ bytes.bytesize,
459
+ passphrase.nil? ? nil : "#{passphrase}\x00".b,
460
+ handle
461
+ ))
462
+ wrap(handle, &block)
463
+ end
464
+
465
+ alias load open
466
+
467
+ private
468
+
469
+ # With a block the key is always closed, even if the block raises.
470
+ def wrap(handle, &block)
471
+ key = new(handle[0, Binding::WORD].unpack1(Binding::WORD_PACK))
472
+ return key unless block
473
+
474
+ begin
475
+ block.call(key)
476
+ ensure
477
+ key.close
478
+ end
479
+ end
480
+ end
481
+
482
+ def initialize(address)
483
+ @address = address
484
+ end
485
+
486
+ def public_key
487
+ alive!
488
+ out = Binding.empty_buffer
489
+ Hide.check(Binding.call(:hide_secret_key_public, handle, out))
490
+ Binding.take(out)
491
+ end
492
+
493
+ # Seals this key with a passphrase, for writing to disk. A forgotten
494
+ # passphrase cannot be recovered: there is no escrow.
495
+ def protect(passphrase)
496
+ alive!
497
+ text = passphrase.to_s
498
+ if text.length < MIN_PASSPHRASE_LEN
499
+ raise InvalidArgumentError,
500
+ "the passphrase must be at least #{MIN_PASSPHRASE_LEN} characters"
501
+ end
502
+
503
+ out = Binding.empty_buffer
504
+ Hide.check(Binding.call(:hide_secret_key_protect, handle, "#{text}\x00".b, out))
505
+ Binding.take(out)
506
+ end
507
+
508
+ def close
509
+ return if @address.nil? || @address.zero?
510
+
511
+ Binding.call(:hide_secret_key_free, Fiddle::Pointer.new(@address))
512
+ @address = nil
513
+ nil
514
+ end
515
+
516
+ def closed?
517
+ @address.nil? || @address.zero?
518
+ end
519
+
520
+ # Never render key material, not even a fingerprint of it.
521
+ def inspect
522
+ "#<Hide::SecretKey #{closed? ? "closed" : "open"}>"
523
+ end
524
+
525
+ alias to_s inspect
526
+
527
+ # Internal: the raw handle, for Hide.decrypt.
528
+ def __handle
529
+ alive!
530
+ handle
531
+ end
532
+
533
+ private
534
+
535
+ def handle
536
+ Fiddle::Pointer.new(@address)
537
+ end
538
+
539
+ def alive!
540
+ raise ClosedKeyError, "this key has been closed" if closed?
541
+ end
542
+ end
543
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hide-protocol
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - HIDE contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ description: 'Ruby bindings to the HIDE core (X25519 + ML-KEM-768). Experimental and
28
+ unaudited: a successful decryption proves the data was not altered, not who created
29
+ it.'
30
+ email:
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE
36
+ - README.md
37
+ - lib/hide_protocol.rb
38
+ - lib/hide_protocol/binding.rb
39
+ homepage: https://github.com/hide-protocol/hide
40
+ licenses:
41
+ - Apache-2.0
42
+ metadata:
43
+ homepage_uri: https://github.com/hide-protocol/hide
44
+ source_code_uri: https://github.com/hide-protocol/hide
45
+ bug_tracker_uri: https://github.com/hide-protocol/hide/issues
46
+ rubygems_mfa_required: 'true'
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.0'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.5.22
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Experimental hybrid post-quantum file and message encryption. Unaudited.
66
+ test_files: []