confium 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +126 -0
- data/Cargo.lock +2534 -0
- data/Cargo.toml +9 -0
- data/README.adoc +114 -14
- data/Rakefile +8 -3
- data/confium.gemspec +42 -21
- data/ext/confium_native/Cargo.toml +62 -0
- data/ext/confium_native/build.rs +72 -0
- data/ext/confium_native/extconf.rb +10 -0
- data/ext/confium_native/src/attributes.rs +88 -0
- data/ext/confium_native/src/audit.rs +169 -0
- data/ext/confium_native/src/composite.rs +302 -0
- data/ext/confium_native/src/deployment.rs +176 -0
- data/ext/confium_native/src/ers.rs +93 -0
- data/ext/confium_native/src/lib.rs +56 -0
- data/ext/confium_native/src/openpgp.rs +55 -0
- data/ext/confium_native/src/path.rs +118 -0
- data/ext/confium_native/src/pki.rs +431 -0
- data/ext/confium_native/src/tc.rs +420 -0
- data/ext/confium_native/src/transparency.rs +341 -0
- data/ext/confium_native/src/util.rs +196 -0
- data/lib/confium/audit.rb +125 -0
- data/lib/confium/cfm.rb +2 -4
- data/lib/confium/crypto.rb +50 -0
- data/lib/confium/digest.rb +7 -7
- data/lib/confium/errors/coerce.rb +49 -0
- data/lib/confium/errors/crypto_error.rb +13 -0
- data/lib/confium/errors/index_error.rb +13 -0
- data/lib/confium/errors/not_found_error.rb +13 -0
- data/lib/confium/errors/parse_error.rb +13 -0
- data/lib/confium/errors/policy_violation_error.rb +13 -0
- data/lib/confium/errors/threshold_error.rb +14 -0
- data/lib/confium/errors/unresolved_signer_error.rb +12 -0
- data/lib/confium/errors/validation_error.rb +15 -0
- data/lib/confium/errors/verification_error.rb +13 -0
- data/lib/confium/errors.rb +26 -0
- data/lib/confium/ffi.rb +23 -0
- data/lib/confium/lib.rb +2 -39
- data/lib/confium/openpgp.rb +34 -0
- data/lib/confium/pki/certificate_builder.rb +60 -0
- data/lib/confium/pki/cms/signed_data_builder.rb +92 -0
- data/lib/confium/pki/cms.rb +15 -0
- data/lib/confium/pki/cnml.rb +80 -0
- data/lib/confium/pki.rb +13 -0
- data/lib/confium/policy.rb +138 -0
- data/lib/confium/secure_bytes.rb +124 -0
- data/lib/confium/tc/coordinator.rb +67 -0
- data/lib/confium/tc/session.rb +49 -0
- data/lib/confium/tc/session_stub.rb +43 -0
- data/lib/confium/tc/share_file.rb +87 -0
- data/lib/confium/tc.rb +17 -0
- data/lib/confium/transparency/ots.rb +63 -0
- data/lib/confium/version.rb +1 -1
- data/lib/confium.rb +50 -20
- metadata +142 -25
- data/CODE_OF_CONDUCT.md +0 -84
- data/Gemfile +0 -10
- data/sig/confium.rbs +0 -4
|
@@ -0,0 +1,341 @@
|
|
|
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, RHash, 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
|
+
Ok(current == root)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// External-auditor inclusion verification. Accepts an explicit
|
|
228
|
+
/// leaf_hash (32 bytes) instead of using the stored one. Use when
|
|
229
|
+
/// the auditor independently computed the leaf hash from the log's
|
|
230
|
+
/// published (sequence, timestamp, artifact_hash) fields.
|
|
231
|
+
///
|
|
232
|
+
/// Ruby: `proof.verify_with_leaf(leaf_hash, root)`
|
|
233
|
+
fn verify_with_leaf(&self, leaf_bytes: Value, root_bytes: Value) -> Result<bool, Error> {
|
|
234
|
+
let leaf = bytes_from_value(leaf_bytes)?;
|
|
235
|
+
if leaf.len() != 32 {
|
|
236
|
+
return Err(Error::new(
|
|
237
|
+
exception::arg_error(),
|
|
238
|
+
format!("leaf_hash must be exactly 32 bytes, got {}", leaf.len()),
|
|
239
|
+
));
|
|
240
|
+
}
|
|
241
|
+
let root_raw = bytes_from_value(root_bytes)?;
|
|
242
|
+
if root_raw.len() != 32 {
|
|
243
|
+
return Err(Error::new(
|
|
244
|
+
exception::arg_error(),
|
|
245
|
+
format!("root must be exactly 32 bytes, got {}", root_raw.len()),
|
|
246
|
+
));
|
|
247
|
+
}
|
|
248
|
+
let mut current: Hash = [0u8; 32];
|
|
249
|
+
current.copy_from_slice(&leaf);
|
|
250
|
+
let mut root: Hash = [0u8; 32];
|
|
251
|
+
root.copy_from_slice(&root_raw);
|
|
252
|
+
for step in &self.inner.steps {
|
|
253
|
+
current = match step.side {
|
|
254
|
+
Side::Left => hash_internal(step.sibling, current),
|
|
255
|
+
Side::Right => hash_internal(current, step.sibling),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
Ok(current == root)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fn bytes_from_value(v: Value) -> Result<Vec<u8>, Error> {
|
|
263
|
+
// Accept either a binary String (preferred for byte data) or an Array
|
|
264
|
+
// of small integers (also commonly used in Ruby crypto code).
|
|
265
|
+
if let Ok(s) = RString::try_convert(v) {
|
|
266
|
+
// SAFETY: we treat the string's raw bytes as opaque cryptographic
|
|
267
|
+
// input — we never interpret them as a UTF-8 string. Encoding is
|
|
268
|
+
// irrelevant for hash input.
|
|
269
|
+
return Ok(unsafe { s.as_slice() }.to_vec());
|
|
270
|
+
}
|
|
271
|
+
let arr: Vec<i64> = Vec::<i64>::try_convert(v)?;
|
|
272
|
+
arr.into_iter()
|
|
273
|
+
.map(|i| {
|
|
274
|
+
if !(0..=255).contains(&i) {
|
|
275
|
+
Err(Error::new(
|
|
276
|
+
exception::arg_error(),
|
|
277
|
+
format!("byte out of range 0..255: {i}"),
|
|
278
|
+
))
|
|
279
|
+
} else {
|
|
280
|
+
Ok(i as u8)
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
.collect()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
fn bytes_to_rstring(_ruby: &Ruby, bytes: &[u8]) -> RString {
|
|
287
|
+
let s = RString::buf_new(0);
|
|
288
|
+
s.cat(bytes);
|
|
289
|
+
s
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fn hash_leaf(entry_hash: Hash) -> Hash {
|
|
293
|
+
use sha2::{Digest, Sha256};
|
|
294
|
+
let mut h = Sha256::new();
|
|
295
|
+
h.update([0x01]);
|
|
296
|
+
h.update(entry_hash);
|
|
297
|
+
let r = h.finalize();
|
|
298
|
+
let mut out = [0u8; 32];
|
|
299
|
+
out.copy_from_slice(&r);
|
|
300
|
+
out
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn hash_internal(left: Hash, right: Hash) -> Hash {
|
|
304
|
+
use sha2::{Digest, Sha256};
|
|
305
|
+
let mut h = Sha256::new();
|
|
306
|
+
h.update([0x02]);
|
|
307
|
+
h.update(left);
|
|
308
|
+
h.update(right);
|
|
309
|
+
let r = h.finalize();
|
|
310
|
+
let mut out = [0u8; 32];
|
|
311
|
+
out.copy_from_slice(&r);
|
|
312
|
+
out
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
pub fn init(ruby: &Ruby, parent: magnus::RModule) -> Result<(), Error> {
|
|
316
|
+
let transparency = parent.define_module("Transparency")?;
|
|
317
|
+
|
|
318
|
+
let tree_class = transparency.define_class("MerkleTree", ruby.class_object())?;
|
|
319
|
+
tree_class.define_singleton_method("new", function!(MerkleTree::new, 0))?;
|
|
320
|
+
tree_class.define_method("append", method!(MerkleTree::append, 1))?;
|
|
321
|
+
tree_class.define_method("length", method!(MerkleTree::len, 0))?;
|
|
322
|
+
tree_class.define_method("size", method!(MerkleTree::len, 0))?;
|
|
323
|
+
tree_class.define_method("empty?", method!(MerkleTree::is_empty, 0))?;
|
|
324
|
+
tree_class.define_method("root", method!(MerkleTree::root, 0))?;
|
|
325
|
+
tree_class.define_method("inclusion_proof", method!(MerkleTree::inclusion_proof, 1))?;
|
|
326
|
+
tree_class.define_method("entries", method!(MerkleTree::entries, 0))?;
|
|
327
|
+
tree_class.define_method("to_a", method!(MerkleTree::entries, 0))?;
|
|
328
|
+
tree_class.define_method("consistency_proof", method!(MerkleTree::consistency_proof, 1))?;
|
|
329
|
+
tree_class.define_method(
|
|
330
|
+
"verify_consistency",
|
|
331
|
+
method!(MerkleTree::verify_consistency, 5),
|
|
332
|
+
)?;
|
|
333
|
+
|
|
334
|
+
let proof_class = transparency.define_class("InclusionProof", ruby.class_object())?;
|
|
335
|
+
proof_class.define_method("sequence", method!(InclusionProofWrap::sequence, 0))?;
|
|
336
|
+
proof_class.define_method("steps", method!(InclusionProofWrap::steps, 0))?;
|
|
337
|
+
proof_class.define_method("verify", method!(InclusionProofWrap::verify, 1))?;
|
|
338
|
+
proof_class.define_method("verify_with_leaf", method!(InclusionProofWrap::verify_with_leaf, 2))?;
|
|
339
|
+
|
|
340
|
+
Ok(())
|
|
341
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
pub fn parse_error(msg: impl Into<String>, operation: &str, format: Option<&str>, offset: Option<usize>) -> Error {
|
|
138
|
+
let ruby = match Ruby::get() {
|
|
139
|
+
Ok(r) => r,
|
|
140
|
+
Err(_) => return Error::new(exception::runtime_error(), msg.into()),
|
|
141
|
+
};
|
|
142
|
+
let d = new_details(&ruby);
|
|
143
|
+
let _ = d.aset("operation", operation);
|
|
144
|
+
let _ = d.aset("component", "Confium");
|
|
145
|
+
if let Some(f) = format { let _ = d.aset("format", f); }
|
|
146
|
+
if let Some(o) = offset { let _ = d.aset("offset", o); }
|
|
147
|
+
confium_error(msg, "ParseError", d)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pub fn validation_error(msg: impl Into<String>, operation: &str, param: &str, expected: &str, actual: &str) -> Error {
|
|
151
|
+
let ruby = match Ruby::get() {
|
|
152
|
+
Ok(r) => r,
|
|
153
|
+
Err(_) => return Error::new(exception::runtime_error(), msg.into()),
|
|
154
|
+
};
|
|
155
|
+
let d = new_details(&ruby);
|
|
156
|
+
let _ = d.aset("operation", operation);
|
|
157
|
+
let _ = d.aset("param", param);
|
|
158
|
+
let _ = d.aset("expected", expected);
|
|
159
|
+
let _ = d.aset("actual", actual);
|
|
160
|
+
confium_error(msg, "ValidationError", d)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pub fn verification_error(msg: impl Into<String>, operation: &str, signer_index: Option<usize>, algorithm: Option<&str>) -> Error {
|
|
164
|
+
let ruby = match Ruby::get() {
|
|
165
|
+
Ok(r) => r,
|
|
166
|
+
Err(_) => return Error::new(exception::runtime_error(), msg.into()),
|
|
167
|
+
};
|
|
168
|
+
let d = new_details(&ruby);
|
|
169
|
+
let _ = d.aset("operation", operation);
|
|
170
|
+
if let Some(si) = signer_index { let _ = d.aset("signer_index", si); }
|
|
171
|
+
if let Some(alg) = algorithm { let _ = d.aset("algorithm", alg); }
|
|
172
|
+
confium_error(msg, "VerificationError", d)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
pub fn crypto_error(msg: impl Into<String>, operation: &str, primitive: &str) -> Error {
|
|
176
|
+
let ruby = match Ruby::get() {
|
|
177
|
+
Ok(r) => r,
|
|
178
|
+
Err(_) => return Error::new(exception::runtime_error(), msg.into()),
|
|
179
|
+
};
|
|
180
|
+
let d = new_details(&ruby);
|
|
181
|
+
let _ = d.aset("operation", operation);
|
|
182
|
+
let _ = d.aset("primitive", primitive);
|
|
183
|
+
confium_error(msg, "CryptoError", d)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pub fn threshold_error(msg: impl Into<String>, operation: &str, have: usize, need: usize) -> Error {
|
|
187
|
+
let ruby = match Ruby::get() {
|
|
188
|
+
Ok(r) => r,
|
|
189
|
+
Err(_) => return Error::new(exception::runtime_error(), msg.into()),
|
|
190
|
+
};
|
|
191
|
+
let d = new_details(&ruby);
|
|
192
|
+
let _ = d.aset("operation", operation);
|
|
193
|
+
let _ = d.aset("have_count", have);
|
|
194
|
+
let _ = d.aset("need_count", need);
|
|
195
|
+
confium_error(msg, "ThresholdError", d)
|
|
196
|
+
}
|
|
@@ -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(&block)
|
|
85
|
+
@records.each(&block)
|
|
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
|
@@ -5,11 +5,9 @@ module Confium
|
|
|
5
5
|
attr_reader :ptr
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
|
-
pptr = FFI::MemoryPointer.new(:pointer)
|
|
8
|
+
pptr = ::FFI::MemoryPointer.new(:pointer)
|
|
9
9
|
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'])
|
|
10
|
+
@ptr = ::FFI::AutoPointer.new(pptr.read_pointer, self.class.method(:destroy))
|
|
13
11
|
end
|
|
14
12
|
|
|
15
13
|
def self.destroy(ptr)
|