code wiki / (root) / nx_signed_cookie.nx

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}