xoodyak 0.1.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: '087e6888550b824a43b269965b1066bd4daad3d1942cc517ced8435ffcfaa21b'
4
+ data.tar.gz: 0cabd3ab1dfddae09c63de3040479e365e11c10726d9a3a484f7983927080173
5
+ SHA512:
6
+ metadata.gz: ea9b1f41888eef78c4ca2e960988f7f04cc418386b2883f0cf4653be7a699b7eef0eb5d44c9b072fd0a9dbf2340bf6d5082ceb16c7f5582c7bf502bc0a90c0cb
7
+ data.tar.gz: 1c0a4f756035ff03478a7031c40fd505d03f7c32fb02e0e5e755ac7ed872c587dafe2b71f161cd308907c4fa183769553ede8bc3bf615fdee91c12303999bab1
data/LICENSE.md ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Sarun Rattanasiri
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,352 @@
1
+ # Xoodyak Ruby Gem
2
+
3
+ A blazing fast, secure, and modern Rust-backed Ruby implementation of the Xoodyak cryptographic scheme.
4
+
5
+ [![Gem Version](https://img.shields.io/gem/v/xoodyak.svg?style=flat-square)](https://badge.fury.io/rb/xoodyak)
6
+ [![License](https://img.shields.io/badge/license-BSD--2--Clause-blue.svg?style=flat-square)](https://github.com/midnight-wonderer/xoodyak-rb/blob/main/LICENSE.md)
7
+ [![RBS Types](https://img.shields.io/badge/types-RBS-informational.svg?style=flat-square)](https://github.com/midnight-wonderer/xoodyak-rb/blob/main/sig/xoodyak.rbs)
8
+
9
+ ---
10
+
11
+ ## 📖 Table of Contents
12
+
13
+ - [Introduction](#-introduction)
14
+ - [Features](#-features)
15
+ - [Installation](#-installation)
16
+ - [Usage Guide](#-usage-guide)
17
+ - [1. Hashing (Unkeyed Mode)](#1-hashing-unkeyed-mode)
18
+ - [2. Ruby Digest API Integration](#2-ruby-digest-api-integration)
19
+ - [3. Symmetric Encryption (Keyed Mode)](#3-symmetric-encryption-keyed-mode)
20
+ - [4. Authenticated Encryption (AEAD)](#4-authenticated-encryption-aead)
21
+ - [Combined Ciphertext & Tag](#combined-ciphertext--tag)
22
+ - [Detached Ciphertext & Tag](#detached-ciphertext--tag)
23
+ - [5. Advanced Keyed Customization (Nonces, Key IDs, Counters)](#5-advanced-keyed-customization-nonces-key-ids-counters)
24
+ - [6. Forward Secrecy (State Ratcheting)](#6-forward-secrecy-state-ratcheting)
25
+ - [7. Stateful Session-based Encrypt/Decrypt](#7-stateful-session-based-encryptdecrypt)
26
+ - [8. State Cloning & Checkpointing](#8-state-cloning--checkpointing)
27
+ - [API Reference](#-api-reference)
28
+ - [Type Safety with RBS](#-type-safety-with-rbs)
29
+ - [Development & Testing](#-development--testing)
30
+ - [License](#-license)
31
+
32
+ ---
33
+
34
+ ## 🌟 Introduction
35
+
36
+ **Xoodyak** is a lightweight cryptographic scheme designed by the Keccak team (creators of SHA-3). It is part of the Keccak family and is optimized for low-resource environments. Xoodyak operates as a stateful "sponge" construction, making it extremely versatile. A single instance can perform:
37
+ - **Hashing** (unkeyed mode)
38
+ - **Symmetric Encryption** (keyed mode)
39
+ - **Message Authentication Codes (MAC)** (keyed mode)
40
+ - **Authenticated Encryption with Associated Data (AEAD)** (keyed mode)
41
+ - **Key Derivation & Ratcheting**
42
+
43
+ This gem provides a production-ready Ruby interface to Xoodyak, wrapping a highly optimized Rust implementation.
44
+
45
+ ---
46
+
47
+ ## ⚡ Features
48
+
49
+ - 🏎️ **Blazing Fast**: Native Rust extension using `magnus` and `rb-sys` outpaces pure Ruby cryptography.
50
+ - 🔒 **Sponge-based Design**: Supports stateful session-based protocols.
51
+ - 🛠️ **Seamless Digest Integration**: Inherits from Ruby's standard `Digest::Base` for drop-in compatibility.
52
+ - 📦 **Zero-Configuration AEAD**: Simple combined and detached AEAD interfaces.
53
+ - 🧩 **RBS Typed**: Complete type definitions shipped out of the box.
54
+ - 🛡️ **Memory Safe**: Built-in Rust safety guarantees prevent common memory leaks and buffer overflows.
55
+
56
+ ---
57
+
58
+ ## 📥 Installation
59
+
60
+ Add this line to your application's Gemfile:
61
+
62
+ ```ruby
63
+ gem 'xoodyak'
64
+ ```
65
+
66
+ And then execute:
67
+
68
+ ```bash
69
+ $ bundle install
70
+ ```
71
+
72
+ Or install it directly via:
73
+
74
+ ```bash
75
+ $ gem install xoodyak
76
+ ```
77
+
78
+ > [!NOTE]
79
+ > Since this gem includes a Rust extension, you must have the **Rust toolchain** (cargo/rustc) installed on your system to compile it.
80
+
81
+ ---
82
+
83
+ ## 🚀 Usage Guide
84
+
85
+ ### 1. Hashing (Unkeyed Mode)
86
+
87
+ In unkeyed mode, Xoodyak acts as a standard cryptographic hash function. You can feed data incrementally using `absorb` and extract the hash using `squeeze`.
88
+
89
+ ```ruby
90
+ require 'xoodyak'
91
+
92
+ # Initialize in unkeyed (hashing) mode
93
+ hash_sponge = Xoodyak.new
94
+
95
+ # Absorb data
96
+ hash_sponge.absorb("Hello, world!")
97
+
98
+ # Squeeze out the digest (you can request any length!)
99
+ digest = hash_sponge.squeeze(32)
100
+ # => returns a 32-byte binary string
101
+ ```
102
+
103
+ ### 2. Ruby Digest API Integration
104
+
105
+ For standard hashing tasks, this gem integrates directly with Ruby's `Digest` framework.
106
+
107
+ ```ruby
108
+ require 'xoodyak'
109
+
110
+ # 1. Instantiate the Digest class
111
+ digest = Xoodyak::Digest.new
112
+ digest.update("Hello, ")
113
+ digest.update("world!")
114
+ puts digest.hexdigest
115
+ # => "c1ae6b98..."
116
+
117
+ # 2. Or use the shortcut methods
118
+ hex_hash = Digest::Xoodyak.hexdigest("Hello, world!")
119
+ binary_hash = Digest::Xoodyak.digest("Hello, world!")
120
+
121
+ # 3. Dynamic loading also works
122
+ algo = Digest("Xoodyak").new
123
+ ```
124
+
125
+ ### 3. Symmetric Encryption (Keyed Mode)
126
+
127
+ By passing a key during initialization, Xoodyak enters **keyed mode**. This allows standard symmetric encryption and decryption.
128
+
129
+ ```ruby
130
+ require 'xoodyak'
131
+
132
+ key = "my-secure-key-16" # Can be any length (Xoodyak handles varying key lengths)
133
+
134
+ # Encrypting
135
+ encryptor = Xoodyak.new(key)
136
+ ciphertext = encryptor.encrypt("super secret message")
137
+
138
+ # Decrypting (initialize a new state with the same key)
139
+ decryptor = Xoodyak.new(key)
140
+ plaintext = decryptor.decrypt(ciphertext)
141
+ puts plaintext # => "super secret message"
142
+ ```
143
+
144
+ ### 4. Authenticated Encryption (AEAD)
145
+
146
+ Standard encryption protects confidentiality but not integrity. **AEAD** (Authenticated Encryption with Associated Data) is highly recommended because it also authenticates the message and optional "Associated Data" (like unencrypted routing headers).
147
+
148
+ #### Combined Ciphertext & Tag
149
+
150
+ `aead_encrypt` appends a 16-byte authentication tag directly to the ciphertext. `aead_decrypt` verifies the tag and returns the decrypted text, raising an error if the tag is invalid.
151
+
152
+ ```ruby
153
+ require 'xoodyak'
154
+ require 'securerandom'
155
+
156
+ key = "my-secure-key-16"
157
+ nonce = SecureRandom.bytes(16) # Nonces must be unique for each encryption!
158
+
159
+ # Encrypt with Associated Data
160
+ alice = Xoodyak.new(key, nonce)
161
+ alice.absorb("Associated Data (unencrypted header)")
162
+ ciphertext_with_tag = alice.aead_encrypt("confidential message")
163
+
164
+ # Decrypt and Verify
165
+ bob = Xoodyak.new(key, nonce)
166
+ bob.absorb("Associated Data (unencrypted header)") # Must match Alice's AD
167
+
168
+ begin
169
+ decrypted = bob.aead_decrypt(ciphertext_with_tag)
170
+ puts decrypted # => "confidential message"
171
+ rescue Xoodyak::Error => e
172
+ # Raised if ciphertext or associated data was altered
173
+ puts "Integrity check failed: #{e.message}"
174
+ end
175
+ ```
176
+
177
+ #### Detached Ciphertext & Tag
178
+
179
+ If your protocol stores or transmits the ciphertext and tag separately, you can use the detached API:
180
+
181
+ ```ruby
182
+ require 'xoodyak'
183
+ require 'securerandom'
184
+
185
+ key = "my-secure-key-16"
186
+ nonce = SecureRandom.bytes(16)
187
+
188
+ # Encrypt
189
+ alice = Xoodyak.new(key, nonce)
190
+ alice.absorb("metadata")
191
+ ciphertext, tag = alice.aead_encrypt_detached("confidential message")
192
+
193
+ # Decrypt and Verify
194
+ bob = Xoodyak.new(key, nonce)
195
+ bob.absorb("metadata")
196
+
197
+ begin
198
+ decrypted = bob.aead_decrypt_detached(ciphertext, tag)
199
+ puts decrypted # => "confidential message"
200
+ rescue Xoodyak::Error => e
201
+ puts "Integrity check failed: #{e.message}"
202
+ end
203
+ ```
204
+
205
+ ### 5. Advanced Keyed Customization (Nonces, Key IDs, Counters)
206
+
207
+ Xoodyak supports initializing the keyed state with a variety of optional parameters:
208
+ - `key` (required for keyed mode)
209
+ - `nonce` (optional binary string)
210
+ - `key_id` (optional binary string)
211
+ - `counter` (optional binary string)
212
+
213
+ ```ruby
214
+ # Initialize with key, nonce, key_id, and counter
215
+ xoodyak = Xoodyak.new(key, nonce, key_id, counter)
216
+ ```
217
+
218
+ > [!WARNING]
219
+ > Passing `nonce`, `key_id`, or `counter` without a `key` will raise an `ArgumentError`.
220
+
221
+ ### 6. Forward Secrecy (State Ratcheting)
222
+
223
+ State ratcheting advances the keyed state in a non-reversible way. Even if an attacker gains access to the current state, they cannot reconstruct past states, providing forward secrecy.
224
+
225
+ ```ruby
226
+ require 'xoodyak'
227
+
228
+ xoodyak = Xoodyak.new("my-secret-key")
229
+
230
+ # Perform operations...
231
+ xoodyak.absorb("some context")
232
+
233
+ # Ratchet the state
234
+ xoodyak.ratchet
235
+
236
+ # Squeeze out session keys or continue encrypting
237
+ session_key = xoodyak.squeeze(32)
238
+ ```
239
+
240
+ ### 7. Stateful Session-based Encrypt/Decrypt
241
+
242
+ Xoodyak is stateful: every operation transitions the internal sponge state. This allows Bob and Alice to have a stateful session where they encrypt and decrypt a stream of messages in order.
243
+
244
+ ```ruby
245
+ require 'xoodyak'
246
+ require 'securerandom'
247
+
248
+ key = "session-key-1234"
249
+ nonce = SecureRandom.bytes(16)
250
+
251
+ alice = Xoodyak.new(key, nonce)
252
+ bob = Xoodyak.new(key, nonce)
253
+
254
+ # Alice sends first message
255
+ ct1 = alice.encrypt("Message 1")
256
+ # Bob receives and decrypts
257
+ puts bob.decrypt(ct1) # => "Message 1"
258
+
259
+ # Alice sends second message (depends on state mutated by msg1!)
260
+ ct2 = alice.encrypt("Message 2")
261
+ # Bob decrypts
262
+ puts bob.decrypt(ct2) # => "Message 2"
263
+ ```
264
+
265
+ > [!IMPORTANT]
266
+ > Because the state mutates with each operation, Alice and Bob must remain in perfect sync. If any message is lost, reordered, or duplicated, decryption will fail. This provides built-in replay and out-of-order protection.
267
+
268
+ ### 8. State Cloning & Checkpointing
269
+
270
+ You can duplicate or clone the state of a Xoodyak instance. This is useful for saving checkpoints or branching a cryptographic session.
271
+
272
+ ```ruby
273
+ require 'xoodyak'
274
+
275
+ xoodyak = Xoodyak.new
276
+ xoodyak.absorb("initial setup data")
277
+
278
+ # Duplicate the state
279
+ checkpoint = xoodyak.dup
280
+
281
+ # Both instances can now diverge independently
282
+ xoodyak.absorb("branch A")
283
+ checkpoint.absorb("branch B")
284
+
285
+ puts xoodyak.squeeze(16).unpack1("H*") # Squeezes based on "initial setup data" + "branch A"
286
+ puts checkpoint.squeeze(16).unpack1("H*") # Squeezes based on "initial setup data" + "branch B"
287
+ ```
288
+
289
+ ---
290
+
291
+ ## 🛠️ API Reference
292
+
293
+ ### `Xoodyak` Class
294
+
295
+ | Method | Signature | Mode | Description |
296
+ | :--- | :--- | :--- | :--- |
297
+ | `initialize` | `(key=nil, nonce=nil, key_id=nil, counter=nil)` | Any | Creates a Xoodyak instance. Enters keyed mode if a key is provided. |
298
+ | `absorb` | `(bin: String) -> void` | Any | Absorbs binary data into the state. |
299
+ | `squeeze` | `(len: Integer) -> String` | Any | Squeezes `len` bytes from the state. |
300
+ | `squeeze_key` | `(len: Integer) -> String` | Any | Squeezes `len` key bytes from the state. |
301
+ | `encrypt` | `(bin: String) -> String` | Keyed | Encrypts a message. |
302
+ | `decrypt` | `(bin: String) -> String` | Keyed | Decrypts a message. |
303
+ | `aead_encrypt` | `(bin: String) -> String` | Keyed | Encrypts a message, appending a 16-byte authentication tag. |
304
+ | `aead_decrypt` | `(bin: String) -> String` | Keyed | Verifies the tag and decrypts a combined AEAD message. |
305
+ | `aead_encrypt_detached` | `(bin: String) -> [String, String]` | Keyed | Encrypts a message, returning `[ciphertext, tag]`. |
306
+ | `aead_decrypt_detached` | `(bin: String, tag: String) -> String` | Keyed | Verifies the detached tag and decrypts the ciphertext. |
307
+ | `ratchet` | `() -> void` | Keyed | Ratchets the state to provide forward secrecy. |
308
+ | `dup` / `clone` | `() -> Xoodyak` | Any | Creates a deep copy of the Xoodyak instance state. |
309
+
310
+ ---
311
+
312
+ ## 🧩 Type Safety with RBS
313
+
314
+ This gem is packaged with complete RBS type definitions. You can typecheck your application using Steep or other Ruby signature verification tools.
315
+
316
+ Type signatures are defined in [sig/xoodyak.rbs](file:///storage/projects/xoodyak-rb/sig/xoodyak.rbs).
317
+
318
+ ---
319
+
320
+ ## 🔧 Development & Testing
321
+
322
+ After checking out the repo, run `bin/setup` to install dependencies.
323
+
324
+ ### Compilation
325
+
326
+ Since the core cryptographic operations are written in Rust, you must compile the C-extension locally:
327
+
328
+ ```bash
329
+ bundle exec rake compile
330
+ ```
331
+
332
+ ### Running Tests
333
+
334
+ Run the RSpec test suite:
335
+
336
+ ```bash
337
+ bundle exec rake spec
338
+ ```
339
+
340
+ ### Linting
341
+
342
+ Check code formatting and style guidelines:
343
+
344
+ ```bash
345
+ bundle exec rake rubocop
346
+ ```
347
+
348
+ ---
349
+
350
+ ## 📄 License
351
+
352
+ This gem is available as open source under the terms of the [BSD 2-Clause License](LICENSE.md).
@@ -0,0 +1,24 @@
1
+ [package]
2
+ name = "xoodyak"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+
7
+ [lib]
8
+ crate-type = ["cdylib"]
9
+
10
+ [dependencies]
11
+ magnus = { version = "0.8.2", features = ["rb-sys"] }
12
+ rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
13
+ xoodyak = "0.8.4"
14
+
15
+ [build-dependencies]
16
+ rb-sys-env = "0.2.2"
17
+
18
+ [dev-dependencies]
19
+ rb-sys-test-helpers = { version = "0.2.2" }
20
+
21
+ [lints.rust]
22
+ unexpected_cfgs = { level = "warn", check-cfg = [
23
+ 'cfg(digest_use_rb_ext_resolve_symbol)',
24
+ ] }
@@ -0,0 +1,5 @@
1
+ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
2
+ let _ = rb_sys_env::activate()?;
3
+
4
+ Ok(())
5
+ }
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ # Detect when DIGEST_USE_RB_EXT_RESOLVE_SYMBOL is defined truthy,
7
+ # which indicates rb_ext_resolve_symbol("digest.so", "rb_digest_wrap_metadata")
8
+ # will work with the version of Digest we're building against.
9
+ digest_use_rb_ext_resolve_symbol = try_compile(<<~C)
10
+ #include <ruby/digest.h>
11
+
12
+ #if !defined(DIGEST_USE_RB_EXT_RESOLVE_SYMBOL)
13
+ # error DIGEST_USE_RB_EXT_RESOLVE_SYMBOL not defined
14
+ #endif
15
+ #if !DIGEST_USE_RB_EXT_RESOLVE_SYMBOL
16
+ # error DIGEST_USE_RB_EXT_RESOLVE_SYMBOL is 0
17
+ #endif
18
+ C
19
+ puts("digest_use_rb_ext_resolve_symbol=#{digest_use_rb_ext_resolve_symbol}")
20
+
21
+ create_rust_makefile("xoodyak/xoodyak") do |r|
22
+ r.extra_rustflags = ["--cfg digest_use_rb_ext_resolve_symbol"] if digest_use_rb_ext_resolve_symbol
23
+ end
@@ -0,0 +1,438 @@
1
+ use std::cell::RefCell;
2
+ use std::ffi::{c_int, c_uchar, c_void};
3
+ use std::mem::MaybeUninit;
4
+ use std::os::raw::c_char;
5
+ use magnus::{
6
+ prelude::*, scan_args::scan_args, Error, ExceptionClass, RString, Ruby, Value,
7
+ };
8
+ use rb_sys::{VALUE, size_t};
9
+ use xoodyak::{XoodyakHash, XoodyakKeyed, XoodyakCommon, XoodyakError, XOODYAK_AUTH_TAG_BYTES};
10
+
11
+ // Low-level Digest bindings
12
+ pub const RUBY_DIGEST_API_VERSION: c_int = 3;
13
+
14
+ pub type RbDigestHashInitFuncT = unsafe extern "C" fn(*mut c_void) -> c_int;
15
+ pub type RbDigestHashUpdateFuncT = unsafe extern "C" fn(*mut c_void, *mut c_uchar, size_t);
16
+ pub type RbDigestHashFinishFuncT = unsafe extern "C" fn(*mut c_void, *mut c_uchar) -> c_int;
17
+
18
+ #[derive(Debug)]
19
+ #[repr(C)]
20
+ pub struct RbDigestMetadataT {
21
+ pub api_version: c_int,
22
+ pub digest_len: size_t,
23
+ pub block_len: size_t,
24
+ pub ctx_size: size_t,
25
+ pub init_func: RbDigestHashInitFuncT,
26
+ pub update_func: RbDigestHashUpdateFuncT,
27
+ pub finish_func: RbDigestHashFinishFuncT,
28
+ }
29
+
30
+ #[allow(dead_code)]
31
+ type WrapperType = unsafe extern "C" fn(&'static RbDigestMetadataT) -> VALUE;
32
+
33
+ #[cfg(any(digest_use_rb_ext_resolve_symbol, ruby_gte_3_4))]
34
+ pub unsafe fn rb_digest_make_metadata(meta: &'static RbDigestMetadataT) -> VALUE {
35
+ static mut WRAPPER: Option<WrapperType> = None;
36
+
37
+ unsafe fn load_wrapper() {
38
+ use rb_sys::rb_ext_resolve_symbol;
39
+ use std::ffi::c_char;
40
+ use std::sync::Once;
41
+
42
+ static INIT: Once = Once::new();
43
+
44
+ INIT.call_once(|| {
45
+ let lib_name = "digest.so\0".as_ptr() as *const c_char;
46
+ let symbol_name = "rb_digest_wrap_metadata\0".as_ptr() as *const c_char;
47
+ let symbol_ptr = unsafe { rb_ext_resolve_symbol(lib_name, symbol_name) };
48
+
49
+ if !symbol_ptr.is_null() {
50
+ unsafe {
51
+ WRAPPER = Some(std::mem::transmute::<*mut c_void, WrapperType>(symbol_ptr));
52
+ }
53
+ } else {
54
+ panic!("Failed to resolve rb_digest_wrap_metadata");
55
+ }
56
+ });
57
+ }
58
+
59
+ unsafe {
60
+ load_wrapper();
61
+ if let Some(wrapper) = WRAPPER {
62
+ return wrapper(meta);
63
+ }
64
+ }
65
+ panic!("Failed to resolve rb_digest_wrap_metadata");
66
+ }
67
+
68
+ #[cfg(not(any(digest_use_rb_ext_resolve_symbol, ruby_gte_3_4)))]
69
+ pub unsafe fn rb_digest_make_metadata(meta: &'static RbDigestMetadataT) -> VALUE {
70
+ use rb_sys::{rb_data_object_wrap, rb_obj_freeze};
71
+ unsafe {
72
+ let data = rb_data_object_wrap(
73
+ 0 as VALUE,
74
+ meta as *const RbDigestMetadataT as *mut c_void,
75
+ None,
76
+ None,
77
+ );
78
+ rb_obj_freeze(data)
79
+ }
80
+ }
81
+
82
+ struct RbXoodyakDigestCtx {
83
+ hash: XoodyakHash,
84
+ buffer: [u8; 16],
85
+ buf_len: usize,
86
+ first_block_absorbed: bool,
87
+ }
88
+
89
+ // Struct to configure dynamic digest metadata
90
+ struct XoodyakDigest;
91
+
92
+ impl XoodyakDigest {
93
+ const BLOCK_LEN: usize = 16;
94
+ const DIGEST_LEN: usize = 32;
95
+
96
+ fn digest_metadata() -> &'static RbDigestMetadataT {
97
+ static DIGEST_METADATA: RbDigestMetadataT = RbDigestMetadataT {
98
+ api_version: RUBY_DIGEST_API_VERSION,
99
+ digest_len: XoodyakDigest::DIGEST_LEN as _,
100
+ block_len: XoodyakDigest::BLOCK_LEN as _,
101
+ ctx_size: std::mem::size_of::<RbXoodyakDigestCtx>() as _,
102
+ init_func: XoodyakDigest::init_in_place,
103
+ update_func: XoodyakDigest::update,
104
+ finish_func: XoodyakDigest::finish,
105
+ };
106
+ &DIGEST_METADATA
107
+ }
108
+
109
+ extern "C" fn init_in_place(ctx: *mut c_void) -> c_int {
110
+ let ctx = ctx as *mut MaybeUninit<RbXoodyakDigestCtx>;
111
+ let ctx = unsafe { &mut *ctx };
112
+ ctx.write(RbXoodyakDigestCtx {
113
+ hash: XoodyakHash::new(),
114
+ buffer: [0u8; 16],
115
+ buf_len: 0,
116
+ first_block_absorbed: false,
117
+ });
118
+ true as _
119
+ }
120
+
121
+ extern "C" fn update(ctx: *mut c_void, data: *mut c_uchar, len: size_t) {
122
+ let ctx = ctx as *mut MaybeUninit<RbXoodyakDigestCtx>;
123
+ let ctx = unsafe { &mut *ctx };
124
+ let ctx = unsafe { ctx.assume_init_mut() };
125
+ let mut slice = unsafe { std::slice::from_raw_parts(data, len as _) };
126
+
127
+ while !slice.is_empty() {
128
+ let space = 16 - ctx.buf_len;
129
+ if slice.len() >= space {
130
+ ctx.buffer[ctx.buf_len..16].copy_from_slice(&slice[..space]);
131
+ slice = &slice[space..];
132
+
133
+ if !ctx.first_block_absorbed {
134
+ ctx.hash.absorb(&ctx.buffer);
135
+ ctx.first_block_absorbed = true;
136
+ } else {
137
+ ctx.hash.absorb_more(&ctx.buffer, 16);
138
+ }
139
+ ctx.buf_len = 0;
140
+ } else {
141
+ ctx.buffer[ctx.buf_len..ctx.buf_len + slice.len()].copy_from_slice(slice);
142
+ ctx.buf_len += slice.len();
143
+ break;
144
+ }
145
+ }
146
+ }
147
+
148
+ extern "C" fn finish(ctx: *mut c_void, digest: *mut c_uchar) -> c_int {
149
+ let ctx = ctx as *mut MaybeUninit<RbXoodyakDigestCtx>;
150
+ let ctx = unsafe { &mut *ctx };
151
+ let ctx = unsafe { ctx.assume_init_mut() };
152
+
153
+ if ctx.buf_len > 0 {
154
+ if !ctx.first_block_absorbed {
155
+ ctx.hash.absorb(&ctx.buffer[..ctx.buf_len]);
156
+ ctx.first_block_absorbed = true;
157
+ } else {
158
+ ctx.hash.absorb_more(&ctx.buffer[..ctx.buf_len], 16);
159
+ }
160
+ ctx.buf_len = 0;
161
+ }
162
+
163
+ let outbuf = unsafe { std::slice::from_raw_parts_mut(digest, Self::DIGEST_LEN) };
164
+ ctx.hash.squeeze(outbuf);
165
+ true as _
166
+ }
167
+ }
168
+
169
+ fn get_error_class(name: &str) -> Option<ExceptionClass> {
170
+ let ruby = Ruby::get().unwrap();
171
+ ruby.class_object()
172
+ .const_get::<_, magnus::RClass>("Xoodyak")
173
+ .ok()
174
+ .and_then(|xoodyak| xoodyak.const_get::<_, ExceptionClass>(name).ok())
175
+ }
176
+
177
+ fn keyed_mode_error(msg: &'static str) -> Error {
178
+ let ruby = Ruby::get().unwrap();
179
+ if let Some(error_class) = get_error_class("KeyedModeError") {
180
+ Error::new(error_class, msg)
181
+ } else {
182
+ Error::new(ruby.exception_runtime_error(), msg)
183
+ }
184
+ }
185
+
186
+ fn argument_error(msg: &'static str) -> Error {
187
+ let ruby = Ruby::get().unwrap();
188
+ if let Some(error_class) = get_error_class("ArgumentError") {
189
+ Error::new(error_class, msg)
190
+ } else {
191
+ Error::new(ruby.exception_arg_error(), msg)
192
+ }
193
+ }
194
+
195
+ fn verification_error(msg: &'static str) -> Error {
196
+ let ruby = Ruby::get().unwrap();
197
+ if let Some(error_class) = get_error_class("VerificationError") {
198
+ Error::new(error_class, msg)
199
+ } else {
200
+ Error::new(ruby.exception_runtime_error(), msg)
201
+ }
202
+ }
203
+
204
+ // Custom error mapping
205
+ fn map_xoodyak_err(err: XoodyakError) -> Error {
206
+ match err {
207
+ XoodyakError::InvalidBufferLength => argument_error("invalid buffer length"),
208
+ XoodyakError::InvalidParameterLength => argument_error("invalid parameter length"),
209
+ XoodyakError::KeyRequired => argument_error("key required"),
210
+ XoodyakError::TagMismatch => verification_error("tag mismatch"),
211
+ }
212
+ }
213
+
214
+ // Unified Xoodyak class
215
+ #[derive(Clone)]
216
+ enum XoodyakState {
217
+ Unkeyed(XoodyakHash),
218
+ Keyed(XoodyakKeyed),
219
+ }
220
+
221
+ #[derive(magnus::TypedData, Clone)]
222
+ #[magnus(class = "Xoodyak", free_immediately)]
223
+ pub struct Xoodyak {
224
+ state: RefCell<XoodyakState>,
225
+ }
226
+
227
+ impl Default for XoodyakState {
228
+ fn default() -> Self {
229
+ XoodyakState::Unkeyed(XoodyakHash::new())
230
+ }
231
+ }
232
+
233
+ impl Default for Xoodyak {
234
+ fn default() -> Self {
235
+ Xoodyak {
236
+ state: RefCell::new(XoodyakState::default()),
237
+ }
238
+ }
239
+ }
240
+
241
+ impl magnus::DataTypeFunctions for Xoodyak {}
242
+
243
+ impl Xoodyak {
244
+ fn initialize(rb_self: magnus::typed_data::Obj<Self>, args: &[Value]) -> Result<(), Error> {
245
+ let args = scan_args::<(), (Option<Option<RString>>, Option<Option<RString>>, Option<Option<RString>>, Option<Option<RString>>), (), (), (), ()>(args)?;
246
+ let (key, nonce, key_id, counter) = args.optional;
247
+ let key = key.flatten();
248
+ let nonce = nonce.flatten();
249
+ let key_id = key_id.flatten();
250
+ let counter = counter.flatten();
251
+ if let Some(k) = key {
252
+ let key_bytes = unsafe { k.as_slice() };
253
+ let nonce_bytes = nonce.as_ref().map(|n| unsafe { n.as_slice() });
254
+ let key_id_bytes = key_id.as_ref().map(|ki| unsafe { ki.as_slice() });
255
+ let counter_bytes = counter.as_ref().map(|c| unsafe { c.as_slice() });
256
+ let keyed = XoodyakKeyed::new(key_bytes, nonce_bytes, key_id_bytes, counter_bytes)
257
+ .map_err(map_xoodyak_err)?;
258
+ *rb_self.state.borrow_mut() = XoodyakState::Keyed(keyed);
259
+ } else {
260
+ if nonce.is_some() || key_id.is_some() || counter.is_some() {
261
+ return Err(argument_error(
262
+ "nonce, key_id, and counter can only be used in keyed mode (when key is provided)",
263
+ ));
264
+ }
265
+ *rb_self.state.borrow_mut() = XoodyakState::Unkeyed(XoodyakHash::new());
266
+ }
267
+ Ok(())
268
+ }
269
+
270
+ fn initialize_copy(&self, other: &Xoodyak) -> Result<(), Error> {
271
+ let other_state = other.state.borrow().clone();
272
+ *self.state.borrow_mut() = other_state;
273
+ Ok(())
274
+ }
275
+
276
+ fn absorb(&self, bin: RString) {
277
+ let bin_bytes = unsafe { bin.as_slice() };
278
+ match &mut *self.state.borrow_mut() {
279
+ XoodyakState::Unkeyed(ref mut h) => h.absorb(bin_bytes),
280
+ XoodyakState::Keyed(ref mut k) => k.absorb(bin_bytes),
281
+ }
282
+ }
283
+
284
+ fn squeeze(&self, len: usize) -> RString {
285
+ let mut buf = vec![0u8; len];
286
+ match &mut *self.state.borrow_mut() {
287
+ XoodyakState::Unkeyed(ref mut h) => h.squeeze(&mut buf),
288
+ XoodyakState::Keyed(ref mut k) => k.squeeze(&mut buf),
289
+ }
290
+ Ruby::get().unwrap().str_from_slice(&buf)
291
+ }
292
+
293
+ fn squeeze_key(&self, len: usize) -> RString {
294
+ let mut buf = vec![0u8; len];
295
+ match &mut *self.state.borrow_mut() {
296
+ XoodyakState::Unkeyed(ref mut h) => h.squeeze_key(&mut buf),
297
+ XoodyakState::Keyed(ref mut k) => k.squeeze_key(&mut buf),
298
+ }
299
+ Ruby::get().unwrap().str_from_slice(&buf)
300
+ }
301
+
302
+ fn encrypt(&self, bin: RString) -> Result<RString, Error> {
303
+ let bin_bytes = unsafe { bin.as_slice() };
304
+ match &mut *self.state.borrow_mut() {
305
+ XoodyakState::Unkeyed(_) => Err(keyed_mode_error("encrypt is only supported in keyed mode")),
306
+ XoodyakState::Keyed(ref mut k) => {
307
+ let mut out = vec![0u8; bin_bytes.len()];
308
+ k.encrypt(&mut out, bin_bytes).map_err(map_xoodyak_err)?;
309
+ Ok(Ruby::get().unwrap().str_from_slice(&out))
310
+ }
311
+ }
312
+ }
313
+
314
+ fn decrypt(&self, bin: RString) -> Result<RString, Error> {
315
+ let bin_bytes = unsafe { bin.as_slice() };
316
+ match &mut *self.state.borrow_mut() {
317
+ XoodyakState::Unkeyed(_) => Err(keyed_mode_error("decrypt is only supported in keyed mode")),
318
+ XoodyakState::Keyed(ref mut k) => {
319
+ let mut out = vec![0u8; bin_bytes.len()];
320
+ k.decrypt(&mut out, bin_bytes).map_err(map_xoodyak_err)?;
321
+ Ok(Ruby::get().unwrap().str_from_slice(&out))
322
+ }
323
+ }
324
+ }
325
+
326
+ fn aead_encrypt(&self, bin: RString) -> Result<RString, Error> {
327
+ let bin_bytes = unsafe { bin.as_slice() };
328
+ match &mut *self.state.borrow_mut() {
329
+ XoodyakState::Unkeyed(_) => Err(keyed_mode_error("aead_encrypt is only supported in keyed mode")),
330
+ XoodyakState::Keyed(ref mut k) => {
331
+ let mut out = vec![0u8; bin_bytes.len() + XOODYAK_AUTH_TAG_BYTES];
332
+ k.aead_encrypt(&mut out, Some(bin_bytes)).map_err(map_xoodyak_err)?;
333
+ Ok(Ruby::get().unwrap().str_from_slice(&out))
334
+ }
335
+ }
336
+ }
337
+
338
+ fn aead_decrypt(&self, bin: RString) -> Result<RString, Error> {
339
+ let bin_bytes = unsafe { bin.as_slice() };
340
+ match &mut *self.state.borrow_mut() {
341
+ XoodyakState::Unkeyed(_) => Err(keyed_mode_error("aead_decrypt is only supported in keyed mode")),
342
+ XoodyakState::Keyed(ref mut k) => {
343
+ if bin_bytes.len() < XOODYAK_AUTH_TAG_BYTES {
344
+ return Err(argument_error("ciphertext is too short to contain a tag"));
345
+ }
346
+ let mut out = vec![0u8; bin_bytes.len() - XOODYAK_AUTH_TAG_BYTES];
347
+ k.aead_decrypt(&mut out, bin_bytes).map_err(map_xoodyak_err)?;
348
+ Ok(Ruby::get().unwrap().str_from_slice(&out))
349
+ }
350
+ }
351
+ }
352
+
353
+ fn aead_encrypt_detached(&self, bin: RString) -> Result<magnus::RArray, Error> {
354
+ let bin_bytes = unsafe { bin.as_slice() };
355
+ match &mut *self.state.borrow_mut() {
356
+ XoodyakState::Unkeyed(_) => Err(keyed_mode_error("aead_encrypt_detached is only supported in keyed mode")),
357
+ XoodyakState::Keyed(ref mut k) => {
358
+ let mut out = vec![0u8; bin_bytes.len()];
359
+ let tag = k.aead_encrypt_detached(&mut out, Some(bin_bytes)).map_err(map_xoodyak_err)?;
360
+ let ruby = Ruby::get().unwrap();
361
+ let ct_str = ruby.str_from_slice(&out);
362
+ let tag_str = ruby.str_from_slice(tag.as_ref());
363
+ Ok(ruby.ary_new_from_values(&[ct_str.as_value(), tag_str.as_value()]))
364
+ }
365
+ }
366
+ }
367
+
368
+ fn aead_decrypt_detached(&self, bin: RString, tag: RString) -> Result<RString, Error> {
369
+ let bin_bytes = unsafe { bin.as_slice() };
370
+ let tag_bytes = unsafe { tag.as_slice() };
371
+ match &mut *self.state.borrow_mut() {
372
+ XoodyakState::Unkeyed(_) => Err(keyed_mode_error("aead_decrypt_detached is only supported in keyed mode")),
373
+ XoodyakState::Keyed(ref mut k) => {
374
+ let t_array: [u8; XOODYAK_AUTH_TAG_BYTES] = tag_bytes.try_into().map_err(|_| {
375
+ argument_error("tag must be 16 bytes")
376
+ })?;
377
+ let mut out = vec![0u8; bin_bytes.len()];
378
+ k.aead_decrypt_detached(&mut out, &t_array.into(), Some(bin_bytes))
379
+ .map_err(map_xoodyak_err)?;
380
+ Ok(Ruby::get().unwrap().str_from_slice(&out))
381
+ }
382
+ }
383
+ }
384
+
385
+ fn ratchet(&self) -> Result<(), Error> {
386
+ match &mut *self.state.borrow_mut() {
387
+ XoodyakState::Unkeyed(_) => Err(keyed_mode_error("ratchet is only supported in keyed mode")),
388
+ XoodyakState::Keyed(ref mut k) => {
389
+ k.ratchet();
390
+ Ok(())
391
+ }
392
+ }
393
+ }
394
+ }
395
+
396
+ #[magnus::init]
397
+ fn init(ruby: &Ruby) -> Result<(), Error> {
398
+ // Define the Xoodyak class at the top level
399
+ let class = ruby.define_class("Xoodyak", ruby.class_object())?;
400
+ class.define_alloc_func::<Xoodyak>();
401
+ class.define_method("initialize", magnus::method!(Xoodyak::initialize, -1))?;
402
+ class.define_method("initialize_copy", magnus::method!(Xoodyak::initialize_copy, 1))?;
403
+ class.define_method("absorb", magnus::method!(Xoodyak::absorb, 1))?;
404
+ class.define_method("squeeze", magnus::method!(Xoodyak::squeeze, 1))?;
405
+ class.define_method("squeeze_key", magnus::method!(Xoodyak::squeeze_key, 1))?;
406
+ class.define_method("encrypt", magnus::method!(Xoodyak::encrypt, 1))?;
407
+ class.define_method("decrypt", magnus::method!(Xoodyak::decrypt, 1))?;
408
+ class.define_method("aead_encrypt", magnus::method!(Xoodyak::aead_encrypt, 1))?;
409
+ class.define_method("aead_decrypt", magnus::method!(Xoodyak::aead_decrypt, 1))?;
410
+ class.define_method("aead_encrypt_detached", magnus::method!(Xoodyak::aead_encrypt_detached, 1))?;
411
+ class.define_method("aead_decrypt_detached", magnus::method!(Xoodyak::aead_decrypt_detached, 2))?;
412
+ class.define_method("ratchet", magnus::method!(Xoodyak::ratchet, 0))?;
413
+
414
+ // Define the custom Error classes/modules under Xoodyak class
415
+ let error_module = class.define_module("Error")?;
416
+
417
+ let keyed_mode_error = class.define_error("KeyedModeError", ruby.exception_standard_error())?;
418
+ keyed_mode_error.include_module(error_module)?;
419
+
420
+ let verification_error = class.define_error("VerificationError", ruby.exception_standard_error())?;
421
+ verification_error.include_module(error_module)?;
422
+
423
+ let argument_error = class.define_error("ArgumentError", ruby.exception_arg_error())?;
424
+ argument_error.include_module(error_module)?;
425
+
426
+ // Define Digest subclassing Digest::Base nested under Xoodyak class
427
+ ruby.require("digest")?;
428
+ let digest_module = ruby.define_module("Digest")?;
429
+ let digest_base = digest_module.const_get::<_, magnus::RClass>("Base")?;
430
+ let digest_klass = class.define_class("Digest", digest_base)?;
431
+
432
+ use magnus::rb_sys::AsRawValue;
433
+ let meta = unsafe { rb_digest_make_metadata(XoodyakDigest::digest_metadata()) };
434
+ let metadata_id = unsafe { rb_sys::rb_intern("metadata\0".as_ptr() as *const c_char) };
435
+ unsafe { rb_sys::rb_ivar_set(digest_klass.as_raw(), metadata_id, meta) };
436
+
437
+ Ok(())
438
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "xoodyak"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Xoodyak
4
+ VERSION = "0.1.0"
5
+ end
data/lib/xoodyak.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ # Define the Digest class in the Xoodyak namespace inheriting from Digest::Base,
6
+ # so the C extension can successfully find it and attach the digest metadata.
7
+ class Xoodyak
8
+ class Digest < ::Digest::Base
9
+ end
10
+ end
11
+
12
+ require_relative "xoodyak/version"
13
+
14
+ begin
15
+ RUBY_VERSION =~ /(\d+\.\d+)/
16
+ require "xoodyak/#{Regexp.last_match(1)}/xoodyak"
17
+ rescue LoadError
18
+ begin
19
+ require_relative "xoodyak/xoodyak"
20
+ rescue LoadError
21
+ require "xoodyak/xoodyak"
22
+ end
23
+ end
24
+
25
+ module Digest
26
+ Xoodyak = ::Xoodyak::Digest
27
+ end
data/sig/xoodyak.rbs ADDED
@@ -0,0 +1,34 @@
1
+ class Xoodyak
2
+ VERSION: String
3
+
4
+ module Error
5
+ end
6
+
7
+ class KeyedModeError < ::StandardError
8
+ include Error
9
+ end
10
+
11
+ class VerificationError < ::StandardError
12
+ include Error
13
+ end
14
+
15
+ class ArgumentError < ::ArgumentError
16
+ include Error
17
+ end
18
+
19
+ class Digest < ::Digest::Base
20
+ end
21
+
22
+ def initialize: (?String? key, ?String? nonce, ?String? key_id, ?String? counter) -> void
23
+ def initialize_copy: (Xoodyak other) -> void
24
+ def absorb: (String bin) -> void
25
+ def squeeze: (Integer len) -> String
26
+ def squeeze_key: (Integer len) -> String
27
+ def encrypt: (String bin) -> String
28
+ def decrypt: (String bin) -> String
29
+ def aead_encrypt: (String bin) -> String
30
+ def aead_decrypt: (String bin) -> String
31
+ def aead_encrypt_detached: (String bin) -> [String, String]
32
+ def aead_decrypt_detached: (String bin, String tag) -> String
33
+ def ratchet: () -> void
34
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: xoodyak
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sarun Rattanasiri
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.9.91
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.9.91
26
+ description: A Ruby wrapper for the Xoodyak cryptographic scheme, built in Rust using
27
+ magnus and rb-sys. It supports hashing (unkeyed mode), symmetric encryption and
28
+ AEAD (keyed mode), forward secrecy (state ratcheting), and integrates with the standard
29
+ Ruby Digest API.
30
+ email: midnight_w@gmx.tw
31
+ executables: []
32
+ extensions:
33
+ - ext/xoodyak/extconf.rb
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE.md
37
+ - README.md
38
+ - ext/xoodyak/Cargo.toml
39
+ - ext/xoodyak/build.rs
40
+ - ext/xoodyak/extconf.rb
41
+ - ext/xoodyak/src/lib.rs
42
+ - lib/digest/xoodyak.rb
43
+ - lib/xoodyak.rb
44
+ - lib/xoodyak/version.rb
45
+ - sig/xoodyak.rbs
46
+ homepage: https://github.com/midnight-wonderer/xoodyak-rb
47
+ licenses:
48
+ - BSD-2-Clause
49
+ metadata:
50
+ source_code_uri: https://github.com/midnight-wonderer/xoodyak-rb
51
+ bug_tracker_uri: https://github.com/midnight-wonderer/xoodyak-rb/issues
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 3.2.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 4.0.10
67
+ specification_version: 4
68
+ summary: A fast, memory-safe Rust-backed Ruby implementation of the Xoodyak cryptographic
69
+ scheme
70
+ test_files: []