Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,7 @@ TEST_TARGETS = test_worker_affinity \
test_websocket_async \
test_webrtc \
test_jwt \
test_jwt_asym \
test_migrate \
test_json_heal \
test_https \
Expand Down Expand Up @@ -1270,6 +1271,10 @@ test_jwt: $(LIB_NAME) tests/test_jwt.c
$(CC) $(CFLAGS) -o test_jwt tests/test_jwt.c $(LIB_NAME) $(LIBS)
./test_jwt

test_jwt_asym: $(LIB_NAME) tests/test_jwt_asym.c
$(CC) $(CFLAGS) -o $@ tests/test_jwt_asym.c $(LIB_NAME) $(LIBS)
./$@

test_migrate: $(LIB_NAME) tests/test_migrate.c
$(CC) $(CFLAGS) -o test_migrate tests/test_migrate.c $(LIB_NAME) $(LIBS)
./test_migrate
Expand Down
10 changes: 7 additions & 3 deletions bindings/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ request/response strings and headers, error values, the `_extern` forms of
`static inline` helpers, and the advanced surface the safe wrappers cover:
safe strings (`cwist_sstring_*`), the SQLite wrapper (`cwist_db_*` plus the
hook-aware `cJSON_PrintUnformatted`/`cJSON_Delete`/`cJSON_free`), the
background scheduler (`cwist_scheduler_*`), JWT verify/claims
(`cwist_jwt_verify` and friends; `cwist_jwt_sign` was already bound),
background scheduler (`cwist_scheduler_*`), JWT sign/verify/claims
(`cwist_jwt_sign`, `cwist_jwt_verify`, the ES256/Ed25519 variants and
friends),
SipHash-2-4 (`siphash24`, `cwist_generate_hash_seed`) and the bump arena
(`cwist_arena_*`). `cwist_app`, `cwist_query_map`, `cwist_db`, `cJSON`,
`sqlite3`, `cwist_async`, `cwist_compress_backend`, `cwist_scheduler_t` and
Expand Down Expand Up @@ -102,7 +103,10 @@ bytes with `Error::InteriorNul` instead of truncating. Per-item contracts
no parameter binding, so validate statements in Rust before they touch
user input. `serialize`/`open_memory` round-trip a database image.
* `cwist::Scheduler` — worker pool plus delayed task queue; `Send + Sync`.
* `cwist::{sign, verify, Claims}` — HMAC-SHA256 JWT.
* `cwist::{sign, sign_es256, sign_ed25519, verify, verify_es256,
verify_ed25519, Claims}` — JWT with HMAC-SHA256, ES256 (P-256) or
Ed25519; the verifiers accept only their own `alg`, so cross-algorithm
confusion is rejected.
* `cwist::Key` — SipHash-2-4 with a random or caller-supplied 128-bit key.
* `cwist::Arena` — request-scoped bump allocator; `alloc_bytes` hands out
zeroed slices, `unsafe alloc_uninit` is the zero-copy escape hatch.
Expand Down
2 changes: 2 additions & 0 deletions bindings/rust/cwist-sys/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const FUNCTIONS: &[&str] = &[
"cwist_mw_(request_id|access_log|rate_limit_ip|rate_limit_reset|metrics|cors|jwt_auth|compress|jwt_get_claims)",
// Signs tokens for the JWT middleware (security/jwt/jwt.h).
"cwist_jwt_sign",
// Asymmetric algorithms (ES256, Ed25519); kid is an optional header claim.
"cwist_jwt_(sign_es256|verify_es256|sign_ed25519|verify_ed25519)",
"cwist_jwt_(verify|claims_get|claims_destroy)",
"cwist_alloc",
"cwist_free",
Expand Down
120 changes: 101 additions & 19 deletions bindings/rust/cwist/src/jwt.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
//! JWT signing and verification wrapping CWIST's HMAC-SHA256 implementation.
//! JWT signing and verification: HMAC-SHA256, ES256 (ECDSA P-256) and
//! Ed25519, wrapping CWIST's implementation.

use crate::http::c_str;
use crate::support::CBuf;
use crate::Error;
use cwist_sys as sys;
use std::ffi::CString;
use std::ptr::NonNull;
use std::ffi::{c_char, c_long, CString};
use std::ptr::{self, NonNull};

fn nul_free(s: &str, what: &'static str) -> Result<CString, Error> {
CString::new(s).map_err(|_| Error::InteriorNul(what))
}

/// Copies the signed token into an owned String; the C buffer is released
/// with cwist_free by the guard on every path.
fn take_token(raw: *mut c_char) -> Result<String, Error> {
let token = CBuf::new(raw.cast(), sys::cwist_free).ok_or(Error::JwtSign)?;
// SAFETY: token is a NUL-terminated string owned by the guard, live
// until end of scope.
Ok(unsafe { c_str(token.as_ptr().cast()) }
.ok_or(Error::JwtSign)?
.to_owned())
}

type VerifyFn =
unsafe extern "C" fn(*const c_char, *const c_char) -> *mut sys::cwist_jwt_claims;

fn verify_with(verify_fn: VerifyFn, token: &str, key: &str) -> Result<Claims, Error> {
let token = nul_free(token, "JWT token")?;
let key = nul_free(key, "JWT key")?;
// SAFETY: both strings outlive the call; NULL means verification failed.
let raw = unsafe { verify_fn(token.as_ptr(), key.as_ptr()) };
NonNull::new(raw).map(|raw| Claims { raw }).ok_or(Error::InvalidToken)
}

/// Signs a JWT token with HMAC-SHA256, returning the compact
/// `header.payload.signature` form.
Expand All @@ -15,20 +42,13 @@ use std::ptr::NonNull;
/// `exp_seconds` from now; pass `exp_seconds = 0` to keep full control and
/// supply `exp` yourself. Both strings must not contain an interior NUL.
pub fn sign(claims_json: &str, secret: &str, exp_seconds: i64) -> Result<String, Error> {
let claims_json = CString::new(claims_json).map_err(|_| Error::InteriorNul("claims JSON"))?;
let secret = CString::new(secret).map_err(|_| Error::InteriorNul("JWT secret"))?;
let claims_json = nul_free(claims_json, "claims JSON")?;
let secret = nul_free(secret, "JWT secret")?;
// SAFETY: both strings outlive the call. c_long is 64 bits on every
// platform CWIST supports (LP64 Linux and macOS), so exp_seconds passes
// directly.
let token = unsafe { sys::cwist_jwt_sign(claims_json.as_ptr(), secret.as_ptr(), exp_seconds) };
// cwist_jwt_sign's contract is cwist_free; the guard releases the token
// on every path out of this function.
let token = CBuf::new(token.cast(), sys::cwist_free).ok_or(Error::JwtSign)?;
// SAFETY: token is a NUL-terminated string owned by the guard, live
// until end of scope.
Ok(unsafe { c_str(token.as_ptr().cast()) }
.ok_or(Error::JwtSign)?
.to_owned())
take_token(token)
}

/// Claims decoded from a verified JWT token, destroyed on drop.
Expand Down Expand Up @@ -57,11 +77,73 @@ impl Drop for Claims {

/// Verifies a JWT token's structure, HMAC-SHA256 signature and `exp` claim,
/// returning its decoded claims. Returns [`Error::InvalidToken`] when the
/// token is malformed, signed with a different secret, or expired.
/// token is malformed, signed with a different secret or algorithm, or
/// expired.
pub fn verify(token: &str, secret: &str) -> Result<Claims, Error> {
let token = CString::new(token).map_err(|_| Error::InteriorNul("JWT token"))?;
let secret = CString::new(secret).map_err(|_| Error::InteriorNul("JWT secret"))?;
// SAFETY: both strings outlive the call; NULL means verification failed.
let raw = unsafe { sys::cwist_jwt_verify(token.as_ptr(), secret.as_ptr()) };
NonNull::new(raw).map(|raw| Claims { raw }).ok_or(Error::InvalidToken)
verify_with(sys::cwist_jwt_verify, token, secret)
}

type SignAsymFn = unsafe extern "C" fn(
*const c_char,
*const c_char,
*const c_char,
c_long,
) -> *mut c_char;

fn sign_asym(
sign_fn: SignAsymFn,
claims_json: &str,
pem_private_key: &str,
kid: Option<&str>,
exp_seconds: i64,
) -> Result<String, Error> {
let claims_json = nul_free(claims_json, "claims JSON")?;
let key = nul_free(pem_private_key, "PEM private key")?;
let kid = kid.map(|k| nul_free(k, "key id")).transpose()?;
let kid_ptr = kid.as_ref().map(|k| k.as_ptr()).unwrap_or(ptr::null());
// SAFETY: all strings outlive the call; exp_seconds fits c_long on LP64.
let token = unsafe {
sign_fn(claims_json.as_ptr(), key.as_ptr(), kid_ptr, exp_seconds)
};
take_token(token)
}

/// Signs a JWT token with ES256 (ECDSA over P-256 with SHA-256), returning
/// the compact `header.payload.signature` form.
///
/// `pem_private_key` is a PEM-encoded EC private key on P-256. `kid`, when
/// given, is written into the JWT header. See [`sign`] for the `exp_seconds`
/// contract.
pub fn sign_es256(
claims_json: &str,
pem_private_key: &str,
kid: Option<&str>,
exp_seconds: i64,
) -> Result<String, Error> {
sign_asym(sys::cwist_jwt_sign_es256, claims_json, pem_private_key, kid, exp_seconds)
}

/// Verifies an ES256-signed JWT token against a PEM-encoded EC P-256 public
/// (or private) key. The token header must declare exactly `alg: ES256`, so
/// a token signed with any other algorithm — including HS256 — is rejected
/// with [`Error::InvalidToken`].
pub fn verify_es256(token: &str, pem_key: &str) -> Result<Claims, Error> {
verify_with(sys::cwist_jwt_verify_es256, token, pem_key)
}

/// Signs a JWT token with EdDSA (Ed25519). See [`sign_es256`] for the
/// parameter contract.
pub fn sign_ed25519(
claims_json: &str,
pem_private_key: &str,
kid: Option<&str>,
exp_seconds: i64,
) -> Result<String, Error> {
sign_asym(sys::cwist_jwt_sign_ed25519, claims_json, pem_private_key, kid, exp_seconds)
}

/// Verifies an Ed25519-signed JWT token against a PEM-encoded Ed25519 public
/// (or private) key. The token header must declare exactly `alg: EdDSA`.
pub fn verify_ed25519(token: &str, pem_key: &str) -> Result<Claims, Error> {
verify_with(sys::cwist_jwt_verify_ed25519, token, pem_key)
}
2 changes: 1 addition & 1 deletion bindings/rust/cwist/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ pub use scheduler::Scheduler;
pub use server::shutdown;
pub use siphash::Key;
pub use sstring::SString;
pub use jwt::{sign, verify, Claims};
pub use jwt::{sign, sign_ed25519, sign_es256, verify, verify_ed25519, verify_es256, Claims};
60 changes: 59 additions & 1 deletion bindings/rust/cwist/tests/advanced.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! The advanced/dangerous FFI surface wrapped safely: growable strings,
//! SQLite, the job scheduler, JWT, SipHash and the bump arena.

use cwist::{sign, verify, Arena, Db, Error, Key, Scheduler, SString};
use cwist::{
sign, sign_ed25519, sign_es256, verify, verify_ed25519, verify_es256, Arena, Db, Error, Key,
Scheduler, SString,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
Expand Down Expand Up @@ -229,3 +232,58 @@ fn arena_zeroed_memory_owns_and_generations() {
assert!(big.iter().all(|&b| b == 0));
big[4095] = 1;
}

// P-256 and Ed25519 test keys (throwaway, generated for these tests).
const EC256_PRIV: &str = "-----BEGIN EC PRIVATE KEY-----\n\
MHcCAQEEIDC7BCqt+gWMBQEEX7581vxye5WCTZNUlp2OdYdHwbYSoAoGCCqGSM49\n\
AwEHoUQDQgAEQlZeTKbWplC+kWKJzbfp82Ig7o1N0Pdin5CkqfUEO+prdr/XnLjp\n\
vFFp2mMpAudMNv4Uw8BmpCLrUSeUF1jZKQ==\n\
-----END EC PRIVATE KEY-----\n";
const EC256_PUB: &str = "-----BEGIN PUBLIC KEY-----\n\
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQlZeTKbWplC+kWKJzbfp82Ig7o1N\n\
0Pdin5CkqfUEO+prdr/XnLjpvFFp2mMpAudMNv4Uw8BmpCLrUSeUF1jZKQ==\n\
-----END PUBLIC KEY-----\n";
const ED25519_PRIV: &str = "-----BEGIN PRIVATE KEY-----\n\
MC4CAQAwBQYDK2VwBCIEIOg0tgwevnUolReLx+S+Kq+tiity9wj7HtCnqnU45TLD\n\
-----END PRIVATE KEY-----\n";
const ED25519_PUB: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAm1eSBuXZV0PVcazp/ZYocHlQfQJVEDOmhcRNj3X1sdI=\n\
-----END PUBLIC KEY-----\n";

#[test]
fn jwt_es256_roundtrip_kid_and_confusion() {
let token = sign_es256(r#"{"sub":"user-1"}"#, EC256_PRIV, Some("key-1"), 3600).expect("sign");
assert!(token.starts_with("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0"));

let claims = verify_es256(&token, EC256_PUB).expect("verify");
assert_eq!(claims.get("sub"), Some("user-1"));
drop(claims);

// The private key verifies too (its public half is used).
assert!(verify_es256(&token, EC256_PRIV).is_ok());

// Wrong key, wrong algorithm and HMAC verification all reject it.
assert!(matches!(verify_es256(&token, ED25519_PUB), Err(Error::InvalidToken)));
assert!(matches!(verify_ed25519(&token, ED25519_PUB), Err(Error::InvalidToken)));
assert!(matches!(verify(&token, "secret"), Err(Error::InvalidToken)));

// HS256 tokens are rejected by the asymmetric verifiers (alg confusion).
let hs = sign(r#"{"sub":"1"}"#, "secret", 3600).expect("hs256 sign");
assert!(matches!(verify_es256(&hs, EC256_PUB), Err(Error::InvalidToken)));
assert!(matches!(verify_ed25519(&hs, ED25519_PUB), Err(Error::InvalidToken)));
}

#[test]
fn jwt_ed25519_roundtrip_and_key_type_mismatch() {
let token = sign_ed25519(r#"{"sub":"alice"}"#, ED25519_PRIV, None, 3600).expect("sign");
assert!(token.starts_with("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9"));

let claims = verify_ed25519(&token, ED25519_PUB).expect("verify");
assert_eq!(claims.get("sub"), Some("alice"));
drop(claims);

// Keys of the wrong type cannot sign for the other algorithm.
assert!(sign_es256(r#"{"sub":"1"}"#, ED25519_PRIV, None, 0).is_err());
assert!(sign_ed25519(r#"{"sub":"1"}"#, EC256_PRIV, None, 0).is_err());
assert!(sign_es256(r#"{"sub":"1"}"#, "not a pem", None, 0).is_err());
}
12 changes: 10 additions & 2 deletions docs/api/v4.0-api-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# (parameter names dropped) and struct/union/enum layouts in member order.
# `make api-check` (CI) fails when the headers no longer match this file.

== functions (770) ==
== functions (774) ==
cwist_accept_socket
cwist_alloc
cwist_alloc_array
Expand Down Expand Up @@ -477,9 +477,13 @@ cwist_jwt_claims_get
cwist_jwt_join_chunks
cwist_jwt_sign
cwist_jwt_sign_chunks
cwist_jwt_sign_ed25519
cwist_jwt_sign_es256
cwist_jwt_split_chunks
cwist_jwt_verify
cwist_jwt_verify_chunks
cwist_jwt_verify_ed25519
cwist_jwt_verify_es256
cwist_log_async_active
cwist_log_async_flush
cwist_log_async_start
Expand Down Expand Up @@ -776,7 +780,7 @@ cwist_zod_parse
cwist_zod_print_errors
cwist_zod_validate

== function signatures (771) ==
== function signatures (775) ==
cwist_accept_socket: cwist_error_t (int, struct sockaddr *, void (*)(int client_fd, void *ctx), void *)
cwist_alloc: void * (size_t)
cwist_alloc_array: void * (size_t, size_t)
Expand Down Expand Up @@ -1250,9 +1254,13 @@ cwist_jwt_claims_get: const char * (const cwist_jwt_claims *, const char *)
cwist_jwt_join_chunks: char * (const cwist_jwt_chunk_t *, size_t)
cwist_jwt_sign: char * (const char *, const char *, long)
cwist_jwt_sign_chunks: cwist_jwt_chunk_t * (const char *, const char *, long, uint16_t, size_t *)
cwist_jwt_sign_ed25519: char * (const char *, const char *, const char *, long)
cwist_jwt_sign_es256: char * (const char *, const char *, const char *, long)
cwist_jwt_split_chunks: cwist_jwt_chunk_t * (const char *, uint16_t, size_t *)
cwist_jwt_verify: cwist_jwt_claims * (const char *, const char *)
cwist_jwt_verify_chunks: cwist_jwt_claims * (const cwist_jwt_chunk_t *, size_t, const char *)
cwist_jwt_verify_ed25519: cwist_jwt_claims * (const char *, const char *)
cwist_jwt_verify_es256: cwist_jwt_claims * (const char *, const char *)
cwist_log_async_active: bool (void)
cwist_log_async_flush: void (void)
cwist_log_async_start: bool (size_t)
Expand Down
2 changes: 1 addition & 1 deletion example/jwt/step-1-sign-verify/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
CC = gcc
CFLAGS = -Wall -Wextra -g -I../../../include -I../../../lib -I../../../lib/libttak/include
LDFLAGS = -L../../../ -L../../../lib/cjson -L../../../lib/libttak/lib
LDFLAGS = -L../../../ -L../../../lib/cjson -L../../../lib/libttak/lib -L../../../lib/boringssl/build
LIBS = -lcwist -lcjson -lssl -lcrypto -lttak -lpthread -ldl -lstdc++ -lm -lz

TARGET = step-1-sign-verify
Expand Down
Loading
Loading