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}