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,312 @@
|
|
|
1
|
+
// rust/crypto/jwt/tests.rs — unit tests for the JWT core + napi entry points.
|
|
2
|
+
//
|
|
3
|
+
// Exercises the token core in `token.rs` and the napi boundary in `napi.rs`.
|
|
4
|
+
// The old local base64url helpers were consolidated into
|
|
5
|
+
// `crate::crypto::base64`; the tests import them under their former `b64url_*`
|
|
6
|
+
// names so the assertions read the same.
|
|
7
|
+
|
|
8
|
+
use super::*;
|
|
9
|
+
use crate::crypto::base64::{
|
|
10
|
+
base64url_decode_bytes as b64url_decode, base64url_encode_bytes as b64url_encode,
|
|
11
|
+
};
|
|
12
|
+
use napi::bindgen_prelude::*;
|
|
13
|
+
use sonic_rs::JsonValueTrait;
|
|
14
|
+
|
|
15
|
+
const SECRET: &[u8] = b"test-secret-key";
|
|
16
|
+
|
|
17
|
+
fn token(claims: &serde_json::Value, secret: &[u8]) -> Vec<u8> {
|
|
18
|
+
token_with_header(
|
|
19
|
+
&serde_json::json!({ "alg": "HS256", "typ": "JWT" }),
|
|
20
|
+
claims,
|
|
21
|
+
secret,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn token_with_header(
|
|
26
|
+
header: &serde_json::Value,
|
|
27
|
+
claims: &serde_json::Value,
|
|
28
|
+
secret: &[u8],
|
|
29
|
+
) -> Vec<u8> {
|
|
30
|
+
let header_b64 = b64url_encode(&serde_json::to_vec(header).unwrap());
|
|
31
|
+
let payload_b64 = b64url_encode(&serde_json::to_vec(claims).unwrap());
|
|
32
|
+
build_token(&header_b64, &payload_b64, secret)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn verify_token_rejects_wrong_alg() {
|
|
37
|
+
// Regression: `alg` must be allowlisted. A token with a VALID HS256
|
|
38
|
+
// signature but header `alg: "none"` (or any non-HS256) must be
|
|
39
|
+
// rejected — otherwise alg-confusion attacks become possible later.
|
|
40
|
+
let claims = serde_json::json!({ "sub": "1" });
|
|
41
|
+
let none = token_with_header(
|
|
42
|
+
&serde_json::json!({ "alg": "none", "typ": "JWT" }),
|
|
43
|
+
&claims,
|
|
44
|
+
SECRET,
|
|
45
|
+
);
|
|
46
|
+
assert!(verify_token(&none, SECRET, 1_000_000).is_none());
|
|
47
|
+
|
|
48
|
+
let rs256 = token_with_header(
|
|
49
|
+
&serde_json::json!({ "alg": "RS256", "typ": "JWT" }),
|
|
50
|
+
&claims,
|
|
51
|
+
SECRET,
|
|
52
|
+
);
|
|
53
|
+
assert!(verify_token(&rs256, SECRET, 1_000_000).is_none());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[test]
|
|
57
|
+
fn verify_token_enforces_nbf() {
|
|
58
|
+
let claims = serde_json::json!({ "sub": "1", "nbf": 2_000_000 });
|
|
59
|
+
let t = token(&claims, SECRET);
|
|
60
|
+
// Before nbf -> rejected; at/after nbf -> accepted.
|
|
61
|
+
assert!(verify_token(&t, SECRET, 1_999_999).is_none());
|
|
62
|
+
assert!(verify_token(&t, SECRET, 2_000_000).is_some());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn verify_token_enforces_iat_leeway() {
|
|
67
|
+
let now = 1_000_000;
|
|
68
|
+
// iat in the far future -> rejected (beyond 60s skew leeway).
|
|
69
|
+
let far = token(
|
|
70
|
+
&serde_json::json!({ "sub": "1", "iat": now + 10_000 }),
|
|
71
|
+
SECRET,
|
|
72
|
+
);
|
|
73
|
+
assert!(verify_token(&far, SECRET, now).is_none());
|
|
74
|
+
// iat within leeway -> accepted.
|
|
75
|
+
let near = token(&serde_json::json!({ "sub": "1", "iat": now + 30 }), SECRET);
|
|
76
|
+
assert!(verify_token(&near, SECRET, now).is_some());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[test]
|
|
80
|
+
fn verify_token_accepts_valid_claims() {
|
|
81
|
+
let claims = serde_json::json!({ "sub": "123", "role": "admin" });
|
|
82
|
+
let t = token(&claims, SECRET);
|
|
83
|
+
let payload = verify_token(&t, SECRET, 1_000_000).expect("valid token verifies");
|
|
84
|
+
let v: sonic_rs::Value = sonic_rs::from_slice(&payload).unwrap();
|
|
85
|
+
assert_eq!(v["sub"], "123");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn sign_then_verify_roundtrip() {
|
|
90
|
+
let claims = serde_json::json!({ "sub": "123", "name": "John", "role": "admin" });
|
|
91
|
+
let t = token(&claims, SECRET);
|
|
92
|
+
assert!(verify_signature(&t, SECRET));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[test]
|
|
96
|
+
fn verify_rejects_wrong_secret() {
|
|
97
|
+
let claims = serde_json::json!({ "sub": "1" });
|
|
98
|
+
let t = token(&claims, SECRET);
|
|
99
|
+
assert!(!verify_signature(&t, b"wrong-secret"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[test]
|
|
103
|
+
fn verify_rejects_tampered_payload() {
|
|
104
|
+
let claims = serde_json::json!({ "sub": "1", "role": "user" });
|
|
105
|
+
let t = token(&claims, SECRET);
|
|
106
|
+
let first_dot = memchr::memchr(b'.', &t).unwrap();
|
|
107
|
+
let second_rel = memchr::memchr(b'.', &t[first_dot + 1..]).unwrap();
|
|
108
|
+
let payload_start = first_dot + 1;
|
|
109
|
+
let payload_end = payload_start + second_rel;
|
|
110
|
+
let mid = (payload_start + payload_end) / 2;
|
|
111
|
+
|
|
112
|
+
let mut tampered = t.clone();
|
|
113
|
+
tampered[mid] ^= 0x01;
|
|
114
|
+
assert!(!verify_signature(&tampered, SECRET));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[test]
|
|
118
|
+
fn verify_rejects_malformed() {
|
|
119
|
+
assert!(!verify_signature(b"no-dots-here", SECRET));
|
|
120
|
+
assert!(!verify_signature(b"a.b.c.d", SECRET));
|
|
121
|
+
assert!(!verify_signature(b".", SECRET));
|
|
122
|
+
assert!(!verify_signature(b"", SECRET));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[test]
|
|
126
|
+
fn base64url_roundtrip() {
|
|
127
|
+
let data = b"hello world \x00\xff binary";
|
|
128
|
+
let enc = b64url_encode(data);
|
|
129
|
+
assert_eq!(b64url_decode(&enc).unwrap(), data);
|
|
130
|
+
assert!(!enc.contains(&b'='));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn base64url_uses_urlsafe_alphabet() {
|
|
135
|
+
// Bytes 0xfb 0xff encode to base64url "-_8" (no +/ or /).
|
|
136
|
+
assert_eq!(b64url_encode(b"\xfb\xff"), b"-_8");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[test]
|
|
140
|
+
fn split_handles_three_segments() {
|
|
141
|
+
let t = b"aaa.bbb.ccc";
|
|
142
|
+
let parts = split_token(t).unwrap();
|
|
143
|
+
assert_eq!(parts.header_b64, b"aaa");
|
|
144
|
+
assert_eq!(parts.payload_b64, b"bbb");
|
|
145
|
+
assert_eq!(parts.sig_b64, b"ccc");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#[test]
|
|
149
|
+
fn jwt_signer_instance_roundtrips_and_injects_claims() {
|
|
150
|
+
// Precompiled-key instance: sign+verify with no per-call key derivation.
|
|
151
|
+
let signer = JwtSigner::new(Uint8Array::new(SECRET.to_vec()), Some(3600));
|
|
152
|
+
let now = 1_000_000i64;
|
|
153
|
+
|
|
154
|
+
let claims = serde_json::json!({ "sub": "123" });
|
|
155
|
+
let token = signer.sign(claims.clone(), now).unwrap();
|
|
156
|
+
|
|
157
|
+
// ttl injected iat/exp at construction time.
|
|
158
|
+
let verified_payload = verify_token_with_key(&token, &signer.key, now).expect("token verifies");
|
|
159
|
+
let verified: sonic_rs::Value = sonic_rs::from_slice(&verified_payload).unwrap();
|
|
160
|
+
assert_eq!(verified["sub"], "123");
|
|
161
|
+
assert_eq!(verified["iat"], now);
|
|
162
|
+
assert_eq!(verified["exp"], now + 3600);
|
|
163
|
+
|
|
164
|
+
// Same instance verifies, wrong key instance rejects.
|
|
165
|
+
let other = JwtSigner::new(Uint8Array::new(b"other-secret".to_vec()), None);
|
|
166
|
+
assert!(verify_token_with_key(&token, &other.key, now).is_none());
|
|
167
|
+
|
|
168
|
+
// No ttl -> no iat/exp injected.
|
|
169
|
+
let no_ttl = JwtSigner::new(Uint8Array::new(SECRET.to_vec()), None);
|
|
170
|
+
let t2 = no_ttl.sign(claims.clone(), now).unwrap();
|
|
171
|
+
let v2_payload = verify_token_with_key(&t2, &no_ttl.key, now).unwrap();
|
|
172
|
+
let v2: sonic_rs::Value = sonic_rs::from_slice(&v2_payload).unwrap();
|
|
173
|
+
assert!(v2.get("iat").is_none());
|
|
174
|
+
assert!(v2.get("exp").is_none());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[test]
|
|
178
|
+
fn jwt_sign_bytes_matches_value_sign_and_verifies() {
|
|
179
|
+
let claims_json = br#"{"sub":"123","name":"John"}"#.to_vec();
|
|
180
|
+
let secret = Uint8Array::new(SECRET.to_vec());
|
|
181
|
+
|
|
182
|
+
// Byte-JSON sign (no napi Value marshal) matches the Value sign
|
|
183
|
+
// semantically. Key ORDER differs between the two serializers — sonic
|
|
184
|
+
// (`sort_keys` → BTreeMap) emits sorted keys, serde_json
|
|
185
|
+
// (`preserve_order` → IndexMap) emits insertion order — so compare the
|
|
186
|
+
// decoded claims, not the raw token bytes.
|
|
187
|
+
let from_bytes = jwt_sign_bytes(
|
|
188
|
+
Uint8Array::new(claims_json.clone()),
|
|
189
|
+
secret,
|
|
190
|
+
None,
|
|
191
|
+
1_000_000,
|
|
192
|
+
)
|
|
193
|
+
.unwrap();
|
|
194
|
+
let claims: serde_json::Value = serde_json::from_slice(&claims_json).unwrap();
|
|
195
|
+
let from_value = jwt_sign(claims, Uint8Array::new(SECRET.to_vec()), None, 1_000_000).unwrap();
|
|
196
|
+
let claims_from_bytes: sonic_rs::Value = sonic_rs::from_slice(
|
|
197
|
+
&b64url_decode(split_token(&from_bytes).unwrap().payload_b64).unwrap(),
|
|
198
|
+
)
|
|
199
|
+
.unwrap();
|
|
200
|
+
let claims_from_value: sonic_rs::Value = sonic_rs::from_slice(
|
|
201
|
+
&b64url_decode(split_token(&from_value).unwrap().payload_b64).unwrap(),
|
|
202
|
+
)
|
|
203
|
+
.unwrap();
|
|
204
|
+
assert_eq!(claims_from_bytes, claims_from_value);
|
|
205
|
+
|
|
206
|
+
// The bytes-signed token verifies and carries the claims.
|
|
207
|
+
let verified_payload = verify_token(from_bytes.as_ref(), SECRET, 1_000_000).unwrap();
|
|
208
|
+
let verified: sonic_rs::Value = sonic_rs::from_slice(&verified_payload).unwrap();
|
|
209
|
+
assert_eq!(verified["sub"], "123");
|
|
210
|
+
assert_eq!(verified["name"], "John");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#[test]
|
|
214
|
+
fn jwt_sign_bytes_injects_ttl_and_instance_sign_bytes_works() {
|
|
215
|
+
let claims_json = br#"{"sub":"123"}"#.to_vec();
|
|
216
|
+
let token = jwt_sign_bytes(
|
|
217
|
+
Uint8Array::new(claims_json.clone()),
|
|
218
|
+
Uint8Array::new(SECRET.to_vec()),
|
|
219
|
+
Some(3600),
|
|
220
|
+
1_000_000,
|
|
221
|
+
)
|
|
222
|
+
.unwrap();
|
|
223
|
+
let payload = verify_token(&token, SECRET, 1_000_000).unwrap();
|
|
224
|
+
let v: sonic_rs::Value = sonic_rs::from_slice(&payload).unwrap();
|
|
225
|
+
assert_eq!(v["iat"], 1_000_000);
|
|
226
|
+
assert_eq!(v["exp"], 1_000_000 + 3600);
|
|
227
|
+
|
|
228
|
+
// Precompiled-key instance: sign_bytes injects its construction ttl.
|
|
229
|
+
let signer = JwtSigner::new(Uint8Array::new(SECRET.to_vec()), Some(3600));
|
|
230
|
+
let t2 = signer
|
|
231
|
+
.sign_bytes(Uint8Array::new(claims_json), 2_000_000)
|
|
232
|
+
.unwrap();
|
|
233
|
+
let v2_payload = verify_token_with_key(&t2, &signer.key, 2_000_000).unwrap();
|
|
234
|
+
let v2: sonic_rs::Value = sonic_rs::from_slice(&v2_payload).unwrap();
|
|
235
|
+
assert_eq!(v2["iat"], 2_000_000);
|
|
236
|
+
assert_eq!(v2["exp"], 2_000_000 + 3600);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn eddsa_sign_verify_round_trip() {
|
|
241
|
+
let (private_der, public_der) = crate::crypto::ed25519::generate_keypair().unwrap();
|
|
242
|
+
let claims_json = br#"{"sub":"user-1","roles":["admin"]}"#.to_vec();
|
|
243
|
+
|
|
244
|
+
let token = sign_eddsa(&claims_json, &private_der, None, 1_000_000).unwrap();
|
|
245
|
+
// Compact token: exactly two dots, EdDSA header.
|
|
246
|
+
let parts = split_token(&token).unwrap();
|
|
247
|
+
assert_eq!(
|
|
248
|
+
b64url_decode(parts.header_b64).unwrap(),
|
|
249
|
+
br#"{"alg":"EdDSA","typ":"JWT"}"#
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Verifies with the matching public key.
|
|
253
|
+
let payload = verify_token_eddsa(&token, &public_der, 1_000_000).unwrap();
|
|
254
|
+
let v: sonic_rs::Value = sonic_rs::from_slice(&payload).unwrap();
|
|
255
|
+
assert_eq!(v["sub"], "user-1");
|
|
256
|
+
assert_eq!(v["roles"][0], "admin");
|
|
257
|
+
|
|
258
|
+
// Wrong public key (a different keypair) fails.
|
|
259
|
+
let (_, other_pub) = crate::crypto::ed25519::generate_keypair().unwrap();
|
|
260
|
+
assert!(verify_token_eddsa(&token, &other_pub, 1_000_000).is_none());
|
|
261
|
+
|
|
262
|
+
// Tampered payload fails (signature over header.payload is invalid).
|
|
263
|
+
let mut bad = token.clone();
|
|
264
|
+
let n = bad.len();
|
|
265
|
+
bad[n - 2] ^= 0x01;
|
|
266
|
+
assert!(verify_token_eddsa(&bad, &public_der, 1_000_000).is_none());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#[test]
|
|
270
|
+
fn eddsa_injects_ttl_and_enforces_time_claims() {
|
|
271
|
+
let (private_der, public_der) = crate::crypto::ed25519::generate_keypair().unwrap();
|
|
272
|
+
let claims_json = br#"{"sub":"user-1"}"#.to_vec();
|
|
273
|
+
|
|
274
|
+
let token = sign_eddsa(&claims_json, &private_der, Some(3600), 1_000_000).unwrap();
|
|
275
|
+
let v: sonic_rs::Value =
|
|
276
|
+
sonic_rs::from_slice(&verify_token_eddsa(&token, &public_der, 1_000_000).unwrap()).unwrap();
|
|
277
|
+
assert_eq!(v["iat"], 1_000_000);
|
|
278
|
+
assert_eq!(v["exp"], 1_000_000 + 3600);
|
|
279
|
+
|
|
280
|
+
// Expired token rejected.
|
|
281
|
+
assert!(verify_token_eddsa(&token, &public_der, 1_000_000 + 3600).is_none());
|
|
282
|
+
// Not-yet-valid (future iat beyond leeway) rejected.
|
|
283
|
+
let future = sign_eddsa(&claims_json, &private_der, Some(3600), 1_000_000).unwrap();
|
|
284
|
+
assert!(verify_token_eddsa(&future, &public_der, 1_000_000 - 61).is_none());
|
|
285
|
+
assert!(verify_token_eddsa(&future, &public_der, 1_000_000).is_some());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
fn eddsa_rejects_wrong_alg_and_malformed() {
|
|
290
|
+
let (private_der, public_der) = crate::crypto::ed25519::generate_keypair().unwrap();
|
|
291
|
+
let claims_json = br#"{"sub":"x"}"#.to_vec();
|
|
292
|
+
let token = sign_eddsa(&claims_json, &private_der, None, 1_000_000).unwrap();
|
|
293
|
+
|
|
294
|
+
// Swap the header alg to HS256 while keeping a valid Ed25519 signature
|
|
295
|
+
// over the new header.payload — the alg allowlist must reject it.
|
|
296
|
+
let header_b64 = b64url_encode(br#"{"alg":"HS256","typ":"JWT"}"#);
|
|
297
|
+
let parts = split_token(&token).unwrap();
|
|
298
|
+
let mut forged_input = Vec::new();
|
|
299
|
+
forged_input.extend_from_slice(&header_b64);
|
|
300
|
+
forged_input.push(b'.');
|
|
301
|
+
forged_input.extend_from_slice(parts.payload_b64);
|
|
302
|
+
let sig = crate::crypto::ed25519::sign(&private_der, &forged_input).unwrap();
|
|
303
|
+
let mut forged = forged_input;
|
|
304
|
+
forged.push(b'.');
|
|
305
|
+
forged.extend_from_slice(&b64url_encode(&sig));
|
|
306
|
+
assert!(verify_token_eddsa(&forged, &public_der, 1_000_000).is_none());
|
|
307
|
+
|
|
308
|
+
// Malformed tokens.
|
|
309
|
+
assert!(verify_token_eddsa(b"not-a-token", &public_der, 1_000_000).is_none());
|
|
310
|
+
assert!(verify_token_eddsa(b"a.b.c.d", &public_der, 1_000_000).is_none());
|
|
311
|
+
assert!(verify_token_eddsa(&token, &[], 1_000_000).is_none());
|
|
312
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// rust/crypto/jwt/token.rs — HS256/EdDSA JWT token core (assembly + verification).
|
|
2
|
+
//
|
|
3
|
+
// Backend-framework feature: JWTs for auth. We implement HS256 by hand on top
|
|
4
|
+
// of the existing aws-lc-rs HMAC + base64 crates (no `jsonwebtoken` dep) to
|
|
5
|
+
// keep the dependency tree small and match this crate's byte-machinery style.
|
|
6
|
+
// RS256 (RSA via aws-lc-rs) can be layered on later.
|
|
7
|
+
//
|
|
8
|
+
// This is the pure token machinery: header constants, base64url (shared via
|
|
9
|
+
// `crate::crypto::base64`), HMAC-SHA256, split/build/verify, zero-DOM
|
|
10
|
+
// time-claim checks, and `iat`/`exp` injection. It is reused by the napi entry
|
|
11
|
+
// points (`napi.rs`), the C-ABI fast paths in `rust/ffi.rs`, and
|
|
12
|
+
// `crate::crypto::ed25519` keypair serialization. Only the
|
|
13
|
+
// `inject_and_payload_b64*` helpers touch napi types (`napi::Result`), because
|
|
14
|
+
// `rust/ffi.rs` reuses them; everything else is napi-free and unit-testable.
|
|
15
|
+
|
|
16
|
+
use memchr::memchr;
|
|
17
|
+
use napi::{Error, Result};
|
|
18
|
+
use sonic_rs::{JsonValueMutTrait, JsonValueTrait};
|
|
19
|
+
use std::sync::OnceLock;
|
|
20
|
+
|
|
21
|
+
use crate::crypto::base64::{base64url_decode_bytes, base64url_encode_bytes};
|
|
22
|
+
|
|
23
|
+
/// Clock-skew tolerance (seconds) for the `iat` claim in `jwt_verify`.
|
|
24
|
+
const CLOCK_SKEW_LEEWAY_SECS: i64 = 60;
|
|
25
|
+
|
|
26
|
+
/// The canonical HS256 JWT header (`{"alg":"HS256","typ":"JWT"}`), serialized
|
|
27
|
+
/// and base64url-encoded ONCE (lazily). `JwtSigner::sign`, the scalar
|
|
28
|
+
/// `jwt_sign` and the batch sign all reuse it instead of rebuilding +
|
|
29
|
+
/// re-serializing the constant header on every call.
|
|
30
|
+
static JWT_HEADER_B64: OnceLock<Vec<u8>> = OnceLock::new();
|
|
31
|
+
|
|
32
|
+
pub fn jwt_header_b64() -> &'static [u8] {
|
|
33
|
+
JWT_HEADER_B64.get_or_init(|| {
|
|
34
|
+
let header = serde_json::json!({ "alg": "HS256", "typ": "JWT" });
|
|
35
|
+
base64url_encode_bytes(
|
|
36
|
+
&serde_json::to_vec(&header).expect("constant JWT header serializes"),
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// The canonical EdDSA (Ed25519) JWT header (`{"alg":"EdDSA","typ":"JWT"}`),
|
|
42
|
+
/// serialized + base64url-encoded ONCE (lazily) — twin of `jwt_header_b64`.
|
|
43
|
+
static EDDSA_HEADER_B64: OnceLock<Vec<u8>> = OnceLock::new();
|
|
44
|
+
|
|
45
|
+
pub fn eddsa_header_b64() -> &'static [u8] {
|
|
46
|
+
EDDSA_HEADER_B64.get_or_init(|| {
|
|
47
|
+
let header = serde_json::json!({ "alg": "EdDSA", "typ": "JWT" });
|
|
48
|
+
base64url_encode_bytes(
|
|
49
|
+
&serde_json::to_vec(&header).expect("constant JWT header serializes"),
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── HMAC-SHA256 (aws-lc-rs) ────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/// HMAC-SHA256 with a PRE-COMPILED key — avoids re-deriving the key on every
|
|
57
|
+
/// call (the win the `JwtSigner`/`HmacSigner` instances exploit).
|
|
58
|
+
pub fn hmac_sha256_with_key(key: &aws_lc_rs::hmac::Key, data: &[u8]) -> [u8; 32] {
|
|
59
|
+
let tag = aws_lc_rs::hmac::sign(key, data);
|
|
60
|
+
let mut out = [0u8; 32];
|
|
61
|
+
out.copy_from_slice(tag.as_ref());
|
|
62
|
+
out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Token assembly / splitting ─────────────────────────────────
|
|
66
|
+
|
|
67
|
+
pub struct TokenParts<'a> {
|
|
68
|
+
pub header_b64: &'a [u8],
|
|
69
|
+
pub payload_b64: &'a [u8],
|
|
70
|
+
pub sig_b64: &'a [u8],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Split a compact JWT into its three dot-separated segments.
|
|
74
|
+
pub fn split_token(token: &[u8]) -> Option<TokenParts<'_>> {
|
|
75
|
+
let first = memchr(b'.', token)?;
|
|
76
|
+
let header_b64 = &token[..first];
|
|
77
|
+
let rest = &token[first + 1..];
|
|
78
|
+
let second = memchr(b'.', rest)?;
|
|
79
|
+
let payload_b64 = &rest[..second];
|
|
80
|
+
let sig_b64 = &rest[second + 1..];
|
|
81
|
+
if memchr(b'.', sig_b64).is_some() {
|
|
82
|
+
return None;
|
|
83
|
+
}
|
|
84
|
+
Some(TokenParts {
|
|
85
|
+
header_b64,
|
|
86
|
+
payload_b64,
|
|
87
|
+
sig_b64,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Build a full HS256 token from base64url-encoded header + payload segments.
|
|
92
|
+
pub fn build_token(header_b64: &[u8], payload_b64: &[u8], secret: &[u8]) -> Vec<u8> {
|
|
93
|
+
let key = aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, secret);
|
|
94
|
+
build_token_with_key(header_b64, payload_b64, &key)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Build a token with a PRE-COMPILED key (per-call key derivation removed).
|
|
98
|
+
pub fn build_token_with_key(
|
|
99
|
+
header_b64: &[u8],
|
|
100
|
+
payload_b64: &[u8],
|
|
101
|
+
key: &aws_lc_rs::hmac::Key,
|
|
102
|
+
) -> Vec<u8> {
|
|
103
|
+
let mut signing_input = Vec::with_capacity(header_b64.len() + 1 + payload_b64.len());
|
|
104
|
+
signing_input.extend_from_slice(header_b64);
|
|
105
|
+
signing_input.push(b'.');
|
|
106
|
+
signing_input.extend_from_slice(payload_b64);
|
|
107
|
+
|
|
108
|
+
let sig = hmac_sha256_with_key(key, &signing_input);
|
|
109
|
+
let sig_b64 = base64url_encode_bytes(&sig);
|
|
110
|
+
|
|
111
|
+
signing_input.push(b'.');
|
|
112
|
+
signing_input.extend_from_slice(&sig_b64);
|
|
113
|
+
signing_input
|
|
114
|
+
}
|
|
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
|
+
/// Verify an HS256 signature (constant-time). Does NOT check claims/expiry.
|
|
130
|
+
///
|
|
131
|
+
/// Test-only convenience over the fresh-key derivation: production code uses
|
|
132
|
+
/// `verify_signature_with_key` with a precompiled key.
|
|
133
|
+
#[cfg(test)]
|
|
134
|
+
pub fn verify_signature(token: &[u8], secret: &[u8]) -> bool {
|
|
135
|
+
let key = aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, secret);
|
|
136
|
+
verify_signature_with_key(token, &key)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Verify an HS256 signature (constant-time) with a PRE-COMPILED key.
|
|
140
|
+
pub fn verify_signature_with_key(token: &[u8], key: &aws_lc_rs::hmac::Key) -> bool {
|
|
141
|
+
let Some(parts) = split_token(token) else {
|
|
142
|
+
return false;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
let mut signing_input =
|
|
146
|
+
Vec::with_capacity(parts.header_b64.len() + 1 + parts.payload_b64.len());
|
|
147
|
+
signing_input.extend_from_slice(parts.header_b64);
|
|
148
|
+
signing_input.push(b'.');
|
|
149
|
+
signing_input.extend_from_slice(parts.payload_b64);
|
|
150
|
+
|
|
151
|
+
let sig = hmac_sha256_with_key(key, &signing_input);
|
|
152
|
+
|
|
153
|
+
match base64url_decode_bytes(parts.sig_b64) {
|
|
154
|
+
Some(provided) => ct_eq(&sig, &provided),
|
|
155
|
+
None => false,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Claim injection (iat/exp) ──────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/// Inject `iat`/`exp` into object claims when `ttl_seconds` is set, then
|
|
162
|
+
/// serialize + base64url-encode the payload. Shared by the scalar, instance,
|
|
163
|
+
/// and byte-JSON sign paths so the injection semantics can't drift.
|
|
164
|
+
pub fn inject_and_payload_b64(
|
|
165
|
+
claims: &mut serde_json::Value,
|
|
166
|
+
ttl_seconds: Option<i64>,
|
|
167
|
+
now_seconds: i64,
|
|
168
|
+
) -> Result<Vec<u8>> {
|
|
169
|
+
if let Some(obj) = claims.as_object_mut() {
|
|
170
|
+
if let Some(ttl) = ttl_seconds {
|
|
171
|
+
if ttl > 0 {
|
|
172
|
+
obj.entry("iat")
|
|
173
|
+
.or_insert_with(|| serde_json::json!(now_seconds));
|
|
174
|
+
obj.entry("exp")
|
|
175
|
+
.or_insert_with(|| serde_json::json!(now_seconds + ttl));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
Ok(base64url_encode_bytes(
|
|
180
|
+
&serde_json::to_vec(claims).map_err(napi_err)?,
|
|
181
|
+
))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// Inject `iat`/`exp` into a sonic object when `ttl_seconds` is set (unless
|
|
185
|
+
/// already present) — shared by the sonic byte-sign paths.
|
|
186
|
+
pub(crate) fn inject_ttl_claims(
|
|
187
|
+
claims: &mut sonic_rs::Value,
|
|
188
|
+
ttl_seconds: Option<i64>,
|
|
189
|
+
now_seconds: i64,
|
|
190
|
+
) {
|
|
191
|
+
if let Some(ttl) = ttl_seconds {
|
|
192
|
+
if ttl > 0 {
|
|
193
|
+
if let Some(obj) = claims.as_object_mut() {
|
|
194
|
+
if obj.get(&"iat").is_none() {
|
|
195
|
+
obj.insert("iat", now_seconds);
|
|
196
|
+
}
|
|
197
|
+
if obj.get(&"exp").is_none() {
|
|
198
|
+
obj.insert("exp", now_seconds + ttl);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Sonic twin of [`inject_and_payload_b64`] for the pre-serialized-claim-byte
|
|
206
|
+
/// sign paths (no `serde_json::Value` DOM): parse the claims once into a
|
|
207
|
+
/// compact `sonic_rs::Value`, inject `iat`/`exp`, serialize + base64url.
|
|
208
|
+
pub fn inject_and_payload_b64_sonic(
|
|
209
|
+
claims: &mut sonic_rs::Value,
|
|
210
|
+
ttl_seconds: Option<i64>,
|
|
211
|
+
now_seconds: i64,
|
|
212
|
+
) -> Result<Vec<u8>> {
|
|
213
|
+
inject_ttl_claims(claims, ttl_seconds, now_seconds);
|
|
214
|
+
Ok(base64url_encode_bytes(
|
|
215
|
+
&sonic_rs::to_vec(claims).map_err(napi_err)?,
|
|
216
|
+
))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn napi_err(e: impl std::fmt::Display) -> Error {
|
|
220
|
+
Error::from_reason(e.to_string())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Verification ───────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/// Pure-core JWT verification: HS256 signature + `alg` allowlist + time claims
|
|
226
|
+
/// (`exp`/`nbf`/`iat`). Returns the decoded payload BYTES (valid JSON claims),
|
|
227
|
+
/// or `None` on any failure — no `serde_json::Value` DOM and no re-serialize.
|
|
228
|
+
pub fn verify_token(token: &[u8], secret: &[u8], now_seconds: i64) -> Option<Vec<u8>> {
|
|
229
|
+
let key = aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, secret);
|
|
230
|
+
verify_token_with_key(token, &key, now_seconds)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/// Zero-DOM time-claim checks over a base64url payload segment: `exp`
|
|
234
|
+
/// (reject when `now >= exp`), `nbf` (reject when not yet valid), `iat`
|
|
235
|
+
/// (reject tokens issued beyond the clock-skew leeway). Returns the decoded
|
|
236
|
+
/// payload bytes (valid JSON claims) or `None`. Shared by the HS256 and
|
|
237
|
+
/// EdDSA verify paths so the claim semantics can't drift.
|
|
238
|
+
pub(crate) fn verify_time_claims(payload_b64: &[u8], now_seconds: i64) -> Option<Vec<u8>> {
|
|
239
|
+
let payload = base64url_decode_bytes(payload_b64)?;
|
|
240
|
+
let value: sonic_rs::Value = sonic_rs::from_slice(&payload).ok()?;
|
|
241
|
+
|
|
242
|
+
// `exp`: reject when now >= exp.
|
|
243
|
+
if let Some(exp) = value.get("exp").and_then(|v| v.as_i64()) {
|
|
244
|
+
if now_seconds >= exp {
|
|
245
|
+
return None;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// `nbf` (not before): reject when the token is not yet valid.
|
|
250
|
+
if let Some(nbf) = value.get("nbf").and_then(|v| v.as_i64()) {
|
|
251
|
+
if now_seconds < nbf {
|
|
252
|
+
return None;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// `iat` (issued at): reject tokens issued in the future beyond a small
|
|
257
|
+
// clock-skew leeway.
|
|
258
|
+
if let Some(iat) = value.get("iat").and_then(|v| v.as_i64()) {
|
|
259
|
+
if now_seconds < iat.saturating_sub(CLOCK_SKEW_LEEWAY_SECS) {
|
|
260
|
+
return None;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
Some(payload)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Verify with a PRE-COMPILED key (no per-call key derivation). Time-claim
|
|
268
|
+
/// checks run over a compact `sonic_rs::Value` (zero per-key heap `String`);
|
|
269
|
+
/// the decoded payload bytes are returned verbatim.
|
|
270
|
+
pub fn verify_token_with_key(
|
|
271
|
+
token: &[u8],
|
|
272
|
+
key: &aws_lc_rs::hmac::Key,
|
|
273
|
+
now_seconds: i64,
|
|
274
|
+
) -> Option<Vec<u8>> {
|
|
275
|
+
if !verify_signature_with_key(token, key) {
|
|
276
|
+
return None;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let parts = split_token(token)?;
|
|
280
|
+
|
|
281
|
+
// ── Header: enforce the alg allowlist (prevents alg-confusion) ──
|
|
282
|
+
// Fast path: the canonical HS256 header matches — accept without parsing
|
|
283
|
+
// the header into a DOM. Otherwise fall back to the full parse (which
|
|
284
|
+
// accepts alternative key order / extra header fields), so behavior is
|
|
285
|
+
// identical to the previous always-parse path.
|
|
286
|
+
if parts.header_b64 != jwt_header_b64() {
|
|
287
|
+
let header: sonic_rs::Value =
|
|
288
|
+
sonic_rs::from_slice(&base64url_decode_bytes(parts.header_b64)?).ok()?;
|
|
289
|
+
if header.get("alg").and_then(|v| v.as_str()) != Some("HS256") {
|
|
290
|
+
return None;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Payload: decode once; zero-DOM time-claim checks (shared) ──
|
|
295
|
+
verify_time_claims(parts.payload_b64, now_seconds)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── EdDSA (Ed25519) ────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/// Sign an EdDSA (Ed25519) JWT from pre-serialized claim JSON bytes. Claims
|
|
301
|
+
/// parsing/`iat`/`exp` injection is shared with the HS256 byte path; only the
|
|
302
|
+
/// signature primitive differs. Returns the compact token bytes, or `None` on
|
|
303
|
+
/// invalid claims JSON / invalid private key.
|
|
304
|
+
pub fn sign_eddsa(
|
|
305
|
+
claims_json: &[u8],
|
|
306
|
+
private_der: &[u8],
|
|
307
|
+
ttl_seconds: Option<i64>,
|
|
308
|
+
now_seconds: i64,
|
|
309
|
+
) -> Option<Vec<u8>> {
|
|
310
|
+
let mut claims: sonic_rs::Value = sonic_rs::from_slice(claims_json).ok()?;
|
|
311
|
+
let payload_b64 = inject_and_payload_b64_sonic(&mut claims, ttl_seconds, now_seconds).ok()?;
|
|
312
|
+
build_eddsa_token(eddsa_header_b64(), &payload_b64, private_der)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/// Assemble an EdDSA token from base64url header + payload segments: sign
|
|
316
|
+
/// `header.payload` with the Ed25519 private key, append the base64url sig.
|
|
317
|
+
pub fn build_eddsa_token(
|
|
318
|
+
header_b64: &[u8],
|
|
319
|
+
payload_b64: &[u8],
|
|
320
|
+
private_der: &[u8],
|
|
321
|
+
) -> Option<Vec<u8>> {
|
|
322
|
+
let mut signing_input = Vec::with_capacity(header_b64.len() + 1 + payload_b64.len());
|
|
323
|
+
signing_input.extend_from_slice(header_b64);
|
|
324
|
+
signing_input.push(b'.');
|
|
325
|
+
signing_input.extend_from_slice(payload_b64);
|
|
326
|
+
let sig = crate::crypto::ed25519::sign(private_der, &signing_input)?;
|
|
327
|
+
signing_input.push(b'.');
|
|
328
|
+
signing_input.extend_from_slice(&base64url_encode_bytes(&sig));
|
|
329
|
+
Some(signing_input)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/// Verify an EdDSA (Ed25519) JWT: signature + `alg` allowlist + time claims
|
|
333
|
+
/// (shared with the HS256 path). Returns the decoded payload bytes (valid JSON
|
|
334
|
+
/// claims), or `None` on any failure.
|
|
335
|
+
pub fn verify_token_eddsa(token: &[u8], public_der: &[u8], now_seconds: i64) -> Option<Vec<u8>> {
|
|
336
|
+
let parts = split_token(token)?;
|
|
337
|
+
|
|
338
|
+
// ── Header: enforce the alg allowlist (prevents alg-confusion) ──
|
|
339
|
+
// Fast path: the canonical EdDSA header matches — accept without parsing.
|
|
340
|
+
if parts.header_b64 != eddsa_header_b64() {
|
|
341
|
+
let header: sonic_rs::Value =
|
|
342
|
+
sonic_rs::from_slice(&base64url_decode_bytes(parts.header_b64)?).ok()?;
|
|
343
|
+
if header.get("alg").and_then(|v| v.as_str()) != Some("EdDSA") {
|
|
344
|
+
return None;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Signature: constant-time Ed25519 verify over `header.payload` ──
|
|
349
|
+
let sig = base64url_decode_bytes(parts.sig_b64)?;
|
|
350
|
+
let mut signing_input =
|
|
351
|
+
Vec::with_capacity(parts.header_b64.len() + 1 + parts.payload_b64.len());
|
|
352
|
+
signing_input.extend_from_slice(parts.header_b64);
|
|
353
|
+
signing_input.push(b'.');
|
|
354
|
+
signing_input.extend_from_slice(parts.payload_b64);
|
|
355
|
+
if !crate::crypto::ed25519::verify(public_der, &signing_input, &sig) {
|
|
356
|
+
return None;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
verify_time_claims(parts.payload_b64, now_seconds)
|
|
360
|
+
}
|