castrum 0.9.7 → 0.9.8
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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +501 -5
- package/dist/index.js +1648 -1325
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/index.ts +1 -0
- package/package.json +3 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +51 -32
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/native_route.rs +21 -3
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +1 -1
- package/rust/task/tests.rs +30 -0
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +103 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +14 -2
- package/src/ingress/routes/common.ts +26 -10
- package/src/ingress/routes/echo.ts +50 -7
- package/src/ingress/routes/json-write.ts +21 -8
- package/src/ingress/routes/responder.ts +4 -4
- package/src/ingress/server-node.ts +33 -14
- package/src/ingress/server.ts +3 -4
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +47 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +4 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/shared/buffer-pool.ts +44 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/packed/parsers.ts +64 -7
- package/src/task/runtime.ts +29 -28
package/rust/crypto/aead.rs
CHANGED
|
@@ -36,6 +36,18 @@ fn batch_nonce(base: &[u8], index: usize) -> [u8; NONCE_LEN] {
|
|
|
36
36
|
nonce
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/// Reject a nonce that is not exactly [`NONCE_LEN`] bytes. Guards the batch
|
|
40
|
+
/// entry points, whose `copy_from_slice` would otherwise panic.
|
|
41
|
+
fn validate_nonce(nonce: &[u8]) -> Result<()> {
|
|
42
|
+
if nonce.len() != NONCE_LEN {
|
|
43
|
+
return Err(Error::from_reason(format!(
|
|
44
|
+
"aead nonce must be {NONCE_LEN} bytes, got {}",
|
|
45
|
+
nonce.len()
|
|
46
|
+
)));
|
|
47
|
+
}
|
|
48
|
+
Ok(())
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
fn resolve_algorithm(name: Option<&str>) -> Result<&'static aead::Algorithm> {
|
|
40
52
|
match name {
|
|
41
53
|
None | Some("aes-256-gcm") => Ok(&aead::AES_256_GCM),
|
|
@@ -197,6 +209,7 @@ pub fn aead_encrypt_batch_packed(
|
|
|
197
209
|
algorithm: Option<String>,
|
|
198
210
|
) -> Result<Buffer> {
|
|
199
211
|
let alg = resolve_algorithm(algorithm.as_deref())?;
|
|
212
|
+
validate_nonce(nonce.as_ref())?;
|
|
200
213
|
|
|
201
214
|
let enc_for = |plaintext: &[u8], index: usize| -> Vec<u8> {
|
|
202
215
|
let n = batch_nonce(nonce.as_ref(), index);
|
|
@@ -220,6 +233,7 @@ pub fn aead_decrypt_batch_packed(
|
|
|
220
233
|
algorithm: Option<String>,
|
|
221
234
|
) -> Result<Buffer> {
|
|
222
235
|
let alg = resolve_algorithm(algorithm.as_deref())?;
|
|
236
|
+
validate_nonce(nonce.as_ref())?;
|
|
223
237
|
|
|
224
238
|
let dec_for = |ciphertext: &[u8], index: usize| -> Vec<u8> {
|
|
225
239
|
let n = batch_nonce(nonce.as_ref(), index);
|
|
@@ -317,6 +331,36 @@ mod tests {
|
|
|
317
331
|
}
|
|
318
332
|
}
|
|
319
333
|
|
|
334
|
+
fn packed_one(bytes: &[u8]) -> Uint8Array {
|
|
335
|
+
let mut data = Vec::new();
|
|
336
|
+
data.extend_from_slice(&1u32.to_le_bytes());
|
|
337
|
+
data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
|
338
|
+
data.extend_from_slice(bytes);
|
|
339
|
+
Uint8Array::new(data)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#[test]
|
|
343
|
+
fn batch_rejects_a_non_12_byte_nonce() {
|
|
344
|
+
// A wrong-length nonce used to panic inside `copy_from_slice` (and the
|
|
345
|
+
// napi sync trampoline unwinds through extern "C" → abort). It must be
|
|
346
|
+
// a normal JS error instead.
|
|
347
|
+
let key = key_bytes();
|
|
348
|
+
assert!(aead_encrypt_batch_packed(
|
|
349
|
+
packed_one(b"abc"),
|
|
350
|
+
Uint8Array::new(key.to_vec()),
|
|
351
|
+
Uint8Array::new(vec![0u8; 16]),
|
|
352
|
+
None,
|
|
353
|
+
)
|
|
354
|
+
.is_err());
|
|
355
|
+
assert!(aead_decrypt_batch_packed(
|
|
356
|
+
packed_one(b"abc"),
|
|
357
|
+
Uint8Array::new(key.to_vec()),
|
|
358
|
+
Uint8Array::new(vec![0u8; 8]),
|
|
359
|
+
None,
|
|
360
|
+
)
|
|
361
|
+
.is_err());
|
|
362
|
+
}
|
|
363
|
+
|
|
320
364
|
#[test]
|
|
321
365
|
fn batch_nonce_derived_encrypt_roundtrip() {
|
|
322
366
|
// Each item encrypted with its derived nonce decrypts correctly, and a
|
package/rust/crypto/argon2.rs
CHANGED
|
@@ -30,8 +30,16 @@ pub struct PasswordHashOptions {
|
|
|
30
30
|
pub out_len: Option<u32>,
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/// Sanity caps for caller-supplied argon2 params: a hostile or mis-set config
|
|
34
|
+
/// must not force a multi-GiB allocation or a multi-minute CPU burn. Generous
|
|
35
|
+
/// enough for any real deployment (defaults are 19 MiB / 2 / 1 / 32).
|
|
36
|
+
pub const ARGON2_MAX_M_COST: u32 = 1_048_576; // 1 GiB, in KiB
|
|
37
|
+
pub const ARGON2_MAX_T_COST: u32 = 64;
|
|
38
|
+
pub const ARGON2_MAX_P_COST: u32 = 64;
|
|
39
|
+
pub const ARGON2_MAX_OUT_LEN: u32 = 1024;
|
|
40
|
+
|
|
33
41
|
fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
|
|
34
|
-
match o {
|
|
42
|
+
let (m, t, p, out) = match o {
|
|
35
43
|
Some(o) => (
|
|
36
44
|
o.m_cost.unwrap_or(19_456),
|
|
37
45
|
o.t_cost.unwrap_or(2),
|
|
@@ -39,7 +47,13 @@ fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
|
|
|
39
47
|
o.out_len.unwrap_or(32),
|
|
40
48
|
),
|
|
41
49
|
None => (19_456, 2, 1, 32),
|
|
42
|
-
}
|
|
50
|
+
};
|
|
51
|
+
(
|
|
52
|
+
m.min(ARGON2_MAX_M_COST),
|
|
53
|
+
t.min(ARGON2_MAX_T_COST),
|
|
54
|
+
p.min(ARGON2_MAX_P_COST),
|
|
55
|
+
out.min(ARGON2_MAX_OUT_LEN),
|
|
56
|
+
)
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
// ── Pure-Rust core ─────────────────────────────────────────────
|
|
@@ -230,6 +244,22 @@ mod tests {
|
|
|
230
244
|
.is_err());
|
|
231
245
|
}
|
|
232
246
|
|
|
247
|
+
#[test]
|
|
248
|
+
fn argon2_params_are_capped() {
|
|
249
|
+
let (m, t, p, out) = resolve_opts(Some(&PasswordHashOptions {
|
|
250
|
+
m_cost: Some(u32::MAX),
|
|
251
|
+
t_cost: Some(u32::MAX),
|
|
252
|
+
p_cost: Some(u32::MAX),
|
|
253
|
+
out_len: Some(u32::MAX),
|
|
254
|
+
}));
|
|
255
|
+
assert_eq!(m, ARGON2_MAX_M_COST);
|
|
256
|
+
assert_eq!(t, ARGON2_MAX_T_COST);
|
|
257
|
+
assert_eq!(p, ARGON2_MAX_P_COST);
|
|
258
|
+
assert_eq!(out, ARGON2_MAX_OUT_LEN);
|
|
259
|
+
// Sane defaults pass through untouched.
|
|
260
|
+
assert_eq!(resolve_opts(None), (19_456, 2, 1, 32));
|
|
261
|
+
}
|
|
262
|
+
|
|
233
263
|
#[test]
|
|
234
264
|
fn verify_rejects_wrong_password() {
|
|
235
265
|
let phc = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
|
package/rust/crypto/jwt/tests.rs
CHANGED
|
@@ -76,6 +76,25 @@ fn verify_token_enforces_iat_leeway() {
|
|
|
76
76
|
assert!(verify_token(&near, SECRET, now).is_some());
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
#[test]
|
|
80
|
+
fn verify_token_rejects_non_integer_or_non_numeric_exp() {
|
|
81
|
+
let now = 1_000_000i64;
|
|
82
|
+
// A fractional exp in the PAST must reject. Pre-fix `as_i64()` returned
|
|
83
|
+
// None for a float, so the expiry check was skipped and the token verified
|
|
84
|
+
// as never-expiring.
|
|
85
|
+
let frac_past = token(&serde_json::json!({ "sub": "1", "exp": 999_999.5 }), SECRET);
|
|
86
|
+
assert!(verify_token(&frac_past, SECRET, now).is_none());
|
|
87
|
+
// Present-but-non-numeric exp is malformed → reject.
|
|
88
|
+
let string_exp = token(&serde_json::json!({ "sub": "1", "exp": "999999" }), SECRET);
|
|
89
|
+
assert!(verify_token(&string_exp, SECRET, now).is_none());
|
|
90
|
+
// A fractional exp in the FUTURE is a valid RFC 7519 NumericDate → accept.
|
|
91
|
+
let frac_future = token(
|
|
92
|
+
&serde_json::json!({ "sub": "1", "exp": 1_000_000.5 }),
|
|
93
|
+
SECRET,
|
|
94
|
+
);
|
|
95
|
+
assert!(verify_token(&frac_future, SECRET, now).is_some());
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
#[test]
|
|
80
99
|
fn verify_token_accepts_valid_claims() {
|
|
81
100
|
let claims = serde_json::json!({ "sub": "123", "role": "admin" });
|
package/rust/crypto/jwt/token.rs
CHANGED
|
@@ -113,19 +113,6 @@ pub fn build_token_with_key(
|
|
|
113
113
|
signing_input
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/// Constant-time byte comparison (no early exit on length mismatch beyond the
|
|
117
|
-
/// required guard — lengths differ → false).
|
|
118
|
-
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
|
119
|
-
if a.len() != b.len() {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
let mut diff = 0u8;
|
|
123
|
-
for (x, y) in a.iter().zip(b.iter()) {
|
|
124
|
-
diff |= x ^ y;
|
|
125
|
-
}
|
|
126
|
-
diff == 0
|
|
127
|
-
}
|
|
128
|
-
|
|
129
116
|
/// Verify an HS256 signature (constant-time). Does NOT check claims/expiry.
|
|
130
117
|
///
|
|
131
118
|
/// Test-only convenience over the fresh-key derivation: production code uses
|
|
@@ -148,10 +135,11 @@ pub fn verify_signature_with_key(token: &[u8], key: &aws_lc_rs::hmac::Key) -> bo
|
|
|
148
135
|
signing_input.push(b'.');
|
|
149
136
|
signing_input.extend_from_slice(parts.payload_b64);
|
|
150
137
|
|
|
151
|
-
let sig = hmac_sha256_with_key(key, &signing_input);
|
|
152
|
-
|
|
153
138
|
match base64url_decode_bytes(parts.sig_b64) {
|
|
154
|
-
|
|
139
|
+
// Audited constant-time comparison (aws-lc-rs), matching every other
|
|
140
|
+
// MAC path in the crate (cookie sign / CSRF / hmac_sha256) instead of a
|
|
141
|
+
// hand-rolled loop the compiler could optimize into an early exit.
|
|
142
|
+
Some(provided) => aws_lc_rs::hmac::verify(key, &signing_input, &provided).is_ok(),
|
|
155
143
|
None => false,
|
|
156
144
|
}
|
|
157
145
|
}
|
|
@@ -239,31 +227,58 @@ pub(crate) fn verify_time_claims(payload_b64: &[u8], now_seconds: i64) -> Option
|
|
|
239
227
|
let payload = base64url_decode_bytes(payload_b64)?;
|
|
240
228
|
let value: sonic_rs::Value = sonic_rs::from_slice(&payload).ok()?;
|
|
241
229
|
|
|
230
|
+
// RFC 7519 NumericDate may be non-integer, so parse as f64 — `as_i64()`
|
|
231
|
+
// returns None for a float, which previously SKIPPED the check entirely and
|
|
232
|
+
// made a token with a fractional `exp` verify as never-expiring. A claim
|
|
233
|
+
// that is present but not a finite number is malformed → reject.
|
|
234
|
+
let now = now_seconds as f64;
|
|
235
|
+
|
|
242
236
|
// `exp`: reject when now >= exp.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
237
|
+
match time_claim(&value, "exp") {
|
|
238
|
+
TimeClaim::Absent => {}
|
|
239
|
+
TimeClaim::Invalid => return None,
|
|
240
|
+
TimeClaim::Value(exp) if now >= exp => return None,
|
|
241
|
+
TimeClaim::Value(_) => {}
|
|
247
242
|
}
|
|
248
243
|
|
|
249
244
|
// `nbf` (not before): reject when the token is not yet valid.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
245
|
+
match time_claim(&value, "nbf") {
|
|
246
|
+
TimeClaim::Absent => {}
|
|
247
|
+
TimeClaim::Invalid => return None,
|
|
248
|
+
TimeClaim::Value(nbf) if now < nbf => return None,
|
|
249
|
+
TimeClaim::Value(_) => {}
|
|
254
250
|
}
|
|
255
251
|
|
|
256
252
|
// `iat` (issued at): reject tokens issued in the future beyond a small
|
|
257
253
|
// clock-skew leeway.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
254
|
+
match time_claim(&value, "iat") {
|
|
255
|
+
TimeClaim::Absent => {}
|
|
256
|
+
TimeClaim::Invalid => return None,
|
|
257
|
+
TimeClaim::Value(iat) if now < iat - CLOCK_SKEW_LEEWAY_SECS as f64 => return None,
|
|
258
|
+
TimeClaim::Value(_) => {}
|
|
262
259
|
}
|
|
263
260
|
|
|
264
261
|
Some(payload)
|
|
265
262
|
}
|
|
266
263
|
|
|
264
|
+
/// Parsed state of a JWT time claim: absent, a finite numeric value, or present
|
|
265
|
+
/// but malformed (string / null / non-finite).
|
|
266
|
+
enum TimeClaim {
|
|
267
|
+
Absent,
|
|
268
|
+
Value(f64),
|
|
269
|
+
Invalid,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn time_claim(value: &sonic_rs::Value, key: &str) -> TimeClaim {
|
|
273
|
+
match value.get(key) {
|
|
274
|
+
None => TimeClaim::Absent,
|
|
275
|
+
Some(v) => match v.as_f64() {
|
|
276
|
+
Some(n) if n.is_finite() => TimeClaim::Value(n),
|
|
277
|
+
_ => TimeClaim::Invalid,
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
267
282
|
/// Verify with a PRE-COMPILED key (no per-call key derivation). Time-claim
|
|
268
283
|
/// checks run over a compact `sonic_rs::Value` (zero per-key heap `String`);
|
|
269
284
|
/// the decoded payload bytes are returned verbatim.
|
package/rust/crypto/session.rs
CHANGED
|
@@ -41,6 +41,12 @@ pub fn seal_core(id: &[u8], data_json: &[u8], exp_secs: i64, secret: &[u8]) -> O
|
|
|
41
41
|
/// Verify a sealed token and extract the envelope fields into the packed
|
|
42
42
|
/// layout: `[u8 ok=1][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`.
|
|
43
43
|
/// Returns `None` on bad signature / malformed envelope.
|
|
44
|
+
///
|
|
45
|
+
/// The returned `exp` is **advisory**: this function does NOT compare it to the
|
|
46
|
+
/// current time, so a correctly signed but expired envelope still opens. The
|
|
47
|
+
/// caller MUST reject it (`exp != 0 && now >= exp`). (Enforcing it here was
|
|
48
|
+
/// considered and rejected: the wire contract is signature + extraction, and
|
|
49
|
+
/// the bind-time self-test seals with fixed historical timestamps.)
|
|
44
50
|
pub fn open_core(token: &[u8], secret: &[u8]) -> Option<(i64, Vec<u8>, Vec<u8>)> {
|
|
45
51
|
if secret.is_empty() {
|
|
46
52
|
return None;
|
package/rust/ffi/crypto.rs
CHANGED
|
@@ -9,7 +9,7 @@ use pbkdf2::pbkdf2_hmac;
|
|
|
9
9
|
use sha2::Sha256;
|
|
10
10
|
use std::slice;
|
|
11
11
|
|
|
12
|
-
use super::util::{aead_alg, cstring_return,
|
|
12
|
+
use super::util::{aead_alg, cstring_return, panic_guard, with_hmac_key_cached};
|
|
13
13
|
|
|
14
14
|
/// HMAC-SHA256 verify → 1/0 (constant-time, hex sig compared after
|
|
15
15
|
/// whitespace-trim — mirrors the napi scalar path).
|
|
@@ -32,8 +32,9 @@ pub unsafe extern "C" fn castrum_hmac_sha256_verify(
|
|
|
32
32
|
let Some(sig_bytes) = crate::util::bytes::hex_decode_32(sig_t) else {
|
|
33
33
|
return 0;
|
|
34
34
|
};
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
with_hmac_key_cached(slice::from_raw_parts(key, klen), |k| {
|
|
36
|
+
u8::from(hmac::verify(k, slice::from_raw_parts(data, dlen), &sig_bytes).is_ok())
|
|
37
|
+
})
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/// CSRF constant-time verify → 1/0 (token format `<64-hex>.<64-hex>`).
|
|
@@ -50,11 +51,12 @@ pub unsafe extern "C" fn castrum_csrf_verify(
|
|
|
50
51
|
if token.is_null() || secret.is_null() {
|
|
51
52
|
return 0;
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
|
|
55
|
+
u8::from(crate::crypto::csrf::csrf_verify_with_key(
|
|
56
|
+
slice::from_raw_parts(token, tlen),
|
|
57
|
+
k,
|
|
58
|
+
))
|
|
59
|
+
})
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/// Argon2id password verify → 1/0 (constant-time internally).
|
|
@@ -71,10 +73,17 @@ pub unsafe extern "C" fn castrum_password_verify(
|
|
|
71
73
|
if password.is_null() || phc.is_null() {
|
|
72
74
|
return 0;
|
|
73
75
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// The argon2 core allocates/derives — a panic must become 0, not unwind
|
|
77
|
+
// through `extern "C"`.
|
|
78
|
+
panic_guard(
|
|
79
|
+
|| {
|
|
80
|
+
u8::from(crate::crypto::argon2::verify_password(
|
|
81
|
+
slice::from_raw_parts(password, plen),
|
|
82
|
+
slice::from_raw_parts(phc, phclen),
|
|
83
|
+
))
|
|
84
|
+
},
|
|
85
|
+
0,
|
|
86
|
+
)
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
/// bcrypt password verify → 1/0 (PHC `$2b$` string).
|
|
@@ -95,10 +104,15 @@ pub unsafe extern "C" fn castrum_password_verify_bcrypt(
|
|
|
95
104
|
if password.is_null() || hash.is_null() {
|
|
96
105
|
return 0;
|
|
97
106
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
panic_guard(
|
|
108
|
+
|| {
|
|
109
|
+
let Ok(h) = std::ffi::CStr::from_ptr(hash).to_str() else {
|
|
110
|
+
return 0;
|
|
111
|
+
};
|
|
112
|
+
u8::from(bcrypt::verify(slice::from_raw_parts(password, plen), h).unwrap_or(false))
|
|
113
|
+
},
|
|
114
|
+
0,
|
|
115
|
+
)
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
/// HMAC-SHA256 hex (64 chars) into `out`.
|
|
@@ -117,8 +131,9 @@ pub unsafe extern "C" fn castrum_hmac_sha256(
|
|
|
117
131
|
if key.is_null() || data.is_null() || out.is_null() || out_cap < 64 {
|
|
118
132
|
return 0;
|
|
119
133
|
}
|
|
120
|
-
let
|
|
121
|
-
|
|
134
|
+
let tag = with_hmac_key_cached(slice::from_raw_parts(key, klen), |k| {
|
|
135
|
+
hmac::sign(k, slice::from_raw_parts(data, dlen))
|
|
136
|
+
});
|
|
122
137
|
let mut hex = [0u8; 64];
|
|
123
138
|
crate::util::bytes::hex_encode_32(tag.as_ref(), &mut hex);
|
|
124
139
|
slice::from_raw_parts_mut(out, 64).copy_from_slice(&hex);
|
|
@@ -141,9 +156,10 @@ pub unsafe extern "C" fn castrum_sign_cookie(
|
|
|
141
156
|
return std::ptr::null();
|
|
142
157
|
}
|
|
143
158
|
let v = slice::from_raw_parts(value, vlen);
|
|
144
|
-
let key = hmac_key_cached(slice::from_raw_parts(secret, slen));
|
|
145
159
|
cstring_return(vlen + 65, |out| {
|
|
146
|
-
|
|
160
|
+
with_hmac_key_cached(slice::from_raw_parts(secret, slen), |key| {
|
|
161
|
+
crate::crypto::cookie_sign::sign_cookie_into(v, key, out)
|
|
162
|
+
})
|
|
147
163
|
})
|
|
148
164
|
}
|
|
149
165
|
|
|
@@ -173,9 +189,14 @@ pub unsafe extern "C" fn castrum_sign_cookie_into(
|
|
|
173
189
|
return needed;
|
|
174
190
|
}
|
|
175
191
|
let v = slice::from_raw_parts(value, vlen);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
with_hmac_key_cached(slice::from_raw_parts(secret, slen), |key| {
|
|
193
|
+
crate::crypto::cookie_sign::sign_cookie_into(
|
|
194
|
+
v,
|
|
195
|
+
key,
|
|
196
|
+
slice::from_raw_parts_mut(out, out_cap),
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
.unwrap_or_default()
|
|
179
200
|
}
|
|
180
201
|
|
|
181
202
|
/// Verify a signed cookie → the value without its signature, returned as a
|
|
@@ -195,9 +216,10 @@ pub unsafe extern "C" fn castrum_verify_cookie(
|
|
|
195
216
|
return std::ptr::null();
|
|
196
217
|
}
|
|
197
218
|
let s = slice::from_raw_parts(signed, slen);
|
|
198
|
-
let key = hmac_key_cached(slice::from_raw_parts(secret, klen));
|
|
199
219
|
cstring_return(slen, |out| {
|
|
200
|
-
|
|
220
|
+
with_hmac_key_cached(slice::from_raw_parts(secret, klen), |key| {
|
|
221
|
+
crate::crypto::cookie_sign::verify_cookie_into(s, key, out)
|
|
222
|
+
})
|
|
201
223
|
})
|
|
202
224
|
}
|
|
203
225
|
|
|
@@ -224,7 +246,6 @@ pub unsafe extern "C" fn castrum_verify_cookie_into(
|
|
|
224
246
|
return 0;
|
|
225
247
|
}
|
|
226
248
|
let s = slice::from_raw_parts(signed, slen);
|
|
227
|
-
let key = hmac_key_cached(slice::from_raw_parts(secret, klen));
|
|
228
249
|
// The value is a prefix of `signed` (everything before the last `.`), so
|
|
229
250
|
// `slen` is always a sufficient bound — needed-size only differs when the
|
|
230
251
|
// caller passed less.
|
|
@@ -237,8 +258,14 @@ pub unsafe extern "C" fn castrum_verify_cookie_into(
|
|
|
237
258
|
}
|
|
238
259
|
return slen;
|
|
239
260
|
}
|
|
240
|
-
|
|
241
|
-
|
|
261
|
+
with_hmac_key_cached(slice::from_raw_parts(secret, klen), |key| {
|
|
262
|
+
crate::crypto::cookie_sign::verify_cookie_into(
|
|
263
|
+
s,
|
|
264
|
+
key,
|
|
265
|
+
slice::from_raw_parts_mut(out, out_cap),
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
.unwrap_or_default()
|
|
242
269
|
}
|
|
243
270
|
|
|
244
271
|
/// CSRF token (`<64-hex(rnd)>.<64-hex(sig)>`, 129 bytes) returned as a
|
|
@@ -254,7 +281,6 @@ pub unsafe extern "C" fn castrum_csrf_token(
|
|
|
254
281
|
if secret.is_null() {
|
|
255
282
|
return std::ptr::null();
|
|
256
283
|
}
|
|
257
|
-
let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
|
|
258
284
|
let mut rnd = [0u8; 32];
|
|
259
285
|
if getrandom::fill(&mut rnd).is_err() {
|
|
260
286
|
return std::ptr::null();
|
|
@@ -262,7 +288,10 @@ pub unsafe extern "C" fn castrum_csrf_token(
|
|
|
262
288
|
let mut rnd_hex = [0u8; 64];
|
|
263
289
|
crate::util::bytes::hex_encode(&rnd, &mut rnd_hex);
|
|
264
290
|
let mut sig_hex = [0u8; 64];
|
|
265
|
-
|
|
291
|
+
let signed = with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
|
|
292
|
+
hmac::sign(k, &rnd_hex)
|
|
293
|
+
});
|
|
294
|
+
crate::util::bytes::hex_encode(signed.as_ref(), &mut sig_hex);
|
|
266
295
|
cstring_return(129, |out| {
|
|
267
296
|
out[..64].copy_from_slice(&rnd_hex);
|
|
268
297
|
out[64] = b'.';
|
|
@@ -291,7 +320,6 @@ pub unsafe extern "C" fn castrum_csrf_token_into(
|
|
|
291
320
|
if out_cap < 129 {
|
|
292
321
|
return 129;
|
|
293
322
|
}
|
|
294
|
-
let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
|
|
295
323
|
let mut rnd = [0u8; 32];
|
|
296
324
|
if getrandom::fill(&mut rnd).is_err() {
|
|
297
325
|
return 0;
|
|
@@ -299,7 +327,10 @@ pub unsafe extern "C" fn castrum_csrf_token_into(
|
|
|
299
327
|
let mut rnd_hex = [0u8; 64];
|
|
300
328
|
crate::util::bytes::hex_encode(&rnd, &mut rnd_hex);
|
|
301
329
|
let mut sig_hex = [0u8; 64];
|
|
302
|
-
|
|
330
|
+
let signed = with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
|
|
331
|
+
hmac::sign(k, &rnd_hex)
|
|
332
|
+
});
|
|
333
|
+
crate::util::bytes::hex_encode(signed.as_ref(), &mut sig_hex);
|
|
303
334
|
let o = slice::from_raw_parts_mut(out, 129);
|
|
304
335
|
o[..64].copy_from_slice(&rnd_hex);
|
|
305
336
|
o[64] = b'.';
|
package/rust/ffi/hashing.rs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
use std::ptr;
|
|
8
8
|
use std::slice;
|
|
9
9
|
|
|
10
|
+
use super::util::panic_guard;
|
|
11
|
+
|
|
10
12
|
/// CRC32 over `data[0..len]`. Returns the CRC-32 checksum.
|
|
11
13
|
///
|
|
12
14
|
/// # Safety
|
|
@@ -179,19 +181,22 @@ pub unsafe extern "C" fn castrum_json_sum_ids(
|
|
|
179
181
|
if data.is_null() || out.is_null() {
|
|
180
182
|
return 0;
|
|
181
183
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
184
|
+
panic_guard(
|
|
185
|
+
|| match crate::json::json_ops::json_sum_ids_bytes(slice::from_raw_parts(data, len)) {
|
|
186
|
+
Ok(sum) if out_len >= 9 => {
|
|
187
|
+
*out = 1;
|
|
188
|
+
ptr::copy_nonoverlapping(sum.to_le_bytes().as_ptr(), out.add(1), 8);
|
|
189
|
+
9
|
|
190
|
+
}
|
|
191
|
+
Ok(_) => 9, // too-small buffer → exact required size (growExact)
|
|
192
|
+
Err(_) if out_len >= 1 => {
|
|
193
|
+
*out = 0;
|
|
194
|
+
1
|
|
195
|
+
}
|
|
196
|
+
Err(_) => 1, // too-small buffer → exact required size
|
|
197
|
+
},
|
|
198
|
+
0,
|
|
199
|
+
)
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
/// Escape JS-RegExp metacharacters (`\ . * + ? ^ $ { } | ( ) [ ]`) in
|
|
@@ -212,13 +217,18 @@ pub unsafe extern "C" fn castrum_regex_escape(
|
|
|
212
217
|
if data.is_null() || out.is_null() {
|
|
213
218
|
return 0;
|
|
214
219
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
panic_guard(
|
|
221
|
+
|| {
|
|
222
|
+
let input = slice::from_raw_parts(data, len);
|
|
223
|
+
let needed = crate::util::text::regex_escape_len(input);
|
|
224
|
+
if needed > out_cap {
|
|
225
|
+
return needed;
|
|
226
|
+
}
|
|
227
|
+
crate::util::text::regex_escape_write(input, slice::from_raw_parts_mut(out, out_cap));
|
|
228
|
+
needed
|
|
229
|
+
},
|
|
230
|
+
0,
|
|
231
|
+
)
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
/// String-input / string-return sibling of [`castrum_regex_escape`]: the
|
|
@@ -269,17 +279,22 @@ pub unsafe extern "C" fn castrum_hex_validate_batch_str(
|
|
|
269
279
|
if ids.is_null() || out.is_null() {
|
|
270
280
|
return 0;
|
|
271
281
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
panic_guard(
|
|
283
|
+
|| {
|
|
284
|
+
let input = std::ffi::CStr::from_ptr(ids).to_bytes();
|
|
285
|
+
let needed = crate::util::validation::hex_batch_count(input);
|
|
286
|
+
if needed > out_cap {
|
|
287
|
+
return needed;
|
|
288
|
+
}
|
|
289
|
+
let mut buf = Vec::with_capacity(needed);
|
|
290
|
+
match crate::util::validation::hex_batch_valid_into(input, width as usize, &mut buf) {
|
|
291
|
+
Ok(()) if buf.len() <= out_cap => {
|
|
292
|
+
slice::from_raw_parts_mut(out, buf.len()).copy_from_slice(&buf);
|
|
293
|
+
buf.len()
|
|
294
|
+
}
|
|
295
|
+
_ => 0,
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
0,
|
|
299
|
+
)
|
|
285
300
|
}
|