hide-protocol 0.6.1-aarch64-linux

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: 818d401cf95253678471c00c1de7cba0e4f3b8cffa7ba3e008d7de587dd6b5b2
4
+ data.tar.gz: 29135f4f040710f385aa9050e72065c602cbfc9ffcbb913ee7f1839d22e4351a
5
+ SHA512:
6
+ metadata.gz: d734cff3c84f55c99f20540547559357a48c9a812bb1092b9d9938095865fc4f3d1b92729f69a4f06bcf0a1adc1bb044b7355d9b04c9838c41d44e14a438754c
7
+ data.tar.gz: 86a43641cebb4b6dedd3ddc3e9b171eb9a3837ea24334cce20cf14ba72bc944383c99ea25d02c5a83cdd9faf8a773e2e5fdd00d49de7781fe544d0eaf042b83c
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,189 @@
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
+ hide_identity_verify: [[VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT32],
112
+ hide_identity_trusts_device: [[VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT32],
113
+ hide_identity_head: [[VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT32],
114
+ hide_epoch_verify: [[VOIDP, SIZE_T, VOIDP], INT32],
115
+ hide_epoch_public_key: [[VOIDP, SIZE_T, UINT64, VOIDP], INT32],
116
+ hide_transparency_verify_inclusion: [
117
+ [VOIDP, SIZE_T, UINT64, UINT64, VOIDP, SIZE_T, VOIDP, SIZE_T], INT32
118
+ ],
119
+ hide_transparency_verify_consistency: [
120
+ [UINT64, UINT64, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T], INT32
121
+ ]
122
+ }.freeze
123
+
124
+ FUNCTIONS = SIGNATURES.each_with_object({}) do |(name, (args, ret)), acc|
125
+ acc[name] = Fiddle::Function.new(LIB[name.to_s], args, ret, name: name.to_s)
126
+ end.freeze
127
+
128
+ def self.call(name, *args)
129
+ FUNCTIONS.fetch(name).call(*args)
130
+ end
131
+
132
+ # A static C string owned by the library; never freed by us.
133
+ def self.static_string(pointer)
134
+ return "" if pointer.null?
135
+
136
+ Fiddle::Pointer.new(pointer.to_i).to_s.force_encoding(Encoding::UTF_8)
137
+ end
138
+
139
+ # Scratch space for one HideBuffer, zeroed the way hide_buffer_empty zeroes it.
140
+ def self.empty_buffer
141
+ slot = Fiddle::Pointer.malloc(BUFFER_SIZE, Fiddle::RUBY_FREE)
142
+ slot[0, BUFFER_SIZE] = "\x00" * BUFFER_SIZE
143
+ call(:hide_buffer_empty, slot)
144
+ slot
145
+ end
146
+
147
+ def self.read_word(slot, index)
148
+ slot[index * WORD, WORD].unpack1(WORD_PACK)
149
+ end
150
+
151
+ # Copies a native buffer out and frees the original. The length field is
152
+ # the only thing consulted: text from this library is length-prefixed, not
153
+ # NUL-terminated, because metadata is attacker-controlled and a NUL in it
154
+ # would silently truncate a C-string read.
155
+ def self.take(slot)
156
+ data = read_word(slot, 0)
157
+ len = read_word(slot, 1)
158
+ bytes =
159
+ if data.zero? || len.zero?
160
+ +""
161
+ else
162
+ Fiddle::Pointer.new(data, len)[0, len]
163
+ end
164
+ bytes.force_encoding(Encoding::BINARY)
165
+ ensure
166
+ call(:hide_buffer_free, slot)
167
+ end
168
+
169
+ # Metadata arrives as length-prefixed UTF-8; empty means absent.
170
+ def self.take_text(slot)
171
+ raw = take(slot)
172
+ return nil if raw.empty?
173
+
174
+ raw.dup.force_encoding(Encoding::UTF_8).scrub
175
+ end
176
+
177
+ # Reads a size_t out-parameter written into a pointer-sized slot.
178
+ def self.read_count(slot)
179
+ read_word(slot, 0)
180
+ end
181
+
182
+ # A slot holding one pointer-sized out-parameter.
183
+ def self.pointer_slot
184
+ slot = Fiddle::Pointer.malloc(WORD, Fiddle::RUBY_FREE)
185
+ slot[0, WORD] = "\x00" * WORD
186
+ slot
187
+ end
188
+ end
189
+ end
Binary file
@@ -0,0 +1,662 @@
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 bytes did not decode at all.
33
+ #
34
+ # A subclass of AuthenticationError so that code which only cares that
35
+ # something failed is unaffected, while a caller that must tell corruption
36
+ # from forgery can rescue this specifically.
37
+ class MalformedError < AuthenticationError; end
38
+
39
+ # The passphrase is wrong, or the key file was modified.
40
+ class WrongPassphraseError < Error; end
41
+
42
+ # This key was not one of the recipients.
43
+ class NoMatchingRecipientError < Error; end
44
+
45
+ # The bytes are not a HIDE key.
46
+ class NotAKeyError < Error; end
47
+
48
+ # More data than the format admits.
49
+ class TooLargeError < Error; end
50
+
51
+ # The key has been closed and its material released.
52
+ class ClosedKeyError < Error; end
53
+
54
+ # The challenge expired before it was answered.
55
+ class ChallengeExpiredError < Error; end
56
+
57
+ # This challenge was already answered. Almost certainly a replay.
58
+ class ChallengeReplayedError < Error; end
59
+
60
+ ERRORS = {
61
+ Binding::ERR_INVALID_ARGUMENT => InvalidArgumentError,
62
+ Binding::ERR_WRONG_PASSPHRASE => WrongPassphraseError,
63
+ Binding::ERR_NOT_A_KEY => NotAKeyError,
64
+ Binding::ERR_AUTHENTICATION => AuthenticationError,
65
+ Binding::ERR_NO_MATCHING_RECIPIENT => NoMatchingRecipientError,
66
+ Binding::ERR_MALFORMED => MalformedError,
67
+ Binding::ERR_TOO_LARGE => TooLargeError,
68
+ Binding::ERR_CHALLENGE_EXPIRED => ChallengeExpiredError,
69
+ Binding::ERR_CHALLENGE_REPLAYED => ChallengeReplayedError
70
+ }.freeze
71
+
72
+ class << self
73
+ def version
74
+ Binding.static_string(Binding.call(:hide_version))
75
+ end
76
+
77
+ # Encrypts for 1..64 recipient public keys. Returns binary bytes.
78
+ def encrypt(plaintext, recipients:, filename: nil, media_type: nil)
79
+ joined = join_recipients(recipients)
80
+ plain = binary(plaintext, "plaintext")
81
+
82
+ out = Binding.empty_buffer
83
+ check(Binding.call(
84
+ :hide_encrypt,
85
+ buffer_arg(plain), plain.bytesize,
86
+ joined, recipients.length,
87
+ cstring(filename), cstring(media_type),
88
+ out
89
+ ))
90
+ Binding.take(out)
91
+ end
92
+
93
+ # Decrypts and verifies. Nothing is returned unless the whole payload
94
+ # authenticates, so a caller cannot act on unverified data.
95
+ def decrypt(container, secret)
96
+ raise TypeError, "secret must be a Hide::SecretKey" unless secret.is_a?(SecretKey)
97
+
98
+ bytes = binary(container, "container")
99
+ out = Binding.empty_buffer
100
+ filename = Binding.empty_buffer
101
+ media_type = Binding.empty_buffer
102
+ begin
103
+ check(Binding.call(
104
+ :hide_decrypt,
105
+ buffer_arg(bytes), bytes.bytesize,
106
+ secret.__handle,
107
+ out, filename, media_type
108
+ ))
109
+ rescue StandardError
110
+ # On failure the core leaves the out-parameters untouched, so these are
111
+ # still the empty buffers we created and freeing them is what releases
112
+ # the scratch space.
113
+ Binding.take(out)
114
+ Binding.take(filename)
115
+ Binding.take(media_type)
116
+ raise
117
+ end
118
+
119
+ Decrypted.new(
120
+ Binding.take(out),
121
+ Binding.take_text(filename),
122
+ Binding.take_text(media_type)
123
+ )
124
+ end
125
+
126
+ # Renders a public key as pasteable text.
127
+ def armor_public_key(public_key)
128
+ bytes = binary(public_key, "public key")
129
+ out = Binding.empty_buffer
130
+ check(Binding.call(:hide_public_key_armor, buffer_arg(bytes), bytes.bytesize, out))
131
+ Binding.take_text(out) || ""
132
+ end
133
+
134
+ def dearmor_public_key(text)
135
+ out = Binding.empty_buffer
136
+ check(Binding.call(:hide_public_key_dearmor, cstring(text.to_s), out))
137
+ Binding.take(out)
138
+ end
139
+
140
+ # Returns "raw" or "protected" without needing the passphrase.
141
+ def inspect_key(data)
142
+ bytes = binary(data, "key")
143
+ slot = Binding.pointer_slot
144
+ check(Binding.call(:hide_inspect_key, buffer_arg(bytes), bytes.bytesize, slot))
145
+ kind = slot[0, Binding::WORD].unpack1(Binding::WORD_PACK) & 0xFFFFFFFF
146
+ kind == Binding::KEY_PROTECTED ? "protected" : "raw"
147
+ end
148
+
149
+ def sign(identity, context, message)
150
+ identity.sign(context, message)
151
+ end
152
+
153
+ # Raises unless both the Ed25519 and the ML-DSA half verify. Nothing is
154
+ # returned: a caller who forgot to test a boolean would read every failure
155
+ # as a pass.
156
+ def verify(public_key, context, message, signature)
157
+ key = binary(public_key, "public key")
158
+ ctx = binary(context, "context")
159
+ msg = binary(message, "message")
160
+ sig = binary(signature, "signature")
161
+ check(Binding.call(
162
+ :hide_verify_message,
163
+ buffer_arg(key), key.bytesize,
164
+ buffer_arg(ctx), ctx.bytesize,
165
+ buffer_arg(msg), msg.bytesize,
166
+ buffer_arg(sig), sig.bytesize
167
+ ))
168
+ nil
169
+ end
170
+
171
+ # A detached signature proves possession at some point, to nobody in
172
+ # particular, and can be replayed. A challenge binds a random nonce, an
173
+ # audience and an expiry, so an answer is good once, here, now.
174
+ def new_challenge(audience, now, valid_for)
175
+ out = Binding.empty_buffer
176
+ check(Binding.call(
177
+ :hide_challenge_new,
178
+ cstring(audience), Integer(now), Integer(valid_for), out
179
+ ))
180
+ Binding.take(out)
181
+ end
182
+
183
+ # Replays an identity log and returns how many devices it trusts now.
184
+ #
185
+ # Raises MalformedError for a log that does not decode and
186
+ # AuthenticationError for one that decodes but does not verify — the
187
+ # distinction that tells corruption from forgery. A count is returned
188
+ # rather than a boolean: a caller who forgot to test one would read every
189
+ # failure as a pass.
190
+ def verify_identity(log, recovery_key)
191
+ bytes = binary(log, "log")
192
+ recovery = binary(recovery_key, "recovery key")
193
+ slot = Binding.pointer_slot
194
+ check(Binding.call(
195
+ :hide_identity_verify,
196
+ buffer_arg(bytes), bytes.bytesize,
197
+ buffer_arg(recovery), recovery.bytesize,
198
+ slot
199
+ ))
200
+ Binding.read_count(slot)
201
+ end
202
+
203
+ # Whether the log trusts this device right now.
204
+ #
205
+ # A boolean is right here — this is a membership query, not a
206
+ # cryptographic check. The log is still verified first, so false means
207
+ # "not a member", never "did not verify": that raises.
208
+ def identity_trusts_device(log, recovery_key, device_public_key)
209
+ bytes = binary(log, "log")
210
+ recovery = binary(recovery_key, "recovery key")
211
+ device = binary(device_public_key, "device public key")
212
+ slot = Binding.pointer_slot
213
+ check(Binding.call(
214
+ :hide_identity_trusts_device,
215
+ buffer_arg(bytes), bytes.bytesize,
216
+ buffer_arg(recovery), recovery.bytesize,
217
+ buffer_arg(device), device.bytesize,
218
+ slot
219
+ ))
220
+ (Binding.read_count(slot) & 0xFFFFFFFF) != 0
221
+ end
222
+
223
+ # The head link: 32 bytes naming this exact history.
224
+ def identity_head(log, recovery_key)
225
+ bytes = binary(log, "log")
226
+ recovery = binary(recovery_key, "recovery key")
227
+ out = Binding.empty_buffer
228
+ check(Binding.call(
229
+ :hide_identity_head,
230
+ buffer_arg(bytes), bytes.bytesize,
231
+ buffer_arg(recovery), recovery.bytesize,
232
+ out
233
+ ))
234
+ Binding.take(out)
235
+ end
236
+
237
+ # Verifies a published epoch history and returns how many epochs it holds.
238
+ def verify_epoch_chain(chain)
239
+ bytes = binary(chain, "chain")
240
+ slot = Binding.pointer_slot
241
+ check(Binding.call(:hide_epoch_verify, buffer_arg(bytes), bytes.bytesize, slot))
242
+ Binding.read_count(slot)
243
+ end
244
+
245
+ # The public key a sender should encrypt to for this epoch.
246
+ #
247
+ # The chain is verified first, so a key is never returned from a history
248
+ # that does not hold together. An epoch beyond the chain raises
249
+ # InvalidArgumentError.
250
+ def epoch_public_key(chain, epoch)
251
+ bytes = binary(chain, "chain")
252
+ out = Binding.empty_buffer
253
+ check(Binding.call(
254
+ :hide_epoch_public_key,
255
+ buffer_arg(bytes), bytes.bytesize, Integer(epoch), out
256
+ ))
257
+ Binding.take(out)
258
+ end
259
+
260
+ # Checks that leaf is entry index of a log of size entries under root.
261
+ #
262
+ # path is the concatenated 32-byte hashes; any other length raises
263
+ # InvalidArgumentError. Nothing is returned, for the same reason verify
264
+ # returns nothing.
265
+ def verify_inclusion(leaf, index, size, path, root)
266
+ leaf_bytes = binary(leaf, "leaf")
267
+ path_bytes = binary(path, "path")
268
+ root_bytes = binary(root, "root")
269
+ check(Binding.call(
270
+ :hide_transparency_verify_inclusion,
271
+ buffer_arg(leaf_bytes), leaf_bytes.bytesize,
272
+ Integer(index), Integer(size),
273
+ buffer_arg(path_bytes), path_bytes.bytesize,
274
+ buffer_arg(root_bytes), root_bytes.bytesize
275
+ ))
276
+ nil
277
+ end
278
+
279
+ # Checks that old_root really is the root the log had before it grew to
280
+ # new_root. This is the check that catches a rewritten history.
281
+ def verify_consistency(old_size, new_size, path, old_root, new_root)
282
+ path_bytes = binary(path, "path")
283
+ old_bytes = binary(old_root, "old root")
284
+ new_bytes = binary(new_root, "new root")
285
+ check(Binding.call(
286
+ :hide_transparency_verify_consistency,
287
+ Integer(old_size), Integer(new_size),
288
+ buffer_arg(path_bytes), path_bytes.bytesize,
289
+ buffer_arg(old_bytes), old_bytes.bytesize,
290
+ buffer_arg(new_bytes), new_bytes.bytesize
291
+ ))
292
+ nil
293
+ end
294
+
295
+ def check(code)
296
+ return if code == Binding::OK
297
+
298
+ message = Binding.static_string(Binding.call(:hide_error_message, code))
299
+ raise ERRORS.fetch(code, Error), message
300
+ end
301
+
302
+ private
303
+
304
+ def join_recipients(recipients)
305
+ unless recipients.is_a?(Array)
306
+ raise InvalidArgumentError, "recipients must be an array of public keys"
307
+ end
308
+ unless recipients.length.between?(1, MAX_RECIPIENTS)
309
+ raise InvalidArgumentError, "there must be between 1 and #{MAX_RECIPIENTS} recipients"
310
+ end
311
+
312
+ recipients.each_with_object(+"") do |key, acc|
313
+ bytes = binary(key, "public key")
314
+ if bytes.bytesize != PUBLIC_KEY_LEN
315
+ raise InvalidArgumentError,
316
+ "a public key is #{PUBLIC_KEY_LEN} bytes, got #{bytes.bytesize}"
317
+ end
318
+ acc << bytes
319
+ end.force_encoding(Encoding::BINARY)
320
+ end
321
+
322
+ def binary(value, what)
323
+ raise InvalidArgumentError, "#{what} must be a String" unless value.is_a?(String)
324
+
325
+ value.dup.force_encoding(Encoding::BINARY)
326
+ end
327
+
328
+ # Fiddle passes a String as a pointer to its bytes, but a zero-length
329
+ # String has no address the callee may read, so give it one it can ignore.
330
+ def buffer_arg(bytes)
331
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes
332
+ end
333
+
334
+ # NULL for absent, UTF-8 plus a terminator otherwise.
335
+ def cstring(value)
336
+ return nil if value.nil?
337
+
338
+ text = value.to_s
339
+ if text.include?("\x00")
340
+ raise InvalidArgumentError, "text passed to the core must not contain NUL"
341
+ end
342
+
343
+ "#{text.encode(Encoding::UTF_8)}\x00".force_encoding(Encoding::BINARY)
344
+ end
345
+ end
346
+
347
+ # A verified payload. Holding one of these means it authenticated.
348
+ #
349
+ # The filename is attacker-controlled: never use it to choose an output path.
350
+ class Decrypted
351
+ attr_reader :plaintext, :filename, :media_type
352
+
353
+ def initialize(plaintext, filename, media_type)
354
+ @plaintext = plaintext
355
+ @filename = filename
356
+ @media_type = media_type
357
+ freeze
358
+ end
359
+
360
+ alias data plaintext
361
+ end
362
+
363
+ # Argument coercion shared by the classes that pass byte strings to the core.
364
+ module Bytes
365
+ private
366
+
367
+ def binary(value, what)
368
+ raise InvalidArgumentError, "#{what} must be a String" unless value.is_a?(String)
369
+
370
+ value.dup.force_encoding(Encoding::BINARY)
371
+ end
372
+
373
+ # Fiddle passes a String as a pointer to its bytes, but a zero-length
374
+ # String has no address the callee may read, so give it one it can ignore.
375
+ def arg(bytes)
376
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes
377
+ end
378
+ end
379
+
380
+ # A signing key. The seed stays inside the native library and is never
381
+ # exposed to Ruby; there is deliberately no accessor for it.
382
+ class SigningIdentity
383
+ include Bytes
384
+
385
+ class << self
386
+ # Creates an identity and returns the sealed key file to store. One seed
387
+ # backs both encryption and signing, so there is one thing to back up.
388
+ def generate(passphrase)
389
+ text = passphrase.to_s
390
+ if text.length < MIN_PASSPHRASE_LEN
391
+ raise InvalidArgumentError,
392
+ "the passphrase must be at least #{MIN_PASSPHRASE_LEN} characters"
393
+ end
394
+
395
+ out = Binding.empty_buffer
396
+ Hide.check(Binding.call(:hide_identity_generate, "#{text}\x00".b, out))
397
+ Binding.take(out)
398
+ end
399
+
400
+ # Loads a signing identity. A key file written before signatures existed
401
+ # carries no signing seed and fails rather than being downgraded.
402
+ def open(data, passphrase = nil, &block)
403
+ raise InvalidArgumentError, "key data must be a String" unless data.is_a?(String)
404
+
405
+ bytes = data.dup.force_encoding(Encoding::BINARY)
406
+ handle = Binding.pointer_slot
407
+ Hide.check(Binding.call(
408
+ :hide_signing_identity_open,
409
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes,
410
+ bytes.bytesize,
411
+ passphrase.nil? ? nil : "#{passphrase}\x00".b,
412
+ handle
413
+ ))
414
+ identity = new(handle[0, Binding::WORD].unpack1(Binding::WORD_PACK))
415
+ return identity unless block
416
+
417
+ begin
418
+ block.call(identity)
419
+ ensure
420
+ identity.close
421
+ end
422
+ end
423
+
424
+ alias load open
425
+ end
426
+
427
+ def initialize(address)
428
+ @address = address
429
+ end
430
+
431
+ # The shareable verifying key, for others to check signatures with.
432
+ def public_key
433
+ alive!
434
+ out = Binding.empty_buffer
435
+ Hide.check(Binding.call(:hide_signing_identity_public, handle, out))
436
+ Binding.take(out)
437
+ end
438
+
439
+ # context separates uses of one identity, so a signature made for one
440
+ # purpose cannot be replayed as another. Never let a remote party choose it.
441
+ def sign(context, message)
442
+ alive!
443
+ ctx = binary(context, "context")
444
+ msg = binary(message, "message")
445
+ out = Binding.empty_buffer
446
+ Hide.check(Binding.call(
447
+ :hide_sign_message,
448
+ handle,
449
+ arg(ctx), ctx.bytesize,
450
+ arg(msg), msg.bytesize,
451
+ out
452
+ ))
453
+ Binding.take(out)
454
+ end
455
+
456
+ # Answers a challenge, proving possession to whoever issued it.
457
+ def answer(challenge)
458
+ alive!
459
+ bytes = binary(challenge, "challenge")
460
+ out = Binding.empty_buffer
461
+ Hide.check(Binding.call(:hide_challenge_answer, handle, arg(bytes), bytes.bytesize, out))
462
+ Binding.take(out)
463
+ end
464
+
465
+ def close
466
+ return if @address.nil? || @address.zero?
467
+
468
+ Binding.call(:hide_signing_identity_free, Fiddle::Pointer.new(@address))
469
+ @address = nil
470
+ nil
471
+ end
472
+
473
+ def closed?
474
+ @address.nil? || @address.zero?
475
+ end
476
+
477
+ # Never render key material, not even a fingerprint of it.
478
+ def inspect
479
+ "#<Hide::SigningIdentity #{closed? ? "closed" : "open"}>"
480
+ end
481
+
482
+ alias to_s inspect
483
+
484
+ private
485
+
486
+ def handle
487
+ Fiddle::Pointer.new(@address)
488
+ end
489
+
490
+ def alive!
491
+ raise ClosedKeyError, "this identity has been closed" if closed?
492
+ end
493
+ end
494
+
495
+ # The verifier's record of answered challenges.
496
+ #
497
+ # Replay can only be detected by the verifier: a replayed answer is a genuine
498
+ # signature and nothing about it is invalid on its own. This must therefore
499
+ # outlive a single request.
500
+ class SpentNonces
501
+ include Bytes
502
+
503
+ def self.open
504
+ record = new
505
+ return record unless block_given?
506
+
507
+ begin
508
+ yield record
509
+ ensure
510
+ record.close
511
+ end
512
+ end
513
+
514
+ def initialize
515
+ pointer = Binding.call(:hide_spent_nonces_new)
516
+ raise Error, "could not allocate the nonce record" if pointer.null?
517
+
518
+ @address = pointer.to_i
519
+ end
520
+
521
+ # Accepts an answer exactly once: raises ChallengeReplayedError the second
522
+ # time, ChallengeExpiredError after the window, AuthenticationError if it
523
+ # does not verify.
524
+ def accept(challenge, signature, public_key, now)
525
+ raise ClosedKeyError, "this record has been closed" if closed?
526
+
527
+ chal = binary(challenge, "challenge")
528
+ sig = binary(signature, "signature")
529
+ key = binary(public_key, "public key")
530
+ Hide.check(Binding.call(
531
+ :hide_challenge_accept,
532
+ Fiddle::Pointer.new(@address),
533
+ arg(chal), chal.bytesize,
534
+ arg(sig), sig.bytesize,
535
+ arg(key), key.bytesize,
536
+ Integer(now)
537
+ ))
538
+ nil
539
+ end
540
+
541
+ def close
542
+ return if closed?
543
+
544
+ Binding.call(:hide_spent_nonces_free, Fiddle::Pointer.new(@address))
545
+ @address = nil
546
+ nil
547
+ end
548
+
549
+ def closed?
550
+ @address.nil? || @address.zero?
551
+ end
552
+ end
553
+
554
+ # A secret key. The bytes stay inside the native library and are never
555
+ # exposed to Ruby; there is deliberately no accessor for them.
556
+ class SecretKey
557
+ class << self
558
+ def generate(&block)
559
+ handle = Binding.pointer_slot
560
+ public_key = Binding.empty_buffer
561
+ Hide.check(Binding.call(:hide_keypair_generate, handle, public_key))
562
+ Binding.take(public_key)
563
+ wrap(handle, &block)
564
+ end
565
+
566
+ # Loads a key file. A protected key without its passphrase fails.
567
+ def open(data, passphrase = nil, &block)
568
+ unless data.is_a?(String)
569
+ raise InvalidArgumentError, "key data must be a String"
570
+ end
571
+
572
+ bytes = data.dup.force_encoding(Encoding::BINARY)
573
+ handle = Binding.pointer_slot
574
+ Hide.check(Binding.call(
575
+ :hide_secret_key_open,
576
+ bytes.empty? ? Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE) : bytes,
577
+ bytes.bytesize,
578
+ passphrase.nil? ? nil : "#{passphrase}\x00".b,
579
+ handle
580
+ ))
581
+ wrap(handle, &block)
582
+ end
583
+
584
+ alias load open
585
+
586
+ private
587
+
588
+ # With a block the key is always closed, even if the block raises.
589
+ def wrap(handle, &block)
590
+ key = new(handle[0, Binding::WORD].unpack1(Binding::WORD_PACK))
591
+ return key unless block
592
+
593
+ begin
594
+ block.call(key)
595
+ ensure
596
+ key.close
597
+ end
598
+ end
599
+ end
600
+
601
+ def initialize(address)
602
+ @address = address
603
+ end
604
+
605
+ def public_key
606
+ alive!
607
+ out = Binding.empty_buffer
608
+ Hide.check(Binding.call(:hide_secret_key_public, handle, out))
609
+ Binding.take(out)
610
+ end
611
+
612
+ # Seals this key with a passphrase, for writing to disk. A forgotten
613
+ # passphrase cannot be recovered: there is no escrow.
614
+ def protect(passphrase)
615
+ alive!
616
+ text = passphrase.to_s
617
+ if text.length < MIN_PASSPHRASE_LEN
618
+ raise InvalidArgumentError,
619
+ "the passphrase must be at least #{MIN_PASSPHRASE_LEN} characters"
620
+ end
621
+
622
+ out = Binding.empty_buffer
623
+ Hide.check(Binding.call(:hide_secret_key_protect, handle, "#{text}\x00".b, out))
624
+ Binding.take(out)
625
+ end
626
+
627
+ def close
628
+ return if @address.nil? || @address.zero?
629
+
630
+ Binding.call(:hide_secret_key_free, Fiddle::Pointer.new(@address))
631
+ @address = nil
632
+ nil
633
+ end
634
+
635
+ def closed?
636
+ @address.nil? || @address.zero?
637
+ end
638
+
639
+ # Never render key material, not even a fingerprint of it.
640
+ def inspect
641
+ "#<Hide::SecretKey #{closed? ? "closed" : "open"}>"
642
+ end
643
+
644
+ alias to_s inspect
645
+
646
+ # Internal: the raw handle, for Hide.decrypt.
647
+ def __handle
648
+ alive!
649
+ handle
650
+ end
651
+
652
+ private
653
+
654
+ def handle
655
+ Fiddle::Pointer.new(@address)
656
+ end
657
+
658
+ def alive!
659
+ raise ClosedKeyError, "this key has been closed" if closed?
660
+ end
661
+ end
662
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hide-protocol
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.1
5
+ platform: aarch64-linux
6
+ authors:
7
+ - HIDE contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-08 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
+ - lib/hide_protocol/libhide_ffi.so
40
+ homepage: https://github.com/hide-protocol/hide
41
+ licenses:
42
+ - Apache-2.0
43
+ metadata:
44
+ homepage_uri: https://github.com/hide-protocol/hide
45
+ source_code_uri: https://github.com/hide-protocol/hide
46
+ bug_tracker_uri: https://github.com/hide-protocol/hide/issues
47
+ rubygems_mfa_required: 'true'
48
+ post_install_message:
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.0'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.5.22
64
+ signing_key:
65
+ specification_version: 4
66
+ summary: Experimental hybrid post-quantum file and message encryption. Unaudited.
67
+ test_files: []