castrum 0.9.2 → 0.9.4
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 +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// rust/crypto/bcrypt.rs — bcrypt password hashing.
|
|
2
|
+
//
|
|
3
|
+
// Pure-Rust (bcrypt crate, Blowfish-based). Unlike argon2id (memory-hard), a
|
|
4
|
+
// bcrypt cost of `2^cost` is CPU-bound. We expose the standard `$2b$` PHC
|
|
5
|
+
// string format so the output is interchangeable with Bun.password / bcryptjs.
|
|
6
|
+
//
|
|
7
|
+
// Pure-Rust core stays unit-testable; only the entry points use napi types.
|
|
8
|
+
|
|
9
|
+
use napi::bindgen_prelude::*;
|
|
10
|
+
use napi_derive::napi;
|
|
11
|
+
|
|
12
|
+
/// bcrypt cost lower bound (bcrypt crate enforces 4..=31).
|
|
13
|
+
pub const BCRYPT_MIN_COST: u32 = 4;
|
|
14
|
+
/// bcrypt cost upper bound.
|
|
15
|
+
pub const BCRYPT_MAX_COST: u32 = 31;
|
|
16
|
+
|
|
17
|
+
/// Clamp a caller cost into the bcrypt-legal range (4..=31).
|
|
18
|
+
#[inline]
|
|
19
|
+
pub fn clamp_cost(cost: u32) -> u32 {
|
|
20
|
+
cost.clamp(BCRYPT_MIN_COST, BCRYPT_MAX_COST)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Hash a password with bcrypt at `cost`, returning a `$2b$` PHC string.
|
|
24
|
+
#[napi]
|
|
25
|
+
pub fn password_hash_bcrypt(password: Uint8Array, cost: u32) -> Result<String> {
|
|
26
|
+
let cost = clamp_cost(cost);
|
|
27
|
+
bcrypt::hash(password.as_ref(), cost).map_err(|e| Error::from_reason(e.to_string()))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Verify a password against a bcrypt `$2b$` PHC string.
|
|
31
|
+
/// Returns false on ANY malformed hash (parity with argon2 `password_verify`).
|
|
32
|
+
#[napi]
|
|
33
|
+
pub fn password_verify_bcrypt(password: Uint8Array, hash: String) -> bool {
|
|
34
|
+
bcrypt::verify(password.as_ref(), &hash).unwrap_or(false)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[cfg(test)]
|
|
38
|
+
mod tests {
|
|
39
|
+
use super::*;
|
|
40
|
+
|
|
41
|
+
#[test]
|
|
42
|
+
fn clamp_cost_bounds() {
|
|
43
|
+
assert_eq!(clamp_cost(0), BCRYPT_MIN_COST);
|
|
44
|
+
assert_eq!(clamp_cost(3), BCRYPT_MIN_COST);
|
|
45
|
+
assert_eq!(clamp_cost(4), 4);
|
|
46
|
+
assert_eq!(clamp_cost(10), 10);
|
|
47
|
+
assert_eq!(clamp_cost(31), 31);
|
|
48
|
+
assert_eq!(clamp_cost(99), BCRYPT_MAX_COST);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[test]
|
|
52
|
+
fn bcrypt_roundtrip_and_wrong_password() {
|
|
53
|
+
let h = bcrypt::hash(b"password".as_slice(), 4).unwrap();
|
|
54
|
+
assert!(h.starts_with("$2b$04$"));
|
|
55
|
+
assert_eq!(h.len(), 60); // PHC bcrypt strings are exactly 60 chars
|
|
56
|
+
assert!(bcrypt::verify(b"password".as_slice(), &h).unwrap());
|
|
57
|
+
assert!(!bcrypt::verify(b"wrong".as_slice(), &h).unwrap());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#[test]
|
|
61
|
+
fn bcrypt_is_cost_sensitive_and_self_contained() {
|
|
62
|
+
// Same password, two hashes differ (random salt) but both verify.
|
|
63
|
+
let a = bcrypt::hash(b"pw".as_slice(), 4).unwrap();
|
|
64
|
+
let b = bcrypt::hash(b"pw".as_slice(), 4).unwrap();
|
|
65
|
+
assert_ne!(a, b);
|
|
66
|
+
assert!(bcrypt::verify(b"pw".as_slice(), &a).unwrap());
|
|
67
|
+
assert!(bcrypt::verify(b"pw".as_slice(), &b).unwrap());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#[test]
|
|
71
|
+
fn napi_wrapper_clamps_and_roundtrips() {
|
|
72
|
+
// cost 3 is clamped up to 4 (cheap) — exercises the napi boundary.
|
|
73
|
+
let h = password_hash_bcrypt(Uint8Array::new(b"pw".to_vec()), 3).unwrap();
|
|
74
|
+
assert!(h.starts_with("$2b$04$"));
|
|
75
|
+
assert!(password_verify_bcrypt(
|
|
76
|
+
Uint8Array::new(b"pw".to_vec()),
|
|
77
|
+
h.clone()
|
|
78
|
+
));
|
|
79
|
+
assert!(!password_verify_bcrypt(
|
|
80
|
+
Uint8Array::new(b"nope".to_vec()),
|
|
81
|
+
h
|
|
82
|
+
));
|
|
83
|
+
// Malformed hash -> false (not a throw), matching argon2 semantics.
|
|
84
|
+
assert!(!password_verify_bcrypt(
|
|
85
|
+
Uint8Array::new(b"pw".to_vec()),
|
|
86
|
+
"garbage".to_string()
|
|
87
|
+
));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// rust/crypto/cookie_sign.rs — signed cookies (`value.signature`, HMAC-SHA256).
|
|
2
|
+
//
|
|
3
|
+
// Composes the existing HMAC core + hex tables. The `CookieSigner`
|
|
4
|
+
// higher-order instance precomputes the HMAC key once in its constructor and
|
|
5
|
+
// reuses it across sign/verify calls.
|
|
6
|
+
|
|
7
|
+
use aws_lc_rs::hmac;
|
|
8
|
+
use napi::bindgen_prelude::*;
|
|
9
|
+
use napi_derive::napi;
|
|
10
|
+
|
|
11
|
+
use crate::util::bytes::{hex_decode_32, hex_encode_32};
|
|
12
|
+
|
|
13
|
+
/// Zero-alloc core: `value` ++ "." ++ 64-hex into `out`. Returns the bytes
|
|
14
|
+
/// written, or `None` on a too-small buffer. Used by the C-ABI (`bun:ffi`)
|
|
15
|
+
/// path to avoid the signed-cookie Vec + copy.
|
|
16
|
+
pub fn sign_cookie_into(value: &[u8], key: &hmac::Key, out: &mut [u8]) -> Option<usize> {
|
|
17
|
+
let need = value.len() + 1 + 64;
|
|
18
|
+
if out.len() < need {
|
|
19
|
+
return None;
|
|
20
|
+
}
|
|
21
|
+
let tag = hmac::sign(key, value);
|
|
22
|
+
let mut sig = [0u8; 64];
|
|
23
|
+
hex_encode_32(tag.as_ref(), &mut sig);
|
|
24
|
+
out[..value.len()].copy_from_slice(value);
|
|
25
|
+
out[value.len()] = b'.';
|
|
26
|
+
out[value.len() + 1..need].copy_from_slice(&sig);
|
|
27
|
+
Some(need)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// `value` ++ "." ++ lowercase-hex(HMAC-SHA256(secret, value)).
|
|
31
|
+
pub fn sign_cookie_bytes(value: &[u8], key: &hmac::Key) -> Vec<u8> {
|
|
32
|
+
let tag = hmac::sign(key, value);
|
|
33
|
+
let mut sig = [0u8; 64];
|
|
34
|
+
hex_encode_32(tag.as_ref(), &mut sig);
|
|
35
|
+
let mut out = Vec::with_capacity(value.len() + 1 + 64);
|
|
36
|
+
out.extend_from_slice(value);
|
|
37
|
+
out.push(b'.');
|
|
38
|
+
out.extend_from_slice(&sig);
|
|
39
|
+
out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Zero-alloc core: constant-time verify into `out` (the value without its
|
|
43
|
+
/// signature). Returns the value length, or `None` on invalid signature /
|
|
44
|
+
/// too-small buffer. Used by the C-ABI (`bun:ffi`) path to avoid the Vec.
|
|
45
|
+
pub fn verify_cookie_into(signed: &[u8], key: &hmac::Key, out: &mut [u8]) -> Option<usize> {
|
|
46
|
+
let dot = signed.iter().rposition(|&b| b == b'.')?;
|
|
47
|
+
let (value, sig) = signed.split_at(dot);
|
|
48
|
+
let sig = &sig[1..];
|
|
49
|
+
if sig.len() != 64 {
|
|
50
|
+
return None;
|
|
51
|
+
}
|
|
52
|
+
let sig_bytes = hex_decode_32(sig)?;
|
|
53
|
+
if hmac::verify(key, value, &sig_bytes).is_err() {
|
|
54
|
+
return None;
|
|
55
|
+
}
|
|
56
|
+
if out.len() < value.len() {
|
|
57
|
+
return None;
|
|
58
|
+
}
|
|
59
|
+
out[..value.len()].copy_from_slice(value);
|
|
60
|
+
Some(value.len())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Constant-time verify; returns the signed value without its signature.
|
|
64
|
+
pub fn verify_cookie_bytes(signed: &[u8], key: &hmac::Key) -> Option<Vec<u8>> {
|
|
65
|
+
let dot = signed.iter().rposition(|&b| b == b'.')?;
|
|
66
|
+
let (value, sig) = signed.split_at(dot);
|
|
67
|
+
let sig = &sig[1..];
|
|
68
|
+
if sig.len() != 64 {
|
|
69
|
+
return None;
|
|
70
|
+
}
|
|
71
|
+
let sig_bytes = hex_decode_32(sig)?;
|
|
72
|
+
hmac::verify(key, value, &sig_bytes)
|
|
73
|
+
.ok()
|
|
74
|
+
.map(|()| value.to_vec())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Constant-time verify; returns whether the signature matches, WITHOUT
|
|
78
|
+
/// materializing the signed value. Same checks as [`verify_cookie_bytes`] but
|
|
79
|
+
/// skips the `value.to_vec()` — used by the packed batch to avoid a discarded
|
|
80
|
+
/// allocation per item.
|
|
81
|
+
pub fn verify_cookie_bytes_bool(signed: &[u8], key: &hmac::Key) -> bool {
|
|
82
|
+
let Some(dot) = signed.iter().rposition(|&b| b == b'.') else {
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
let (value, sig) = signed.split_at(dot);
|
|
86
|
+
let sig = &sig[1..];
|
|
87
|
+
if sig.len() != 64 {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
let Some(sig_bytes) = hex_decode_32(sig) else {
|
|
91
|
+
return false;
|
|
92
|
+
};
|
|
93
|
+
hmac::verify(key, value, &sig_bytes).is_ok()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[napi]
|
|
97
|
+
pub fn sign_cookie(value: Uint8Array, secret: Uint8Array) -> Buffer {
|
|
98
|
+
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_ref());
|
|
99
|
+
Buffer::from(sign_cookie_bytes(value.as_ref(), &key))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[napi]
|
|
103
|
+
pub fn verify_cookie(signed: Uint8Array, secret: Uint8Array) -> Option<Buffer> {
|
|
104
|
+
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_ref());
|
|
105
|
+
verify_cookie_bytes(signed.as_ref(), &key).map(Buffer::from)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Higher-order instance: HMAC key compiled once, reused across calls.
|
|
109
|
+
#[napi]
|
|
110
|
+
pub struct CookieSigner {
|
|
111
|
+
key: hmac::Key,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#[napi]
|
|
115
|
+
impl CookieSigner {
|
|
116
|
+
#[napi(constructor)]
|
|
117
|
+
pub fn new(secret: Uint8Array) -> Self {
|
|
118
|
+
Self {
|
|
119
|
+
key: hmac::Key::new(hmac::HMAC_SHA256, secret.as_ref()),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[napi]
|
|
124
|
+
pub fn sign(&self, value: Uint8Array) -> Buffer {
|
|
125
|
+
Buffer::from(sign_cookie_bytes(value.as_ref(), &self.key))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[napi]
|
|
129
|
+
pub fn verify(&self, signed: Uint8Array) -> Option<Buffer> {
|
|
130
|
+
verify_cookie_bytes(signed.as_ref(), &self.key).map(Buffer::from)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[cfg(test)]
|
|
135
|
+
mod tests {
|
|
136
|
+
use super::*;
|
|
137
|
+
|
|
138
|
+
fn key(secret: &[u8]) -> hmac::Key {
|
|
139
|
+
hmac::Key::new(hmac::HMAC_SHA256, secret)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn sign_then_verify_roundtrip() {
|
|
144
|
+
let k = key(b"super-secret");
|
|
145
|
+
let value = b"session=abc123";
|
|
146
|
+
let signed = sign_cookie_bytes(value, &k);
|
|
147
|
+
assert!(signed.starts_with(value));
|
|
148
|
+
assert_eq!(signed[value.len()], b'.');
|
|
149
|
+
assert_eq!(verify_cookie_bytes(&signed, &k).unwrap(), value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn verify_rejects_tampered() {
|
|
154
|
+
let k = key(b"super-secret");
|
|
155
|
+
let value = b"session=abc123";
|
|
156
|
+
let mut signed = sign_cookie_bytes(value, &k);
|
|
157
|
+
let last = signed.len() - 1;
|
|
158
|
+
signed[last] = if signed[last] == b'0' { b'1' } else { b'0' };
|
|
159
|
+
assert!(verify_cookie_bytes(&signed, &k).is_none());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#[test]
|
|
163
|
+
fn verify_rejects_wrong_secret_and_malformed() {
|
|
164
|
+
let signed = sign_cookie_bytes(b"x", &key(b"secret-a"));
|
|
165
|
+
assert!(verify_cookie_bytes(&signed, &key(b"secret-b")).is_none());
|
|
166
|
+
assert!(verify_cookie_bytes(b"no-dot", &key(b"s")).is_none());
|
|
167
|
+
assert!(verify_cookie_bytes(b"x.bad", &key(b"s")).is_none());
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// rust/crypto/csrf.rs — CSRF tokens (32-byte random hex + HMAC-SHA256 signature).
|
|
2
|
+
//
|
|
3
|
+
// Token format: `<random-hex>.<hex(HMAC-SHA256(secret, random-hex))>`. The
|
|
4
|
+
// `CsrfProtector` higher-order instance precomputes the HMAC key once.
|
|
5
|
+
|
|
6
|
+
use aws_lc_rs::hmac;
|
|
7
|
+
use napi::bindgen_prelude::*;
|
|
8
|
+
use napi_derive::napi;
|
|
9
|
+
|
|
10
|
+
use crate::util::bytes::{hex_decode_32, hex_encode};
|
|
11
|
+
|
|
12
|
+
fn csrf_token_with_key(key: &hmac::Key) -> Result<Buffer> {
|
|
13
|
+
let mut rnd = [0u8; 32];
|
|
14
|
+
getrandom::fill(&mut rnd).map_err(|e| Error::from_reason(e.to_string()))?;
|
|
15
|
+
let mut rnd_hex = [0u8; 64];
|
|
16
|
+
hex_encode(&rnd, &mut rnd_hex);
|
|
17
|
+
let tag = hmac::sign(key, &rnd_hex);
|
|
18
|
+
let mut sig_hex = [0u8; 64];
|
|
19
|
+
hex_encode(tag.as_ref(), &mut sig_hex);
|
|
20
|
+
let mut out = Vec::with_capacity(64 + 1 + 64);
|
|
21
|
+
out.extend_from_slice(&rnd_hex);
|
|
22
|
+
out.push(b'.');
|
|
23
|
+
out.extend_from_slice(&sig_hex);
|
|
24
|
+
Ok(Buffer::from(out))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Constant-time verify; returns true when the signature matches.
|
|
28
|
+
pub fn csrf_verify_with_key(token: &[u8], key: &hmac::Key) -> bool {
|
|
29
|
+
let Some(dot) = token.iter().position(|&b| b == b'.') else {
|
|
30
|
+
return false;
|
|
31
|
+
};
|
|
32
|
+
let (rnd_hex, sig_hex) = token.split_at(dot);
|
|
33
|
+
let sig_hex = &sig_hex[1..];
|
|
34
|
+
if rnd_hex.len() != 64 || sig_hex.len() != 64 {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
let Some(sig) = hex_decode_32(sig_hex) else {
|
|
38
|
+
return false;
|
|
39
|
+
};
|
|
40
|
+
hmac::verify(key, rnd_hex, &sig).is_ok()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[napi]
|
|
44
|
+
pub fn csrf_token(secret: Uint8Array) -> Result<Buffer> {
|
|
45
|
+
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_ref());
|
|
46
|
+
csrf_token_with_key(&key)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[napi]
|
|
50
|
+
pub fn csrf_verify(token: Uint8Array, secret: Uint8Array) -> bool {
|
|
51
|
+
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_ref());
|
|
52
|
+
csrf_verify_with_key(token.as_ref(), &key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Higher-order instance: HMAC key compiled once, reused across create/verify.
|
|
56
|
+
#[napi]
|
|
57
|
+
pub struct CsrfProtector {
|
|
58
|
+
key: hmac::Key,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[napi]
|
|
62
|
+
impl CsrfProtector {
|
|
63
|
+
#[napi(constructor)]
|
|
64
|
+
pub fn new(secret: Uint8Array) -> Self {
|
|
65
|
+
Self {
|
|
66
|
+
key: hmac::Key::new(hmac::HMAC_SHA256, secret.as_ref()),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#[napi]
|
|
71
|
+
pub fn create(&self) -> Result<Buffer> {
|
|
72
|
+
csrf_token_with_key(&self.key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[napi]
|
|
76
|
+
pub fn verify(&self, token: Uint8Array) -> bool {
|
|
77
|
+
csrf_verify_with_key(token.as_ref(), &self.key)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[cfg(test)]
|
|
82
|
+
mod tests {
|
|
83
|
+
use super::*;
|
|
84
|
+
|
|
85
|
+
fn key(secret: &[u8]) -> hmac::Key {
|
|
86
|
+
hmac::Key::new(hmac::HMAC_SHA256, secret)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[test]
|
|
90
|
+
fn token_roundtrip() {
|
|
91
|
+
let k = key(b"csrf-secret");
|
|
92
|
+
let token = csrf_token_with_key(&k).unwrap();
|
|
93
|
+
let t = token.as_ref();
|
|
94
|
+
assert_eq!(t.len(), 64 + 1 + 64);
|
|
95
|
+
assert_eq!(t[64], b'.');
|
|
96
|
+
assert!(csrf_verify_with_key(t, &k));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn rejects_wrong_secret_and_malformed() {
|
|
101
|
+
let token = csrf_token_with_key(&key(b"secret-a")).unwrap();
|
|
102
|
+
assert!(!csrf_verify_with_key(token.as_ref(), &key(b"secret-b")));
|
|
103
|
+
assert!(!csrf_verify_with_key(b"tooshort", &key(b"s")));
|
|
104
|
+
assert!(!csrf_verify_with_key(b"x.bad", &key(b"s")));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// rust/crypto/ed25519.rs — Ed25519 keypair generation + sign/verify.
|
|
2
|
+
//
|
|
3
|
+
// Backend-framework feature: EdDSA (Ed25519) signing for JWT auth. Uses
|
|
4
|
+
// aws-lc-rs (already a dependency for HMAC/argon2/etc.) — no new crates.
|
|
5
|
+
//
|
|
6
|
+
// Pure-Rust core (no napi types in signatures) stays unit-testable; only the
|
|
7
|
+
// entry points use napi types — same split as the other crypto modules.
|
|
8
|
+
//
|
|
9
|
+
// Key formats (the `.env` contract consumed by @ignex/native):
|
|
10
|
+
// - private key: PKCS#8 v1 DER (RFC 5208/8410, 48 bytes) — byte-identical
|
|
11
|
+
// to Node `crypto.generateKeyPairSync("ed25519").privateKey.export({type:
|
|
12
|
+
// "pkcs8", format: "der"})`.
|
|
13
|
+
// - public key: SPKI DER (RFC 5280/8410, 44 bytes) — byte-identical to
|
|
14
|
+
// Node `publicKey.export({type: "spki", format: "der"})`.
|
|
15
|
+
// The raw 32-byte seed/public are recovered at the core boundary so the pure
|
|
16
|
+
// core only deals in raw bytes (aws-lc-rs `Ed25519KeyPair`/`UnparsedPublicKey`
|
|
17
|
+
// consume raw Ed25519 keys).
|
|
18
|
+
|
|
19
|
+
use aws_lc_rs::rand::SystemRandom;
|
|
20
|
+
use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _, UnparsedPublicKey, ED25519};
|
|
21
|
+
use napi::bindgen_prelude::*;
|
|
22
|
+
use napi_derive::napi;
|
|
23
|
+
|
|
24
|
+
pub const ED25519_SIGNATURE_LEN: usize = 64;
|
|
25
|
+
pub const ED25519_SEED_LEN: usize = 32;
|
|
26
|
+
pub const ED25519_PUBLIC_KEY_LEN: usize = 32;
|
|
27
|
+
/// PKCS#8 v1 DER length for an Ed25519 private key (RFC 8410: 48 bytes).
|
|
28
|
+
pub const ED25519_PKCS8_V1_LEN: usize = 48;
|
|
29
|
+
/// SPKI DER length for an Ed25519 public key (RFC 8410: 44 bytes).
|
|
30
|
+
pub const ED25519_SPKI_LEN: usize = 44;
|
|
31
|
+
|
|
32
|
+
/// The fixed SPKI DER header (AlgorithmIdentifier + BIT STRING wrapper) that
|
|
33
|
+
/// precedes the raw 32-byte Ed25519 public key — matches Node/OpenSSL.
|
|
34
|
+
const SPKI_HEADER: [u8; 12] = [
|
|
35
|
+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/// Build the RFC 8410 SPKI DER encoding of a raw 32-byte Ed25519 public key.
|
|
39
|
+
pub fn spki_der(raw_public_key: &[u8]) -> Vec<u8> {
|
|
40
|
+
let mut out = Vec::with_capacity(ED25519_SPKI_LEN);
|
|
41
|
+
out.extend_from_slice(&SPKI_HEADER);
|
|
42
|
+
out.extend_from_slice(raw_public_key);
|
|
43
|
+
out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Recover the raw 32-byte public key from SPKI DER (44 bytes) — also accepts
|
|
47
|
+
/// the raw 32-byte form so callers can skip the DER round trip.
|
|
48
|
+
pub fn raw_public_key(public_der: &[u8]) -> Option<&[u8]> {
|
|
49
|
+
match public_der.len() {
|
|
50
|
+
ED25519_PUBLIC_KEY_LEN => Some(public_der),
|
|
51
|
+
ED25519_SPKI_LEN if public_der.starts_with(&SPKI_HEADER) => {
|
|
52
|
+
Some(&public_der[SPKI_HEADER.len()..])
|
|
53
|
+
}
|
|
54
|
+
_ => None,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Generate an Ed25519 keypair → `(pkcs8_v1_private_der, spki_public_der)`.
|
|
59
|
+
///
|
|
60
|
+
/// `None` only if the OS CSPRNG fails (effectively never).
|
|
61
|
+
pub fn generate_keypair() -> Option<(Vec<u8>, Vec<u8>)> {
|
|
62
|
+
let rng = SystemRandom::new();
|
|
63
|
+
let document = Ed25519KeyPair::generate_pkcs8v1(&rng).ok()?;
|
|
64
|
+
let private_der = document.as_ref().to_vec();
|
|
65
|
+
let pair = Ed25519KeyPair::from_pkcs8(&private_der).ok()?;
|
|
66
|
+
let public_der = spki_der(pair.public_key().as_ref());
|
|
67
|
+
Some((private_der, public_der))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Sign `msg` with an Ed25519 private key (PKCS#8 v1/v2 DER) → 64-byte sig.
|
|
71
|
+
///
|
|
72
|
+
/// `None` on an unparseable/invalid private key.
|
|
73
|
+
pub fn sign(private_der: &[u8], msg: &[u8]) -> Option<Vec<u8>> {
|
|
74
|
+
let pair = Ed25519KeyPair::from_pkcs8(private_der).ok()?;
|
|
75
|
+
let sig = pair.try_sign(msg).ok()?;
|
|
76
|
+
Some(sig.as_ref().to_vec())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Verify a 64-byte Ed25519 signature over `msg` with an SPKI DER (or raw
|
|
80
|
+
/// 32-byte) public key. Constant-time signature verification (aws-lc-rs).
|
|
81
|
+
pub fn verify(public_der: &[u8], msg: &[u8], sig: &[u8]) -> bool {
|
|
82
|
+
let Some(raw) = raw_public_key(public_der) else {
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
UnparsedPublicKey::new(&ED25519, raw)
|
|
86
|
+
.verify(msg, sig)
|
|
87
|
+
.is_ok()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── NAPI entry points ──────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/// An Ed25519 keypair serialized for `.env` storage — PKCS#8 v1 DER (private)
|
|
93
|
+
/// + SPKI DER (public), base64url encoded (RFC 7515 §2, no padding). The
|
|
94
|
+
/// fields are camelCased by napi-rs → `{ privateKey, publicKey }` on the JS
|
|
95
|
+
/// side.
|
|
96
|
+
#[napi(object)]
|
|
97
|
+
pub struct Ed25519Keypair {
|
|
98
|
+
/// PKCS#8 v1 DER private key, base64url.
|
|
99
|
+
pub private_key: String,
|
|
100
|
+
/// SPKI DER public key, base64url.
|
|
101
|
+
pub public_key: String,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Generate an Ed25519 keypair. Returns `{ privateKey, publicKey }` as
|
|
105
|
+
/// base64url DER strings, ready to persist in `.env`.
|
|
106
|
+
#[napi]
|
|
107
|
+
pub fn generate_ed25519_keypair() -> Result<Ed25519Keypair> {
|
|
108
|
+
let (private_key, public_key) = generate_keypair()
|
|
109
|
+
.ok_or_else(|| Error::from_reason("ed25519 keypair generation failed (CSPRNG error)"))?;
|
|
110
|
+
Ok(Ed25519Keypair {
|
|
111
|
+
private_key: String::from_utf8(crate::crypto::base64::base64url_encode_bytes(&private_key))
|
|
112
|
+
.expect("base64url output is ASCII"),
|
|
113
|
+
public_key: String::from_utf8(crate::crypto::base64::base64url_encode_bytes(&public_key))
|
|
114
|
+
.expect("base64url output is ASCII"),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Sign `msg` with an Ed25519 private key (PKCS#8 DER) → 64-byte signature.
|
|
119
|
+
#[napi]
|
|
120
|
+
pub fn ed25519_sign(msg: Uint8Array, private_key: Uint8Array) -> Result<Buffer> {
|
|
121
|
+
sign(private_key.as_ref(), msg.as_ref())
|
|
122
|
+
.map(Buffer::from)
|
|
123
|
+
.ok_or_else(|| Error::from_reason("ed25519 sign failed (invalid private key)"))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Verify a 64-byte Ed25519 signature over `msg` with an SPKI DER (or raw
|
|
127
|
+
/// 32-byte) public key.
|
|
128
|
+
#[napi]
|
|
129
|
+
pub fn ed25519_verify(msg: Uint8Array, signature: Uint8Array, public_key: Uint8Array) -> bool {
|
|
130
|
+
verify(public_key.as_ref(), msg.as_ref(), signature.as_ref())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[cfg(test)]
|
|
134
|
+
mod tests {
|
|
135
|
+
use super::*;
|
|
136
|
+
|
|
137
|
+
/// RFC 8032 §7.1 test vector 1.
|
|
138
|
+
const TEST_SECRET: [u8; 32] = [
|
|
139
|
+
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
|
|
140
|
+
0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
|
|
141
|
+
0x7f, 0x60,
|
|
142
|
+
];
|
|
143
|
+
const TEST_PUBLIC: [u8; 32] = [
|
|
144
|
+
0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07,
|
|
145
|
+
0x3a, 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07,
|
|
146
|
+
0x51, 0x1a,
|
|
147
|
+
];
|
|
148
|
+
const TEST_SIGNATURE: [u8; 64] = [
|
|
149
|
+
0xe5, 0x56, 0x43, 0x00, 0xc3, 0x60, 0xac, 0x72, 0x90, 0x86, 0xe2, 0xcc, 0x80, 0x6e, 0x82,
|
|
150
|
+
0x8a, 0x84, 0x87, 0x7f, 0x1e, 0xb8, 0xe5, 0xd9, 0x74, 0xd8, 0x73, 0xe0, 0x65, 0x22, 0x49,
|
|
151
|
+
0x01, 0x55, 0x5f, 0xb8, 0x82, 0x15, 0x90, 0xa3, 0x3b, 0xac, 0xc6, 0x1e, 0x39, 0x70, 0x1c,
|
|
152
|
+
0xf9, 0xb4, 0x6b, 0xd2, 0x5b, 0xf5, 0xf0, 0x59, 0x5b, 0xbe, 0x24, 0x65, 0x51, 0x41, 0x43,
|
|
153
|
+
0x8e, 0x7a, 0x10, 0x0b,
|
|
154
|
+
];
|
|
155
|
+
const TEST_MESSAGE: &[u8] = b"";
|
|
156
|
+
|
|
157
|
+
fn pkcs8_v1_from_seed(seed: &[u8]) -> Vec<u8> {
|
|
158
|
+
// RFC 8410 PKCS#8 v1 for Ed25519: 48 bytes (16-byte prefix + seed).
|
|
159
|
+
let mut der = Vec::with_capacity(ED25519_PKCS8_V1_LEN);
|
|
160
|
+
der.extend_from_slice(&[
|
|
161
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
|
|
162
|
+
0x04, 0x20,
|
|
163
|
+
]);
|
|
164
|
+
der.extend_from_slice(seed);
|
|
165
|
+
der
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[test]
|
|
169
|
+
fn spki_der_round_trips() {
|
|
170
|
+
let der = spki_der(&TEST_PUBLIC);
|
|
171
|
+
assert_eq!(der.len(), ED25519_SPKI_LEN);
|
|
172
|
+
assert_eq!(raw_public_key(&der), Some(&TEST_PUBLIC[..]));
|
|
173
|
+
assert_eq!(raw_public_key(&TEST_PUBLIC), Some(&TEST_PUBLIC[..]));
|
|
174
|
+
assert_eq!(raw_public_key(&der[..4]), None);
|
|
175
|
+
assert_eq!(raw_public_key(&[]), None);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#[test]
|
|
179
|
+
fn verify_rfc8032_vector_1() {
|
|
180
|
+
let spki = spki_der(&TEST_PUBLIC);
|
|
181
|
+
assert!(verify(&spki, TEST_MESSAGE, &TEST_SIGNATURE));
|
|
182
|
+
// Signature is over the empty message — a non-empty message fails.
|
|
183
|
+
assert!(!verify(&spki, b"x", &TEST_SIGNATURE));
|
|
184
|
+
// Tampered signature fails.
|
|
185
|
+
let mut bad = TEST_SIGNATURE;
|
|
186
|
+
bad[0] ^= 0x01;
|
|
187
|
+
assert!(!verify(&spki, TEST_MESSAGE, &bad));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn sign_matches_rfc8032_vector_1() {
|
|
192
|
+
let pkcs8 = pkcs8_v1_from_seed(&TEST_SECRET);
|
|
193
|
+
let sig = sign(&pkcs8, TEST_MESSAGE).expect("sign should succeed");
|
|
194
|
+
assert_eq!(sig, TEST_SIGNATURE);
|
|
195
|
+
assert_eq!(sig.len(), ED25519_SIGNATURE_LEN);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[test]
|
|
199
|
+
fn generate_sign_verify_round_trip() {
|
|
200
|
+
let (private_der, public_der) = generate_keypair().expect("keypair generation");
|
|
201
|
+
assert_eq!(private_der.len(), ED25519_PKCS8_V1_LEN);
|
|
202
|
+
assert_eq!(public_der.len(), ED25519_SPKI_LEN);
|
|
203
|
+
|
|
204
|
+
let msg = b"integration plan for ignex + ninox + rbac";
|
|
205
|
+
let sig = sign(&private_der, msg).expect("sign");
|
|
206
|
+
assert!(verify(&public_der, msg, &sig));
|
|
207
|
+
assert!(!verify(&public_der, b"tampered", &sig));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#[test]
|
|
211
|
+
fn invalid_key_rejected() {
|
|
212
|
+
assert!(sign(&[0u8; 10], b"msg").is_none());
|
|
213
|
+
assert!(!verify(&[0u8; 10], b"msg", &[0u8; 64]));
|
|
214
|
+
}
|
|
215
|
+
}
|