code wiki / (root) / nx_signed_cookie.nx

nx_signed_cookie.nx source

↩ module page · 225 lines · 10023 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) != 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}