nx_signed_cookie_candidate_t187.nx source
↩ module page · 225 lines · 10046 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_workspace_adapter_t187.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) != 1 {
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}
166
167// Caller-owned signing and verification scratch. No allocation occurs below.
168const SC_SIGNATURE_BYTES:i64=(HMAC_HASH*8+5)/6
169const SC_BASE64_BYTES:i64=((HMAC_HASH+2)/3)*4
170func sc_sign_prefix_bytes()->i64{
171 return ((HMAC_HASH+SC_BASE64_BYTES+SHA256_WORD_ALIGN-1)/SHA256_WORD_ALIGN)*SHA256_WORD_ALIGN
172}
173func sc_sign_workspace_bytes()->i64{return sc_sign_prefix_bytes()+hmac_sha256_workspace_bytes()}
174func sc_workspace_valid(key:*u8,kn:i64,value:*u8,vn:i64,out:*u8,on:i64,workspace:*u8,capacity:i64)->i64{
175 let base:i64=workspace as i64;let target:i64=out as i64;let needed:i64=sc_sign_workspace_bytes()
176 if sha256_checked_input(key,kn,out)!=1 || sha256_checked_input(value,vn,out)!=1{return HMAC_E_INPUT}
177 if on<0 || target>SHA256_SIGNED_MAX-on{return HMAC_E_INPUT}
178 if base<=0 || capacity<needed || base>SHA256_SIGNED_MAX-needed || base%SHA256_WORD_ALIGN!=0{return HMAC_E_WORKSPACE}
179 if sha256_ranges_overlap(base,needed,key as i64,kn)==1 || sha256_ranges_overlap(base,needed,value as i64,vn)==1 || sha256_ranges_overlap(base,needed,target,on)==1{return HMAC_E_WORKSPACE}
180 return 0
181}
182func sc_sign_bytes_workspace(key:*u8,key_len:i64,value:*u8,value_len:i64,out:*u8,out_cap:i64,workspace:*u8,capacity:i64)->i64{
183 if out_cap<SC_SIGNATURE_BYTES{return SC_ERR_SHORT}
184 let valid:i64=sc_workspace_valid(key,key_len,value,value_len,out,SC_SIGNATURE_BYTES,workspace,capacity);if valid!=0{return valid}
185 let mac:*u8=workspace;let encoded:*u8=workspace+HMAC_HASH;let prefix:i64=sc_sign_prefix_bytes()
186 let rc:i64=hmac_sha256_workspace(key,key_len,value,value_len,mac,workspace+prefix,capacity-prefix)
187 if rc!=0{return rc}
188 let n:i64=b64_encode(mac,HMAC_HASH,encoded);var end:i64=n
189 while end>0 && encoded[end-1]==61 as u8{end=end-1}
190 if end!=SC_SIGNATURE_BYTES{return SC_ERR_FORMAT}
191 var i:i64=0;while i<end{var ch:u8=encoded[i];if ch==43 as u8{ch=45 as u8};if ch==47 as u8{ch=95 as u8};out[i]=ch;i=i+1}
192 i=0;while i<prefix{workspace[i]=0 as u8;i=i+1}
193 return end
194}
195func signed_cookie_sign_workspace(value:*u8,value_len:i64,key:*u8,key_len:i64,out:*u8,cap:i64,workspace:*u8,capacity:i64)->i64{
196 if value_len<0 || value_len>SHA256_SIGNED_MAX-1-SC_SIGNATURE_BYTES{return SC_ERR_SHORT}
197 let total:i64=value_len+1+SC_SIGNATURE_BYTES;if cap<total{return SC_ERR_SHORT}
198 let valid:i64=sc_workspace_valid(key,key_len,value,value_len,out,total,workspace,capacity);if valid!=0{return valid}
199 // Exact in-place payload is supported; other overlap can destroy uncopied bytes.
200 if out!=value && sha256_ranges_overlap(out as i64,total,value as i64,value_len)==1{return HMAC_E_INPUT}
201 if sha256_ranges_overlap(out as i64,total,key as i64,key_len)==1{return HMAC_E_INPUT}
202 let sn:i64=sc_sign_bytes_workspace(key,key_len,value,value_len,out+value_len+1,cap-value_len-1,workspace,capacity)
203 if sn<0{return sn}
204 var i:i64=0;while i<value_len{out[i]=value[i];i=i+1};out[value_len]=46 as u8
205 return total
206}
207func sc_verify_workspace_bytes()->i64{
208 return ((SC_SIGNATURE_BYTES+SHA256_WORD_ALIGN-1)/SHA256_WORD_ALIGN)*SHA256_WORD_ALIGN+sc_sign_workspace_bytes()
209}
210func signed_cookie_verify_workspace(cookie:*u8,n:i64,key:*u8,key_len:i64,value_len_out:*i64,workspace:*u8,capacity:i64)->i64{
211 let base:i64=workspace as i64;let needed:i64=sc_verify_workspace_bytes();let vp:i64=value_len_out as i64
212 if n<1 || sha256_checked_input(cookie,n,workspace)!=1 || sha256_checked_input(key,key_len,workspace)!=1 || vp<=0 || vp>SHA256_SIGNED_MAX-SHA256_WIDE_WORD{return SC_ERR_FORMAT}
213 if vp%SHA256_WORD_ALIGN!=0 || sha256_ranges_overlap(vp,SHA256_WIDE_WORD,cookie as i64,n)==1 || sha256_ranges_overlap(vp,SHA256_WIDE_WORD,key as i64,key_len)==1{return HMAC_E_INPUT}
214 if base<=0 || capacity<needed || base>SHA256_SIGNED_MAX-needed || base%SHA256_WORD_ALIGN!=0{return HMAC_E_WORKSPACE}
215 if sha256_ranges_overlap(base,needed,cookie as i64,n)==1 || sha256_ranges_overlap(base,needed,key as i64,key_len)==1 || sha256_ranges_overlap(base,needed,vp,SHA256_WIDE_WORD)==1{return HMAC_E_WORKSPACE}
216 var dot:i64=0-1;var i:i64=0;while i<n{if cookie[i]==46 as u8{dot=i};i=i+1}
217 if dot<0 || n-dot-1!=SC_SIGNATURE_BYTES{return SC_ERR_FORMAT}
218 let prefix:i64=((SC_SIGNATURE_BYTES+SHA256_WORD_ALIGN-1)/SHA256_WORD_ALIGN)*SHA256_WORD_ALIGN
219 let rc:i64=sc_sign_bytes_workspace(key,key_len,cookie,dot,workspace,SC_SIGNATURE_BYTES,workspace+prefix,capacity-prefix)
220 if rc<0{return rc}
221 let same:i64=ct_memcmp(cookie+dot+1,workspace,SC_SIGNATURE_BYTES)
222 i=0;while i<prefix{workspace[i]=0 as u8;i=i+1}
223 if same!=1{return SC_ERR_MAC}
224 *value_len_out=dot;return 0
225}