confium 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/Cargo.lock +2634 -0
  4. data/Cargo.toml +9 -0
  5. data/README.adoc +114 -14
  6. data/Rakefile +11 -6
  7. data/confium.gemspec +50 -29
  8. data/ext/confium_native/Cargo.toml +63 -0
  9. data/ext/confium_native/build.rs +72 -0
  10. data/ext/confium_native/extconf.rb +10 -0
  11. data/ext/confium_native/src/attributes.rs +88 -0
  12. data/ext/confium_native/src/audit.rs +169 -0
  13. data/ext/confium_native/src/composite.rs +302 -0
  14. data/ext/confium_native/src/deployment.rs +176 -0
  15. data/ext/confium_native/src/ers.rs +93 -0
  16. data/ext/confium_native/src/lib.rs +56 -0
  17. data/ext/confium_native/src/openpgp.rs +55 -0
  18. data/ext/confium_native/src/path.rs +118 -0
  19. data/ext/confium_native/src/pki.rs +431 -0
  20. data/ext/confium_native/src/tc.rs +420 -0
  21. data/ext/confium_native/src/transparency.rs +343 -0
  22. data/ext/confium_native/src/util.rs +201 -0
  23. data/lib/confium/audit.rb +125 -0
  24. data/lib/confium/cfm.rb +4 -5
  25. data/lib/confium/crypto.rb +50 -0
  26. data/lib/confium/digest.rb +11 -9
  27. data/lib/confium/errors/coerce.rb +47 -0
  28. data/lib/confium/errors/crypto_error.rb +15 -0
  29. data/lib/confium/errors/index_error.rb +15 -0
  30. data/lib/confium/errors/not_found_error.rb +15 -0
  31. data/lib/confium/errors/parse_error.rb +15 -0
  32. data/lib/confium/errors/policy_violation_error.rb +15 -0
  33. data/lib/confium/errors/threshold_error.rb +16 -0
  34. data/lib/confium/errors/unresolved_signer_error.rb +14 -0
  35. data/lib/confium/errors/validation_error.rb +17 -0
  36. data/lib/confium/errors/verification_error.rb +15 -0
  37. data/lib/confium/errors.rb +26 -0
  38. data/lib/confium/ffi.rb +23 -0
  39. data/lib/confium/lib.rb +18 -56
  40. data/lib/confium/openpgp.rb +34 -0
  41. data/lib/confium/pki/certificate_builder.rb +60 -0
  42. data/lib/confium/pki/cms/signed_data_builder.rb +92 -0
  43. data/lib/confium/pki/cms.rb +15 -0
  44. data/lib/confium/pki/cnml.rb +80 -0
  45. data/lib/confium/pki.rb +13 -0
  46. data/lib/confium/policy.rb +138 -0
  47. data/lib/confium/secure_bytes.rb +126 -0
  48. data/lib/confium/tc/coordinator.rb +68 -0
  49. data/lib/confium/tc/session.rb +51 -0
  50. data/lib/confium/tc/session_stub.rb +43 -0
  51. data/lib/confium/tc/share_file.rb +87 -0
  52. data/lib/confium/tc.rb +17 -0
  53. data/lib/confium/transparency/ots.rb +63 -0
  54. data/lib/confium/version.rb +1 -1
  55. data/lib/confium.rb +50 -20
  56. metadata +142 -25
  57. data/CODE_OF_CONDUCT.md +0 -84
  58. data/Gemfile +0 -10
  59. data/sig/confium.rbs +0 -4
@@ -0,0 +1,343 @@
1
+ //! Confium::Transparency — Ruby surface for `confium_transparency`.
2
+ //!
3
+ //! Exposes:
4
+ //! - `Confium::Transparency::MerkleTree` — append-only Merkle tree with
5
+ //! RFC 6962 inclusion proofs (wraps `confium_transparency::merkle::MerkleTree`).
6
+ //! - `Confium::Transparency::InclusionProof` — proof object with `#verify(root)`.
7
+
8
+ use confium_transparency::{
9
+ entry::{ArtifactType, MerkleEntry},
10
+ merkle::{Hash, InclusionProof as RustInclusionProof, MerkleTree as RustMerkleTree, Side},
11
+ };
12
+ use magnus::{
13
+ exception, function, method, prelude::*, typed_data::Obj, DataTypeFunctions, Error, IntoValue,
14
+ Module, Object, RString, Ruby, TryConvert, TypedData, Value,
15
+ };
16
+
17
+ #[derive(TypedData, DataTypeFunctions)]
18
+ #[magnus(class = "Confium::Transparency::MerkleTree", size)]
19
+ pub struct MerkleTree {
20
+ inner: std::cell::RefCell<RustMerkleTree>,
21
+ }
22
+
23
+ impl MerkleTree {
24
+ fn new() -> Self {
25
+ Self {
26
+ inner: std::cell::RefCell::new(RustMerkleTree::new()),
27
+ }
28
+ }
29
+
30
+ fn append(&self, artifact_hash: Value) -> Result<u64, Error> {
31
+ let bytes = bytes_from_value(artifact_hash)?;
32
+ if bytes.len() != 32 {
33
+ return Err(Error::new(
34
+ exception::arg_error(),
35
+ format!("artifact_hash must be exactly 32 bytes, got {}", bytes.len()),
36
+ ));
37
+ }
38
+ let mut artifact_hash = [0u8; 32];
39
+ artifact_hash.copy_from_slice(&bytes);
40
+ let entry = MerkleEntry::new(0, ArtifactType::CertificateIssuance, artifact_hash);
41
+ let seq = self.inner.borrow_mut().append(entry);
42
+ Ok(seq)
43
+ }
44
+
45
+ fn len(&self) -> usize {
46
+ self.inner.borrow().len()
47
+ }
48
+
49
+ fn is_empty(&self) -> bool {
50
+ self.inner.borrow().is_empty()
51
+ }
52
+
53
+ /// Return all inclusion proofs as an Array. Use #to_a or #entries
54
+ /// to iterate: `tree.to_a.map(&:sequence)` etc.
55
+ fn entries(&self) -> Result<magnus::RArray, Error> {
56
+ let len = self.inner.borrow().len();
57
+ let ruby = Ruby::get().map_err(|e| Error::new(exception::runtime_error(), e.to_string()))?;
58
+ let arr = ruby.ary_new_capa(len);
59
+ for i in 0..(len as u64) {
60
+ let proof = self.inclusion_proof(i)?;
61
+ arr.push(proof)?;
62
+ }
63
+ Ok(arr)
64
+ }
65
+
66
+ /// Compute a consistency proof (RFC 6962 §2.1.2).
67
+ /// Proves that the first `old_size` entries hash to the same root
68
+ /// as a tree of exactly `old_size` entries.
69
+ /// Returns an Array of 32-byte binary Strings.
70
+ fn consistency_proof(&self, old_size: usize) -> Result<magnus::RArray, Error> {
71
+ let proof = self
72
+ .inner
73
+ .borrow()
74
+ .consistency_proof(old_size)
75
+ .map_err(|e| Error::new(exception::runtime_error(), e.to_string()))?;
76
+ let ruby = Ruby::get()
77
+ .map_err(|e| Error::new(exception::runtime_error(), e.to_string()))?;
78
+ let arr = ruby.ary_new_capa(proof.len());
79
+ for h in &proof {
80
+ arr.push(bytes_to_rstring(&ruby, h))?;
81
+ }
82
+ Ok(arr)
83
+ }
84
+
85
+ /// Verify a consistency proof (RFC 6962 §2.1.2).
86
+ ///
87
+ /// Brute-force verifier: recomputes this tree's root at `old_size`
88
+ /// and its current root, compares to `old_root` and `new_root`.
89
+ ///
90
+ /// Ruby signature:
91
+ /// tree.verify_consistency(old_root, new_root, old_size, new_size, proof)
92
+ ///
93
+ /// - old_root: 32-byte binary String (root of tree at old_size)
94
+ /// - new_root: 32-byte binary String (root of current tree)
95
+ /// - old_size: Integer
96
+ /// - new_size: Integer (must equal `tree.size`)
97
+ /// - proof: Array of 32-byte binary Strings from `consistency_proof`
98
+ ///
99
+ /// Returns true if valid, raises RuntimeError otherwise.
100
+ fn verify_consistency(
101
+ &self,
102
+ old_root: Value,
103
+ new_root: Value,
104
+ old_size: usize,
105
+ new_size: usize,
106
+ proof: magnus::RArray,
107
+ ) -> Result<bool, Error> {
108
+ let old_bytes = bytes_from_value(old_root)?;
109
+ if old_bytes.len() != 32 {
110
+ return Err(Error::new(
111
+ exception::arg_error(),
112
+ format!("old_root must be 32 bytes, got {}", old_bytes.len()),
113
+ ));
114
+ }
115
+ let new_bytes = bytes_from_value(new_root)?;
116
+ if new_bytes.len() != 32 {
117
+ return Err(Error::new(
118
+ exception::arg_error(),
119
+ format!("new_root must be 32 bytes, got {}", new_bytes.len()),
120
+ ));
121
+ }
122
+ let mut old_root_hash: Hash = [0u8; 32];
123
+ old_root_hash.copy_from_slice(&old_bytes);
124
+ let mut new_root_hash: Hash = [0u8; 32];
125
+ new_root_hash.copy_from_slice(&new_bytes);
126
+
127
+ let mut proof_hashes: Vec<Hash> = Vec::with_capacity(proof.len());
128
+ for item in proof.each() {
129
+ let bytes = bytes_from_value(item?)?;
130
+ if bytes.len() != 32 {
131
+ return Err(Error::new(
132
+ exception::arg_error(),
133
+ format!("proof entries must be 32 bytes, got {}", bytes.len()),
134
+ ));
135
+ }
136
+ let mut h: Hash = [0u8; 32];
137
+ h.copy_from_slice(&bytes);
138
+ proof_hashes.push(h);
139
+ }
140
+
141
+ self.inner
142
+ .borrow()
143
+ .verify_consistency(old_root_hash, new_root_hash, old_size, new_size, &proof_hashes)
144
+ .map(|_| true)
145
+ .map_err(|e| Error::new(exception::runtime_error(), e.to_string()))
146
+ }
147
+
148
+ fn root(&self) -> Value {
149
+ let ruby = Ruby::get().expect("Ruby must be available");
150
+ let bytes = self.inner.borrow().root();
151
+ bytes_to_rstring(&ruby, &bytes).as_value()
152
+ }
153
+
154
+ fn inclusion_proof(&self, seq: u64) -> Result<Obj<InclusionProofWrap>, Error> {
155
+ let tree = self.inner.borrow();
156
+ let proof = tree
157
+ .inclusion_proof(seq)
158
+ .map_err(|e| Error::new(exception::runtime_error(), e.to_string()))?;
159
+ let entry = tree
160
+ .entry(seq)
161
+ .map_err(|e| Error::new(exception::runtime_error(), e.to_string()))?;
162
+ // Re-derive the leaf hash with the same algorithm the tree uses
163
+ // internally (H(0x01 | entry_hash)).
164
+ let leaf_hash = hash_leaf(entry.entry_hash());
165
+ let ruby = Ruby::get().map_err(|e| Error::new(exception::runtime_error(), e.to_string()))?;
166
+ Ok(ruby.obj_wrap(InclusionProofWrap {
167
+ inner: proof,
168
+ leaf_hash,
169
+ }))
170
+ }
171
+ }
172
+
173
+ #[derive(TypedData, DataTypeFunctions)]
174
+ #[magnus(class = "Confium::Transparency::InclusionProof", size)]
175
+ pub struct InclusionProofWrap {
176
+ pub inner: RustInclusionProof,
177
+ /// The leaf hash (entry_hash run through the leaf-domain hash) at the
178
+ /// time the proof was generated. Stored alongside the proof so that
179
+ /// `#verify(root)` doesn't need to recompute it (which would require
180
+ /// the original entry, including its timestamp).
181
+ pub leaf_hash: Hash,
182
+ }
183
+
184
+ impl InclusionProofWrap {
185
+ fn sequence(&self) -> u64 {
186
+ self.inner.sequence
187
+ }
188
+
189
+ fn steps(&self) -> Result<Value, Error> {
190
+ let ruby = Ruby::get().map_err(|e| Error::new(exception::runtime_error(), e.to_string()))?;
191
+ let result = ruby.hash_new();
192
+ for (i, step) in self.inner.steps.iter().enumerate() {
193
+ let step_hash = ruby.hash_new();
194
+ step_hash.aset("sibling", bytes_to_rstring(&ruby, &step.sibling))?;
195
+ step_hash.aset(
196
+ "side",
197
+ match step.side {
198
+ Side::Left => "left",
199
+ Side::Right => "right",
200
+ },
201
+ )?;
202
+ result.aset(i, step_hash)?;
203
+ }
204
+ Ok(result.into_value_with(&ruby))
205
+ }
206
+
207
+ fn verify(&self, root_bytes: Value) -> Result<bool, Error> {
208
+ let bytes = bytes_from_value(root_bytes)?;
209
+ if bytes.len() != 32 {
210
+ return Err(Error::new(
211
+ exception::arg_error(),
212
+ format!("root must be exactly 32 bytes, got {}", bytes.len()),
213
+ ));
214
+ }
215
+ let mut root: Hash = [0u8; 32];
216
+ root.copy_from_slice(&bytes);
217
+ let mut current = self.leaf_hash;
218
+ for step in &self.inner.steps {
219
+ current = match step.side {
220
+ Side::Left => hash_internal(step.sibling, current),
221
+ Side::Right => hash_internal(current, step.sibling),
222
+ };
223
+ }
224
+ use subtle::ConstantTimeEq;
225
+ Ok(current.ct_eq(&root).into())
226
+ }
227
+
228
+ /// External-auditor inclusion verification. Accepts an explicit
229
+ /// leaf_hash (32 bytes) instead of using the stored one. Use when
230
+ /// the auditor independently computed the leaf hash from the log's
231
+ /// published (sequence, timestamp, artifact_hash) fields.
232
+ ///
233
+ /// Ruby: `proof.verify_with_leaf(leaf_hash, root)`
234
+ fn verify_with_leaf(&self, leaf_bytes: Value, root_bytes: Value) -> Result<bool, Error> {
235
+ let leaf = bytes_from_value(leaf_bytes)?;
236
+ if leaf.len() != 32 {
237
+ return Err(Error::new(
238
+ exception::arg_error(),
239
+ format!("leaf_hash must be exactly 32 bytes, got {}", leaf.len()),
240
+ ));
241
+ }
242
+ let root_raw = bytes_from_value(root_bytes)?;
243
+ if root_raw.len() != 32 {
244
+ return Err(Error::new(
245
+ exception::arg_error(),
246
+ format!("root must be exactly 32 bytes, got {}", root_raw.len()),
247
+ ));
248
+ }
249
+ let mut current: Hash = [0u8; 32];
250
+ current.copy_from_slice(&leaf);
251
+ let mut root: Hash = [0u8; 32];
252
+ root.copy_from_slice(&root_raw);
253
+ for step in &self.inner.steps {
254
+ current = match step.side {
255
+ Side::Left => hash_internal(step.sibling, current),
256
+ Side::Right => hash_internal(current, step.sibling),
257
+ };
258
+ }
259
+ use subtle::ConstantTimeEq;
260
+ Ok(current.ct_eq(&root).into())
261
+ }
262
+ }
263
+
264
+ fn bytes_from_value(v: Value) -> Result<Vec<u8>, Error> {
265
+ // Accept either a binary String (preferred for byte data) or an Array
266
+ // of small integers (also commonly used in Ruby crypto code).
267
+ if let Ok(s) = RString::try_convert(v) {
268
+ // SAFETY: we treat the string's raw bytes as opaque cryptographic
269
+ // input — we never interpret them as a UTF-8 string. Encoding is
270
+ // irrelevant for hash input.
271
+ return Ok(unsafe { s.as_slice() }.to_vec());
272
+ }
273
+ let arr: Vec<i64> = Vec::<i64>::try_convert(v)?;
274
+ arr.into_iter()
275
+ .map(|i| {
276
+ if !(0..=255).contains(&i) {
277
+ Err(Error::new(
278
+ exception::arg_error(),
279
+ format!("byte out of range 0..255: {i}"),
280
+ ))
281
+ } else {
282
+ Ok(i as u8)
283
+ }
284
+ })
285
+ .collect()
286
+ }
287
+
288
+ fn bytes_to_rstring(_ruby: &Ruby, bytes: &[u8]) -> RString {
289
+ let s = RString::buf_new(0);
290
+ s.cat(bytes);
291
+ s
292
+ }
293
+
294
+ fn hash_leaf(entry_hash: Hash) -> Hash {
295
+ use sha2::{Digest, Sha256};
296
+ let mut h = Sha256::new();
297
+ h.update([0x01]);
298
+ h.update(entry_hash);
299
+ let r = h.finalize();
300
+ let mut out = [0u8; 32];
301
+ out.copy_from_slice(&r);
302
+ out
303
+ }
304
+
305
+ fn hash_internal(left: Hash, right: Hash) -> Hash {
306
+ use sha2::{Digest, Sha256};
307
+ let mut h = Sha256::new();
308
+ h.update([0x02]);
309
+ h.update(left);
310
+ h.update(right);
311
+ let r = h.finalize();
312
+ let mut out = [0u8; 32];
313
+ out.copy_from_slice(&r);
314
+ out
315
+ }
316
+
317
+ pub fn init(ruby: &Ruby, parent: magnus::RModule) -> Result<(), Error> {
318
+ let transparency = parent.define_module("Transparency")?;
319
+
320
+ let tree_class = transparency.define_class("MerkleTree", ruby.class_object())?;
321
+ tree_class.define_singleton_method("new", function!(MerkleTree::new, 0))?;
322
+ tree_class.define_method("append", method!(MerkleTree::append, 1))?;
323
+ tree_class.define_method("length", method!(MerkleTree::len, 0))?;
324
+ tree_class.define_method("size", method!(MerkleTree::len, 0))?;
325
+ tree_class.define_method("empty?", method!(MerkleTree::is_empty, 0))?;
326
+ tree_class.define_method("root", method!(MerkleTree::root, 0))?;
327
+ tree_class.define_method("inclusion_proof", method!(MerkleTree::inclusion_proof, 1))?;
328
+ tree_class.define_method("entries", method!(MerkleTree::entries, 0))?;
329
+ tree_class.define_method("to_a", method!(MerkleTree::entries, 0))?;
330
+ tree_class.define_method("consistency_proof", method!(MerkleTree::consistency_proof, 1))?;
331
+ tree_class.define_method(
332
+ "verify_consistency",
333
+ method!(MerkleTree::verify_consistency, 5),
334
+ )?;
335
+
336
+ let proof_class = transparency.define_class("InclusionProof", ruby.class_object())?;
337
+ proof_class.define_method("sequence", method!(InclusionProofWrap::sequence, 0))?;
338
+ proof_class.define_method("steps", method!(InclusionProofWrap::steps, 0))?;
339
+ proof_class.define_method("verify", method!(InclusionProofWrap::verify, 1))?;
340
+ proof_class.define_method("verify_with_leaf", method!(InclusionProofWrap::verify_with_leaf, 2))?;
341
+
342
+ Ok(())
343
+ }
@@ -0,0 +1,201 @@
1
+ //! Shared utilities for the confium-ruby native extension.
2
+ //!
3
+ //! DRY consolidation: a single `bytes_from_value` + size cap + string
4
+ //! conversion + typed-error helper shared by every subsystem module
5
+ //! (composite, pki, tc, transparency, deployment, attributes).
6
+
7
+ use magnus::prelude::*;
8
+ use magnus::{exception, Error, RHash, RString, Ruby, TryConvert, Value};
9
+
10
+ /// Maximum byte length for any input we accept from Ruby. Inputs larger
11
+ /// than this are rejected before they reach an allocator-capable codepath
12
+ /// (DoS guard). 1 MiB is generous for any current Confium operation
13
+ /// (cert bodies, sig material, JSON envelopes) and small enough to
14
+ /// prevent trivial memory-exhaustion attacks.
15
+ pub const MAX_INPUT_SIZE: usize = 1 << 20;
16
+
17
+ /// Convert a Ruby value to bytes. Accepts a binary `String` (any
18
+ /// encoding) or an `Array<Integer>`. Enforces a 1 MiB size cap.
19
+ pub fn bytes_from_value(v: Value) -> Result<Vec<u8>, Error> {
20
+ if let Ok(s) = RString::try_convert(v) {
21
+ // SAFETY: we treat the string's raw bytes as opaque cryptographic
22
+ // input — we never interpret them as a UTF-8 string. Encoding is
23
+ // irrelevant for hash input.
24
+ let bytes = unsafe { s.as_slice() }.to_vec();
25
+ enforce_size(bytes.len())?;
26
+ return Ok(bytes);
27
+ }
28
+ let arr: Vec<i64> = Vec::<i64>::try_convert(v)?;
29
+ enforce_size(arr.len())?;
30
+ arr.into_iter()
31
+ .map(|i| {
32
+ if !(0..=255).contains(&i) {
33
+ Err(Error::new(
34
+ exception::arg_error(),
35
+ format!("byte out of range 0..255: {i}"),
36
+ ))
37
+ } else {
38
+ Ok(i as u8)
39
+ }
40
+ })
41
+ .collect()
42
+ }
43
+
44
+ /// Reject a byte input larger than `MAX_INPUT_SIZE` with a clear
45
+ /// `ArgumentError`. Used at every byte-input boundary to prevent
46
+ /// memory-exhaustion attacks.
47
+ pub fn enforce_size(len: usize) -> Result<(), Error> {
48
+ if len > MAX_INPUT_SIZE {
49
+ return Err(Error::new(
50
+ exception::arg_error(),
51
+ format!("input size {0} exceeds max {MAX_INPUT_SIZE}", len),
52
+ ));
53
+ }
54
+ Ok(())
55
+ }
56
+
57
+ /// Build a Ruby binary `String` from a byte slice. Avoids the UTF-8
58
+ /// round-trip in `RString::buf_new` + `cat` for already-binary input.
59
+ pub fn bytes_to_rstring(_ruby: &Ruby, bytes: &[u8]) -> RString {
60
+ let s = RString::buf_new(0);
61
+ s.cat(bytes);
62
+ s
63
+ }
64
+
65
+ /// Construct a typed `Confium::*Error` instance with the given message
66
+ /// and details Hash, ready to be raised.
67
+ ///
68
+ /// `subclass` is the Ruby class name under `Confium::` (e.g. "ThresholdError").
69
+ /// Falls back to `Confium::Error` if the subclass can't be resolved, then
70
+ /// to Ruby's `RuntimeError` if even the base class is unavailable.
71
+ ///
72
+ /// ## Design
73
+ ///
74
+ /// Each typed error class accepts `(message, details_hash)` positionally,
75
+ /// where `details_hash` is a plain Ruby Hash carrying the structured
76
+ /// fields (`have_count`, `need_count`, `algorithm`, etc.). The class's
77
+ /// initializer extracts its specific keys from the Hash and assigns them
78
+ /// to ivars exposed via `attr_reader`. This pattern survives Ruby 3's
79
+ /// removal of automatic positional-Hash-to-kwargs conversion.
80
+ ///
81
+ /// ## Autoload caveat
82
+ ///
83
+ /// The Ruby side registers error subclasses via `autoload`. We resolve
84
+ /// the constant by walking the path step by step (`Confium` →
85
+ /// `ThresholdError`) rather than `Object.const_get("Confium::ThresholdError")`,
86
+ /// because the latter does not trigger autoload.
87
+ pub fn confium_error(message: impl Into<String>, subclass: &str, details: RHash) -> Error {
88
+ let ruby = match Ruby::get() {
89
+ Ok(r) => r,
90
+ Err(_) => return Error::new(exception::runtime_error(), message.into()),
91
+ };
92
+ let class = resolve_confium_subclass(&ruby, subclass);
93
+ let msg: String = message.into();
94
+ // Construct via `class.new(msg, details_hash)`. The typed subclasses
95
+ // route the Hash through their initializer, which assigns ivars
96
+ // exposed via attr_reader (have_count, need_count, etc.). If the
97
+ // call fails for any reason we fall back to bare RuntimeError with
98
+ // the original message so the binding stays usable.
99
+ match class.funcall::<_, _, magnus::Exception>("new", (msg.as_str(), details)) {
100
+ Ok(exc) => Error::from(exc),
101
+ Err(_) => Error::new(exception::runtime_error(), msg),
102
+ }
103
+ }
104
+
105
+ /// Resolve a `Confium::*Error` subclass by short name (e.g. "ThresholdError").
106
+ /// Walks the constant path segment by segment so autoload triggers
107
+ /// correctly. Falls back to `Confium::Error` if the named subclass is
108
+ /// missing, then to Ruby's `RuntimeError` if even the base class is
109
+ /// unavailable.
110
+ fn resolve_confium_subclass(ruby: &Ruby, subclass: &str) -> magnus::ExceptionClass {
111
+ let confium_mod: magnus::RModule = match ruby
112
+ .class_object()
113
+ .const_get::<_, magnus::RModule>("Confium")
114
+ {
115
+ Ok(m) => m,
116
+ Err(_) => return exception::runtime_error(),
117
+ };
118
+ confium_mod
119
+ .const_get::<_, magnus::ExceptionClass>(subclass)
120
+ .or_else(|_| confium_mod.const_get::<_, magnus::ExceptionClass>("Error"))
121
+ .unwrap_or_else(|_| exception::runtime_error())
122
+ }
123
+
124
+ /// Build an empty `RHash` for the `details:` argument to a Confium
125
+ /// error. The Hash is owned by the Ruby GC; callers add keys via
126
+ /// `aset` before passing to [`confium_error`].
127
+ pub fn new_details(ruby: &Ruby) -> RHash {
128
+ ruby.hash_new()
129
+ }
130
+
131
+ // ===== Typed error helpers (TODO 013) =====
132
+ // Each function builds a details Hash with the right shape for its
133
+ // error class, then delegates to confium_error(). Call sites pass
134
+ // only the domain-specific fields; :operation and :component are
135
+ // filled automatically.
136
+
137
+ #[allow(dead_code)]
138
+ pub fn parse_error(msg: impl Into<String>, operation: &str, format: Option<&str>, offset: Option<usize>) -> Error {
139
+ let ruby = match Ruby::get() {
140
+ Ok(r) => r,
141
+ Err(_) => return Error::new(exception::runtime_error(), msg.into()),
142
+ };
143
+ let d = new_details(&ruby);
144
+ let _ = d.aset("operation", operation);
145
+ let _ = d.aset("component", "Confium");
146
+ if let Some(f) = format { let _ = d.aset("format", f); }
147
+ if let Some(o) = offset { let _ = d.aset("offset", o); }
148
+ confium_error(msg, "ParseError", d)
149
+ }
150
+
151
+ #[allow(dead_code)]
152
+ pub fn validation_error(msg: impl Into<String>, operation: &str, param: &str, expected: &str, actual: &str) -> Error {
153
+ let ruby = match Ruby::get() {
154
+ Ok(r) => r,
155
+ Err(_) => return Error::new(exception::runtime_error(), msg.into()),
156
+ };
157
+ let d = new_details(&ruby);
158
+ let _ = d.aset("operation", operation);
159
+ let _ = d.aset("param", param);
160
+ let _ = d.aset("expected", expected);
161
+ let _ = d.aset("actual", actual);
162
+ confium_error(msg, "ValidationError", d)
163
+ }
164
+
165
+ #[allow(dead_code)]
166
+ pub fn verification_error(msg: impl Into<String>, operation: &str, signer_index: Option<usize>, algorithm: Option<&str>) -> Error {
167
+ let ruby = match Ruby::get() {
168
+ Ok(r) => r,
169
+ Err(_) => return Error::new(exception::runtime_error(), msg.into()),
170
+ };
171
+ let d = new_details(&ruby);
172
+ let _ = d.aset("operation", operation);
173
+ if let Some(si) = signer_index { let _ = d.aset("signer_index", si); }
174
+ if let Some(alg) = algorithm { let _ = d.aset("algorithm", alg); }
175
+ confium_error(msg, "VerificationError", d)
176
+ }
177
+
178
+ #[allow(dead_code)]
179
+ pub fn crypto_error(msg: impl Into<String>, operation: &str, primitive: &str) -> Error {
180
+ let ruby = match Ruby::get() {
181
+ Ok(r) => r,
182
+ Err(_) => return Error::new(exception::runtime_error(), msg.into()),
183
+ };
184
+ let d = new_details(&ruby);
185
+ let _ = d.aset("operation", operation);
186
+ let _ = d.aset("primitive", primitive);
187
+ confium_error(msg, "CryptoError", d)
188
+ }
189
+
190
+ #[allow(dead_code)]
191
+ pub fn threshold_error(msg: impl Into<String>, operation: &str, have: usize, need: usize) -> Error {
192
+ let ruby = match Ruby::get() {
193
+ Ok(r) => r,
194
+ Err(_) => return Error::new(exception::runtime_error(), msg.into()),
195
+ };
196
+ let d = new_details(&ruby);
197
+ let _ = d.aset("operation", operation);
198
+ let _ = d.aset("have_count", have);
199
+ let _ = d.aset("need_count", need);
200
+ confium_error(msg, "ThresholdError", d)
201
+ }
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Audit log sink hierarchy.
4
+ #
5
+ # Sinks receive an audit record Hash for every signed event. The Rust
6
+ # extension fires events on every signing / verification / encryption
7
+ # op; the Ruby side routes them to the configured sink via
8
+ # {Confium::Audit.sink=}.
9
+ #
10
+ # Three reference sinks are shipped: {FileSink} (append-only file),
11
+ # {MemorySink} (in-memory, for testing), {StderrSink} (one-line JSON
12
+ # per record). Custom sinks can subclass {Sink} for HTTP / syslog /
13
+ # Kafka backends.
14
+
15
+ require 'json'
16
+ require 'time'
17
+
18
+ module Confium
19
+ module Audit
20
+ # Base class for audit sinks. Subclasses override {#write} and
21
+ # optionally {#close}. The contract:
22
+ #
23
+ # - #write(record) — synchronously emit the record. Raise on
24
+ # failure; the exception propagates back through the caller.
25
+ # - #close — flush and release resources. Safe to call multiple
26
+ # times.
27
+ class Sink
28
+ # Persist an audit record Hash. The Hash has these keys
29
+ # (all Strings):
30
+ #
31
+ # - `"timestamp"` — ISO8601 UTC, e.g. `"2026-07-30T22:00:00Z"`
32
+ # - `"operation"` — short slug like `"composite_sign"`
33
+ # - `"actor"` — optional String
34
+ # - `"algorithm"` — optional String
35
+ # - `"payload_hash"` — hex SHA-256 of the signed bytes
36
+ # - `"result"` — `"success"` or `"failure"`
37
+ # - `"error"` — optional String
38
+ def write(_record)
39
+ raise NotImplementedError, "#{self.class} must implement #write"
40
+ end
41
+
42
+ # Treat the Sink as a callable — delegates to {#write}. The Rust
43
+ # extension's audit module fires events by calling `.call(record)`
44
+ # on whatever is in `Confium::Audit.sink`, so this lets both
45
+ # Proc-based and Object-based sinks work through the same
46
+ # dispatch point.
47
+ def call(record)
48
+ write(record)
49
+ end
50
+
51
+ def close
52
+ # default no-op
53
+ end
54
+ end
55
+
56
+ # In-memory sink. Records are collected in `#records` and inspected
57
+ # in specs. Implements `#clear` for resetting between tests.
58
+ #
59
+ # Includes Enumerable so callers can iterate, filter, and reduce
60
+ # over recorded events directly:
61
+ #
62
+ # sink.select { |r| r["operation"] == "composite_sign" }
63
+ # sink.count { |r| r["result"] == "failure" }
64
+ # sink.find { |r| r["actor"] == "director-1" }
65
+ class MemorySink < Sink
66
+ include Enumerable
67
+
68
+ attr_reader :records
69
+
70
+ def initialize
71
+ @records = []
72
+ end
73
+
74
+ def write(record)
75
+ @records << record
76
+ self
77
+ end
78
+
79
+ def clear
80
+ @records.clear
81
+ end
82
+
83
+ # Enumerable contract: yield each record in insertion order.
84
+ def each(&)
85
+ @records.each(&)
86
+ end
87
+ end
88
+
89
+ # Stderr sink — emits each record as a one-line JSON object.
90
+ # Suitable for development and CI; for production prefer
91
+ # {FileSink} or a structured-logging sink.
92
+ class StderrSink < Sink
93
+ def initialize(io: $stderr)
94
+ @io = io
95
+ end
96
+
97
+ def write(record)
98
+ @io.puts(JSON.generate(record))
99
+ end
100
+ end
101
+
102
+ # Append-only file sink. The path is opened in append mode; each
103
+ # record is written as a single line followed by a newline.
104
+ # Concurrent writes from multiple processes are NOT supported —
105
+ # serialize audit traffic through this sink to a single writer for
106
+ # multi-process deployments.
107
+ class FileSink < Sink
108
+ attr_reader :path
109
+
110
+ def initialize(path)
111
+ @path = path
112
+ @io = File.open(path, 'a')
113
+ @io.sync = true
114
+ end
115
+
116
+ def write(record)
117
+ @io.puts(JSON.generate(record))
118
+ end
119
+
120
+ def close
121
+ @io.close unless @io.closed?
122
+ end
123
+ end
124
+ end
125
+ end
data/lib/confium/cfm.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'ffi'
2
4
 
3
5
  module Confium
@@ -5,11 +7,9 @@ module Confium
5
7
  attr_reader :ptr
6
8
 
7
9
  def initialize
8
- pptr = FFI::MemoryPointer.new(:pointer)
10
+ pptr = ::FFI::MemoryPointer.new(:pointer)
9
11
  Confium.call_ffi(:cfm_create, pptr)
10
- @ptr = FFI::AutoPointer.new(pptr.read_pointer, self.class.method(:destroy))
11
-
12
- load_plugin('botan', ENV['CFM_HASH_BOTAN_PLUGIN_PATH'])
12
+ @ptr = ::FFI::AutoPointer.new(pptr.read_pointer, self.class.method(:destroy))
13
13
  end
14
14
 
15
15
  def self.destroy(ptr)
@@ -19,6 +19,5 @@ module Confium
19
19
  def load_plugin(name, path)
20
20
  Confium.call_ffi(:cfm_plugin_load, @ptr, name, path, nil, nil)
21
21
  end
22
-
23
22
  end
24
23
  end