nx_signed_cookie.nx source
↩ module page · 165 lines · 5527 B
1// signed_cookie.nx -- HMAC-signed tamper-evident cookies.
2//
3// Browser cookies are stored client-side and editable by the user.
4// For session state you typically want the user to HOLD the state
5// but NOT modify it. Signed cookies solve this:
6//
7// cookie = value || \".\" || base64url(HMAC-SHA-256(key, value))
8//
9// On read: split at the last dot, verify HMAC with secret key,
10// reject if tampered.
11//
12// Same idea as Rails / Django signed cookies + Express
13// cookie-parser's signed mode + the session cookie in tens of
14// thousands of production Python apps.
15//
16// Composes hmac.nx (HMAC-SHA-256) + base64.nx (for URL-safe
17// signature bytes) + ct.nx (constant-time compare).
18//
19// Invariants:
20// SC1 Only the PAYLOAD is signed -- key isn't revealed by
21// cookie value. Tampering changes MAC -> rejected.
22// SC2 Verification uses ct_memcmp to eliminate timing leaks.
23// SC3 Base64url without padding keeps cookie URL-safe +
24// single-valued (compatible with cookie attribute grammar).
25// SC4 No expiry here -- cookie's own Max-Age / Expires
26// attribute handles that. Replay-attack defence requires
27// caller to include a timestamp in the payload and enforce
28// bounds on read.
29
30// nx_safety_envelope:
31// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
32// sil_target: SIL1
33// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
34// verdict: NOT_YET_EVALUATED
35
36import "nx_syscalls.nx"
37import "nx_hmac.nx"
38import "nx_base64.nx"
39import "nx_ct.nx"
40
41const SC_ERR_SHORT: i64 = -1
42const SC_ERR_FORMAT: i64 = -2
43const SC_ERR_MAC: i64 = -3
44
45// Compute base64url(HMAC-SHA-256(key, value)) into out. Returns
46// bytes written.
47func sc_sign_bytes(key: *u8, key_len: i64,
48 value: *u8, value_len: i64,
49 out: *u8) -> i64 {
50 let mac: *u8 = sys_mmap(64)
51 hmac_sha256(key, key_len, value, value_len, mac)
52 // base64-encode the 32-byte MAC into a scratch buf, then patch
53 // to url-safe alphabet + strip padding directly into out.
54 let scratch: *u8 = sys_mmap(64)
55 let b64_len: i64 = b64_encode(mac, 32, scratch)
56 var stripped: i64 = b64_len
57 while stripped > 0 {
58 if scratch[stripped - 1] != 0x3D { break }
59 stripped = stripped - 1
60 }
61 var i: i64 = 0
62 while i < stripped {
63 var c: i64 = scratch[i]
64 if c == 0x2B { c = 0x2D } // '+' -> '-'
65 if c == 0x2F { c = 0x5F } // '/' -> '_'
66 out[i] = c
67 i = i + 1
68 }
69 return stripped
70}
71
72// Build "value.sig". Returns total length written.
73func signed_cookie_sign(value: *u8, value_len: i64,
74 key: *u8, key_len: i64,
75 out: *u8, cap: i64) -> i64 {
76 if cap < value_len + 1 + 48 { return SC_ERR_SHORT }
77 var i: i64 = 0
78 while i < value_len {
79 out[i] = value[i]
80 i = i + 1
81 }
82 out[value_len] = 0x2E // '.'
83 let sig_len: i64 = sc_sign_bytes(key, key_len,
84 value, value_len,
85 out + value_len + 1)
86 return value_len + 1 + sig_len
87}
88
89// Verify + extract the payload. On success writes (value_off,
90// value_len) of the original payload into caller's outputs.
91// Returns 0 on success, negative on tamper / format error.
92func signed_cookie_verify(cookie: *u8, n: i64,
93 key: *u8, key_len: i64,
94 value_off_out: *i64,
95 value_len_out: *i64) -> i64 {
96 // Find LAST '.' so values containing '.' work (common for
97 // json-encoded payloads).
98 var dot: i64 = -1
99 var i: i64 = 0
100 while i < n {
101 if cookie[i] == 0x2E { dot = i }
102 i = i + 1
103 }
104 if dot < 0 { return SC_ERR_FORMAT }
105
106 let value_len: i64 = dot
107 let sig_off: i64 = dot + 1
108 let sig_len: i64 = n - sig_off
109
110 // Recompute expected sig from payload.
111 let expected: *u8 = sys_mmap(64)
112 let exp_len: i64 = sc_sign_bytes(key, key_len,
113 cookie, value_len,
114 expected)
115 if exp_len != sig_len { return SC_ERR_MAC }
116 if ct_memcmp(cookie + sig_off, expected, sig_len) != 0 {
117 return SC_ERR_MAC
118 }
119
120 *value_off_out = 0
121 *value_len_out = value_len
122 return 0
123}
124
125// Compile-only smoke.
126func main() -> i64 {
127 let key: *u8 = "supersecret"
128 let value: *u8 = "user=elder;role=admin"
129
130 let signed: *u8 = sys_mmap(256)
131 let n: i64 = signed_cookie_sign(value, 21, key, 11, signed, 256)
132 if n <= 21 { return 1 }
133
134 // There's exactly one dot (the signature separator) because
135 // our test value has no '.'.
136 var dots: i64 = 0
137 var i: i64 = 0
138 while i < n {
139 if signed[i] == 0x2E { dots = dots + 1 }
140 i = i + 1
141 }
142 if dots != 1 { return 2 }
143
144 // Verify.
145 let vo: *i64 = (sys_mmap(16)) as *i64
146 let vl: *i64 = (sys_mmap(16)) as *i64
147 if signed_cookie_verify(signed, n, key, 11, vo, vl) != 0 {
148 return 3
149 }
150 if *vl != 21 { return 4 }
151
152 // Tamper the last byte of the signature.
153 signed[n - 1] = signed[n - 1] ^ 1
154 if signed_cookie_verify(signed, n, key, 11, vo, vl) != SC_ERR_MAC {
155 return 5
156 }
157
158 // Tamper the payload.
159 signed[n - 1] = signed[n - 1] ^ 1 // restore sig
160 signed[0] = signed[0] ^ 1
161 if signed_cookie_verify(signed, n, key, 11, vo, vl) != SC_ERR_MAC {
162 return 6
163 }
164 return 0
165}