Skip to content

Commit f243732

Browse files
committed
feat(moonzero): add JWT HS256 auth, YAML config loader, and zRPC service groups.
Self-built SHA-256 (FIPS 180-4) + HMAC-SHA256 (RFC 2104), verified against NIST/RFC vectors, back a compact HS256 JWT: jwt_sign/jwt_verify compare signatures in constant time, enforce exp/nbf, and refuse the alg:none downgrade; verified interop against the canonical jwt.io token. The auth middleware rejects absent/tampered/expired Bearer tokens with 401. A minimal-subset YAML parser (block maps, nesting, sequences, typed scalars, comments) feeds ServiceConf::from_yaml through the same field reader as the JSON loader. Config-driven RpcServer/RpcGroup register moonrpc Method handlers by gRPC path and dispatch unary calls, returning Unimplemented for unknown methods. Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent 9c3bb73 commit f243732

16 files changed

Lines changed: 1503 additions & 10 deletions

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,34 @@ let server = @moonzero.Server::new(conf, app)
6363
- **`maxbytes`** — rejects a request whose declared `Content-Length` exceeds the limit with `413`.
6464
- **`structured_logging`** — a [`RequestLog`](./logging.mbt) rendered as one JSON line per request (method, path, status, duration, request-id, client-ip, user-agent).
6565

66+
## Auth, YAML config, and zRPC groups
67+
68+
```moonbit
69+
// JWT HS256 — self-built SHA-256/HMAC (verified against NIST/RFC vectors)
70+
let token = @moonzero.jwt_sign(
71+
Map([("sub", Json::string("alice")), ("exp", Json::number(1893456000.0))]),
72+
"topsecret",
73+
)
74+
let server = @moonzero.Server::new(conf, app)
75+
.use_(@moonzero.auth("topsecret", clock)) // 401 unless a valid Bearer JWT
76+
77+
// YAML config — the etc/*.yaml format go-zero ships, same lenient defaults as JSON
78+
let conf = @moonzero.ServiceConf::from_yaml("name: greet\nport: 9000\nlog_level: error\n")
79+
80+
// zRPC service groups over moonrpc — register Method handlers, dispatch by gRPC path
81+
let rpc = @moonzero.RpcServer::new(@moonzero.RpcServerConf::new(name="greeter", port=9090))
82+
rpc.group("hello.Greeter").register("SayHello", req => handle(req))
83+
rpc.dispatch("/hello.Greeter/SayHello", request) // Ok(bytes) | Err(Unimplemented)
84+
```
85+
86+
- **`jwt_sign` / `jwt_verify`** — compact HS256 tokens on a [self-built SHA-256 + HMAC-SHA256](./crypto.mbt), signatures compared in constant time, `exp`/`nbf` enforced, and the `alg:none` downgrade refused. Interop-verified against the canonical jwt.io token.
87+
- **`auth`** — the [middleware](./auth.mbt) that requires `Authorization: Bearer <jwt>` and answers `401` for an absent, malformed, tampered, or expired token.
88+
- **`ServiceConf::from_yaml`** — a [minimal-subset YAML parser](./yaml.mbt) (block maps, nesting, sequences, typed scalars, comments) feeding the same field reader as the JSON loader, so both formats agree field-for-field.
89+
- **`RpcServer` / `RpcGroup`**[config-driven zRPC groups](./rpc.mbt) that register [`moonrpc`](https://github.com/Lfan-ke/moonrpc) `Method` handlers by gRPC path and dispatch unary calls, returning `Unimplemented` for an unknown method.
90+
6691
## Roadmap (transliterating go-zero)
6792

68-
Typed config (JSON loading with defaults, timeout + log level) + service assembly + the base middleware onion (logging, recovery, CORS, request-id) + the resilience set (timeout, rate-limit, breaker, maxbytes, structured logging) + route groups are here. Next, feature-by-feature: YAML config loading, auth (JWT) + metrics/tracing/prometheus middleware, RPC service groups over `moonrpc`, and service discovery / registry — plus `moonctl`-driven scaffolding of a full `moonzero` service from a spec.
93+
Typed config (JSON + YAML loading with defaults, timeout + log level) + service assembly + the base middleware onion (logging, recovery, CORS, request-id) + the resilience set (timeout, rate-limit, breaker, maxbytes, structured logging) + route groups + JWT auth + zRPC service groups are here. Next, feature-by-feature: metrics/tracing/prometheus middleware, the real h2 transport under `moonrpc` for live RPC, and service discovery / registry (etcd/consul) — plus `moonctl`-driven scaffolding of a full `moonzero` service from a spec.
6994

7095
## License
7196

auth.mbt

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
///|
2+
/// The `Bearer ` scheme prefix an `Authorization` header carries a JWT under.
3+
let bearer_prefix : String = "Bearer "
4+
5+
///|
6+
/// Extract the raw token from an `Authorization: Bearer <token>` header value,
7+
/// or `None` if the value is absent or not a bearer credential. The scheme name
8+
/// is matched case-insensitively (RFC 7235 makes it case-insensitive), the token
9+
/// verbatim.
10+
fn strip_bearer(value : String?) -> String? {
11+
match value {
12+
Some(v) =>
13+
if v.length() >= bearer_prefix.length() &&
14+
v[0:bearer_prefix.length()].to_owned().to_lower() ==
15+
bearer_prefix.to_lower() {
16+
Some(v[bearer_prefix.length():].to_owned())
17+
} else {
18+
None
19+
}
20+
None => None
21+
}
22+
}
23+
24+
///|
25+
/// The event stream an unauthenticated request receives: a `401 Unauthorized`
26+
/// with a `WWW-Authenticate: Bearer` challenge and a short plain-text body. A
27+
/// pure value so the guard's rejection is testable without the async transport.
28+
fn unauthorized_events() -> Array[@moonasgi.Event] {
29+
[
30+
@moonasgi.Event::HttpResponseStart(status=401, headers=[
31+
("content-type", "text/plain; charset=utf-8"),
32+
("www-authenticate", "Bearer"),
33+
]),
34+
@moonasgi.Event::HttpResponseBody(body=b"401 Unauthorized", more_body=false),
35+
]
36+
}
37+
38+
///|
39+
/// Whether a request bearing `token` is authorised at `now_secs`: the token
40+
/// verifies against `secret` under HS256 and is neither expired nor
41+
/// not-yet-valid. A missing token is unauthorised. Exposed as a pure decision so
42+
/// the middleware's accept/reject is testable without driving the transport.
43+
pub fn jwt_authorized(
44+
token : String?,
45+
secret : String,
46+
now_secs : Int64,
47+
) -> Bool {
48+
match token {
49+
Some(t) =>
50+
try {
51+
let _ = jwt_verify(t, secret, now_secs)
52+
true
53+
} catch {
54+
_ => false
55+
}
56+
None => false
57+
}
58+
}
59+
60+
///|
61+
/// JWT auth middleware (← go-zero's `handler.Authorize`): require every HTTP
62+
/// request to carry a valid `Authorization: Bearer <jwt>` header. The token is
63+
/// verified against `secret` under HS256 at the current time read from `clock`
64+
/// (milliseconds, converted to the JWT seconds epoch); an absent, malformed,
65+
/// tampered, expired, or not-yet-valid token is rejected with `401 Unauthorized`
66+
/// before the wrapped app runs. Non-HTTP scopes (lifespan, websocket) pass
67+
/// through untouched.
68+
pub fn auth(secret : String, clock : Clock) -> Middleware {
69+
inner => {
70+
(scope, receive, send) => {
71+
match scope {
72+
Http(_) => {
73+
let token = strip_bearer(scope_header(scope, "authorization"))
74+
let now_secs = clock.now() / 1000L
75+
if jwt_authorized(token, secret, now_secs) {
76+
inner(scope, receive, send)
77+
} else {
78+
for event in unauthorized_events() {
79+
send(event)
80+
}
81+
}
82+
}
83+
_ => inner(scope, receive, send)
84+
}
85+
}
86+
}
87+
}

auth_wbtest.mbt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
///|
2+
test "strip_bearer extracts the token case-insensitively on the scheme" {
3+
assert_eq(strip_bearer(Some("Bearer abc.def.ghi")), Some("abc.def.ghi"))
4+
assert_eq(strip_bearer(Some("bearer abc.def.ghi")), Some("abc.def.ghi"))
5+
assert_eq(strip_bearer(Some("BEARER xyz")), Some("xyz"))
6+
assert_eq(strip_bearer(Some("Basic abc")), None)
7+
assert_eq(strip_bearer(Some("abc")), None)
8+
assert_eq(strip_bearer(None), None)
9+
}
10+
11+
///|
12+
test "jwt_authorized accepts a valid bearer token and rejects the rest" {
13+
let token = jwt_sign(
14+
Map([("sub", Json::string("u1")), ("exp", Json::number(9999.0))]),
15+
"k",
16+
)
17+
assert_eq(jwt_authorized(Some(token), "k", 100L), true)
18+
// expired
19+
assert_eq(jwt_authorized(Some(token), "k", 100000L), false)
20+
// wrong secret
21+
assert_eq(jwt_authorized(Some(token), "other", 100L), false)
22+
// absent
23+
assert_eq(jwt_authorized(None, "k", 100L), false)
24+
// garbage
25+
assert_eq(jwt_authorized(Some("not-a-jwt"), "k", 100L), false)
26+
}
27+
28+
///|
29+
test "auth middleware assembles into an AsgiApp" {
30+
let app = @moonapi.App::new()
31+
app.get("/secret", _ctx => @moonapi.text(200, "classified"))
32+
let clock = ManualClock::new().as_clock()
33+
let _ = auth("k", clock)(app.to_asgi())
34+
}
35+
36+
///|
37+
test "unauthorized_events carry a 401 and a bearer challenge" {
38+
let events = unauthorized_events()
39+
let mut status = 0
40+
let mut challenge : String? = None
41+
for e in events {
42+
match e {
43+
HttpResponseStart(status=s, headers~) => {
44+
status = s
45+
for h in headers {
46+
if h.0 == "www-authenticate" {
47+
challenge = Some(h.1)
48+
}
49+
}
50+
}
51+
_ => ()
52+
}
53+
}
54+
assert_eq(status, 401)
55+
assert_eq(challenge, Some("Bearer"))
56+
}

config.mbt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,35 @@ pub fn ServiceConf::from_json(src : String) -> ServiceConf raise ConfigError {
3636
Object(m) => m
3737
_ => raise ConfigError("config root must be a JSON object")
3838
}
39+
service_conf_of_object(obj)
40+
}
41+
42+
///|
43+
/// Load a `ServiceConf` from a **YAML** config string — the format go-zero
44+
/// actually ships (`etc/*.yaml`) — with the same lenient, default-filling
45+
/// semantics as `from_json`: an empty document yields exactly
46+
/// `ServiceConf::new()`, and each omitted field falls back to its `new()`
47+
/// default. The YAML is parsed by the self-built `yaml_parse` (block mappings,
48+
/// nesting, sequences, scalars, comments) into a `Json` object, then decoded by
49+
/// the shared field reader — so JSON and YAML configs agree field-for-field.
50+
///
51+
/// Raises `ConfigError` on malformed YAML, a non-mapping root, or a field of the
52+
/// wrong type.
53+
pub fn ServiceConf::from_yaml(src : String) -> ServiceConf raise ConfigError {
54+
let obj = match yaml_parse(src) {
55+
Object(m) => m
56+
_ => raise ConfigError("config root must be a YAML mapping")
57+
}
58+
service_conf_of_object(obj)
59+
}
60+
61+
///|
62+
/// Decode a `ServiceConf` from an already-parsed config object, filling every
63+
/// omitted field from `ServiceConf::new()`'s defaults. Shared by the JSON and
64+
/// YAML loaders so both formats apply identical lenient semantics.
65+
fn service_conf_of_object(
66+
obj : Map[String, Json],
67+
) -> ServiceConf raise ConfigError {
3968
let def = ServiceConf::new()
4069
let name = string_field(obj, "name", def.name)
4170
let host = string_field(obj, "host", def.host)

crypto.mbt

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
///|
2+
/// The 64 SHA-256 round constants (§4.2.2 of FIPS 180-4): the first 32 bits of
3+
/// the fractional parts of the cube roots of the first 64 primes.
4+
let sha256_k : Array[UInt] = [
5+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
6+
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
7+
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
8+
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
9+
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
10+
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
11+
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
12+
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
13+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
14+
0xc67178f2,
15+
]
16+
17+
///|
18+
/// A 32-bit right-rotation, the diffusion operator SHA-256 is built from.
19+
fn rotr32(x : UInt, n : Int) -> UInt {
20+
(x >> n) | (x << (32 - n))
21+
}
22+
23+
///|
24+
/// SHA-256 (FIPS 180-4): hash an arbitrary byte string to a 32-byte digest. A
25+
/// self-built primitive — MoonBit's core ships no `crypto` — implementing the
26+
/// full message schedule and 64-round compression over 512-bit blocks with the
27+
/// standard length-padding. Verified against the NIST vectors (`""`, `"abc"`).
28+
/// The building block for `hmac_sha256`, and through it for JWT HS256 signing.
29+
pub fn sha256(msg : Bytes) -> Bytes {
30+
let mut h0 : UInt = 0x6a09e667
31+
let mut h1 : UInt = 0xbb67ae85
32+
let mut h2 : UInt = 0x3c6ef372
33+
let mut h3 : UInt = 0xa54ff53a
34+
let mut h4 : UInt = 0x510e527f
35+
let mut h5 : UInt = 0x9b05688c
36+
let mut h6 : UInt = 0x1f83d9ab
37+
let mut h7 : UInt = 0x5be0cd19
38+
let bitlen = (msg.length() * 8).to_uint64()
39+
let buf = Buffer()
40+
buf.write_bytes(msg[:])
41+
buf.write_byte(b'\x80')
42+
while buf.length() % 64 != 56 {
43+
buf.write_byte(b'\x00')
44+
}
45+
for i = 7; i >= 0; i = i - 1 {
46+
buf.write_byte(((bitlen >> (i * 8)) & 0xFF).to_byte())
47+
}
48+
let data = buf.to_bytes()
49+
let w : Array[UInt] = Array::make(64, 0U)
50+
let nblocks = data.length() / 64
51+
for b = 0; b < nblocks; b = b + 1 {
52+
let off = b * 64
53+
for i = 0; i < 16; i = i + 1 {
54+
let j = off + i * 4
55+
w[i] = (data[j].to_int().reinterpret_as_uint() << 24) |
56+
(data[j + 1].to_int().reinterpret_as_uint() << 16) |
57+
(data[j + 2].to_int().reinterpret_as_uint() << 8) |
58+
data[j + 3].to_int().reinterpret_as_uint()
59+
}
60+
for i = 16; i < 64; i = i + 1 {
61+
let s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >> 3)
62+
let s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >> 10)
63+
w[i] = w[i - 16] + s0 + w[i - 7] + s1
64+
}
65+
let mut a = h0
66+
let mut bb = h1
67+
let mut c = h2
68+
let mut d = h3
69+
let mut e = h4
70+
let mut f = h5
71+
let mut g = h6
72+
let mut hh = h7
73+
for i = 0; i < 64; i = i + 1 {
74+
let s1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25)
75+
let ch = (e & f) ^ (e.lnot() & g)
76+
let t1 = hh + s1 + ch + sha256_k[i] + w[i]
77+
let s0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22)
78+
let maj = (a & bb) ^ (a & c) ^ (bb & c)
79+
let t2 = s0 + maj
80+
hh = g
81+
g = f
82+
f = e
83+
e = d + t1
84+
d = c
85+
c = bb
86+
bb = a
87+
a = t1 + t2
88+
}
89+
h0 = h0 + a
90+
h1 = h1 + bb
91+
h2 = h2 + c
92+
h3 = h3 + d
93+
h4 = h4 + e
94+
h5 = h5 + f
95+
h6 = h6 + g
96+
h7 = h7 + hh
97+
}
98+
let out = Buffer()
99+
for hv in [h0, h1, h2, h3, h4, h5, h6, h7] {
100+
out.write_byte((hv >> 24).to_byte())
101+
out.write_byte((hv >> 16).to_byte())
102+
out.write_byte((hv >> 8).to_byte())
103+
out.write_byte(hv.to_byte())
104+
}
105+
out.to_bytes()
106+
}
107+
108+
///|
109+
/// HMAC-SHA256 (RFC 2104): a keyed message-authentication code over `sha256`.
110+
/// A key longer than the 64-byte block is hashed first; a shorter key is
111+
/// zero-padded. The message is authenticated as
112+
/// `H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ msg))`. Verified against RFC 4231 test case
113+
/// 2. This is the signature function behind JWT HS256.
114+
pub fn hmac_sha256(key : Bytes, msg : Bytes) -> Bytes {
115+
let block = 64
116+
let k0 = Buffer()
117+
if key.length() > block {
118+
k0.write_bytes(sha256(key)[:])
119+
} else {
120+
k0.write_bytes(key[:])
121+
}
122+
while k0.length() < block {
123+
k0.write_byte(b'\x00')
124+
}
125+
let kb = k0.to_bytes()
126+
let ipad = Buffer()
127+
let opad = Buffer()
128+
for i = 0; i < block; i = i + 1 {
129+
ipad.write_byte((kb[i].to_int() ^ 0x36).to_byte())
130+
opad.write_byte((kb[i].to_int() ^ 0x5c).to_byte())
131+
}
132+
ipad.write_bytes(msg[:])
133+
let inner = sha256(ipad.to_bytes())
134+
opad.write_bytes(inner[:])
135+
sha256(opad.to_bytes())
136+
}
137+
138+
///|
139+
/// A constant-time byte-string equality: it inspects every byte of both inputs
140+
/// regardless of where they first differ, so an attacker cannot recover a valid
141+
/// signature byte-by-byte from response timing. Unequal lengths return `false`
142+
/// immediately (length is not secret). Used to compare JWT signatures.
143+
pub fn constant_time_eq(a : Bytes, b : Bytes) -> Bool {
144+
if a.length() != b.length() {
145+
return false
146+
}
147+
let mut diff = 0
148+
for i = 0; i < a.length(); i = i + 1 {
149+
diff = diff | (a[i].to_int() ^ b[i].to_int())
150+
}
151+
diff == 0
152+
}

0 commit comments

Comments
 (0)