code wiki / (root) / signed_cookie.nx

signed_cookie.nx source

↩ module page · 161 lines · 5553 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 30import "syscalls.nx" 31import "nx_hmac.nx" // was hmac.nx -- CODE-IDENTICAL twin (49/49 stmts) on the LEGACY syscalls.nx+sha256.nx family. 32// Two files defining hmac_sha256 + main, with the expander deduping BY PATH NOT BY SYMBOL, made 33// every legacy importer a duplicate-symbol landmine for the nx_ family (debt 1785524913). 34import "base64.nx" 35import "ct.nx" 36 37const SC_ERR_SHORT: i64 = -1 38const SC_ERR_FORMAT: i64 = -2 39const SC_ERR_MAC: i64 = -3 40 41// Compute base64url(HMAC-SHA-256(key, value)) into out. Returns 42// bytes written. 43func sc_sign_bytes(key: *u8, key_len: i64, 44 value: *u8, value_len: i64, 45 out: *u8) -> i64 { 46 let mac: *u8 = sys_mmap(64) 47 hmac_sha256(key, key_len, value, value_len, mac) 48 // base64-encode the 32-byte MAC into a scratch buf, then patch 49 // to url-safe alphabet + strip padding directly into out. 50 let scratch: *u8 = sys_mmap(64) 51 let b64_len: i64 = b64_encode(mac, 32, scratch) 52 var stripped: i64 = b64_len 53 while stripped > 0 { 54 if scratch[stripped - 1] != 0x3D { break } 55 stripped = stripped - 1 56 } 57 var i: i64 = 0 58 while i < stripped { 59 var c: i64 = scratch[i] 60 if c == 0x2B { c = 0x2D } // '+' -> '-' 61 if c == 0x2F { c = 0x5F } // '/' -> '_' 62 out[i] = c 63 i = i + 1 64 } 65 return stripped 66} 67 68// Build "value.sig". Returns total length written. 69func signed_cookie_sign(value: *u8, value_len: i64, 70 key: *u8, key_len: i64, 71 out: *u8, cap: i64) -> i64 { 72 if cap < value_len + 1 + 48 { return SC_ERR_SHORT } 73 var i: i64 = 0 74 while i < value_len { 75 out[i] = value[i] 76 i = i + 1 77 } 78 out[value_len] = 0x2E // '.' 79 let sig_len: i64 = sc_sign_bytes(key, key_len, 80 value, value_len, 81 out + value_len + 1) 82 return value_len + 1 + sig_len 83} 84 85// Verify + extract the payload. On success writes (value_off, 86// value_len) of the original payload into caller's outputs. 87// Returns 0 on success, negative on tamper / format error. 88func signed_cookie_verify(cookie: *u8, n: i64, 89 key: *u8, key_len: i64, 90 value_off_out: *i64, 91 value_len_out: *i64) -> i64 { 92 // Find LAST '.' so values containing '.' work (common for 93 // json-encoded payloads). 94 var dot: i64 = -1 95 var i: i64 = 0 96 while i < n { 97 if cookie[i] == 0x2E { dot = i } 98 i = i + 1 99 } 100 if dot < 0 { return SC_ERR_FORMAT } 101 102 let value_len: i64 = dot 103 let sig_off: i64 = dot + 1 104 let sig_len: i64 = n - sig_off 105 106 // Recompute expected sig from payload. 107 let expected: *u8 = sys_mmap(64) 108 let exp_len: i64 = sc_sign_bytes(key, key_len, 109 cookie, value_len, 110 expected) 111 if exp_len != sig_len { return SC_ERR_MAC } 112 if ct_memcmp(cookie + sig_off, expected, sig_len) != 0 { 113 return SC_ERR_MAC 114 } 115 116 *value_off_out = 0 117 *value_len_out = value_len 118 return 0 119} 120 121// Compile-only smoke. 122func main() -> i64 { 123 let key: *u8 = "supersecret" 124 let value: *u8 = "user=elder;role=admin" 125 126 let signed: *u8 = sys_mmap(256) 127 let n: i64 = signed_cookie_sign(value, 21, key, 11, signed, 256) 128 if n <= 21 { return 1 } 129 130 // There's exactly one dot (the signature separator) because 131 // our test value has no '.'. 132 var dots: i64 = 0 133 var i: i64 = 0 134 while i < n { 135 if signed[i] == 0x2E { dots = dots + 1 } 136 i = i + 1 137 } 138 if dots != 1 { return 2 } 139 140 // Verify. 141 let vo: *i64 = (sys_mmap(16)) as *i64 142 let vl: *i64 = (sys_mmap(16)) as *i64 143 if signed_cookie_verify(signed, n, key, 11, vo, vl) != 0 { 144 return 3 145 } 146 if *vl != 21 { return 4 } 147 148 // Tamper the last byte of the signature. 149 signed[n - 1] = signed[n - 1] ^ 1 150 if signed_cookie_verify(signed, n, key, 11, vo, vl) != SC_ERR_MAC { 151 return 5 152 } 153 154 // Tamper the payload. 155 signed[n - 1] = signed[n - 1] ^ 1 // restore sig 156 signed[0] = signed[0] ^ 1 157 if signed_cookie_verify(signed, n, key, 11, vo, vl) != SC_ERR_MAC { 158 return 6 159 } 160 return 0 161}