code wiki / _hdl_build / nx_connect_album_gate.nx

nx_connect_album_gate.nx source

↩ module page · 258 lines · 14745 B

1// nx_connect_album_gate.nx -- the API's regression proof. The dispatcher is the seam where a permission 2// rule most easily gets lost: it is the layer that turns a request into a plane call, so a bug here looks 3// like a routing detail and behaves like an access-control failure. 4// The teeth that matter are the refusals, and the one that matters most is T3: a signed-out caller must 5// not be able to mutate anything, because that is the failure that leaves no trace in the data. 6// license_tier: ORIGINAL expect_exit: 0 7import "nx_connect_album.nx" 8 9const AG_ALICE: i64 = 1001 10const AG_BOB: i64 = 1002 11const AG_CARA: i64 = 1003 12const AG_ALBUM: i64 = 7001 13const AG_OUT: i64 = 8192 14const AG_KIND: *u8 = "album\x00" 15// "no item" cannot be an empty literal: nx_cc returns the NEXT literal in the pool for a zero-length 16// string (seq907), so absence is carried as a one-byte sentinel. 17const AG_NOITEM: *u8 = "-\x00" 18const AG_ITEM1: *u8 = "cid_aaa\x00" 19const AG_ITEM2: *u8 = "cid_bbb\x00" 20 21func a_w(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} sys_write(1,s,n); return 0 } 22func a_n(v: i64) -> i64 { 23 let bb: *u8=sys_mmap(28); var m: i64=v 24 if m<0 { m=0-m; sys_write(1,"-" as *u8,1) } 25 let t: *u8=sys_mmap(28); var k: i64=0 26 if m==0 { t[0]=48 as u8; k=1 } 27 while m>0 { t[k]=(48+(m%10)) as u8; m=m/10; k=k+1 } 28 var i: i64=0 29 while i<k { bb[i]=t[k-1-i]; i=i+1 } 30 sys_write(1,bb,k); return 0 31} 32func a_t(pass: i64, label: *u8, fails: *i64) -> i64 { 33 a_w(" " as *u8); a_w(label); a_w(": " as *u8) 34 if pass==1 { a_w("PASS\n" as *u8) } else { a_w("FAIL\n" as *u8); fails[0]=fails[0]+1 } 35 return 0 36} 37// does the JSON response contain this literal? (the wire contract IS the thing under test) 38func a_has(buf: *u8, needle: *u8) -> i64 { 39 let hl: i64 = rb_slen(buf); let nl: i64 = rb_slen(needle) 40 if nl==0 { return 0 } 41 var i: i64=0 42 while i+nl<=hl { 43 var j: i64=0; var hit: i64=1 44 while j<nl { if buf[i+j]!=needle[j] { hit=0; j=nl } else { j=j+1 } } 45 if hit==1 { return 1 } 46 i=i+1 47 } 48 return 0 49} 50func px(tag: i64) -> *u8 { 51 let p: *u8 = sys_mmap(128); var o: i64 = 0 52 o = rb_cat(p, o, "/tmp/algate_" as *u8); o = rb_catn(p, o, sys_now_us()) 53 o = rb_cat(p, o, "_" as *u8); o = rb_catn(p, o, tag); o = rb_cat(p, o, "_" as *u8) 54 p[o] = 0 as u8 55 return p 56} 57 58// The containment teeth live in their own function: main() had grown large enough that adding them 59// segfaulted the GATE while nx_album_probe ran the identical calls clean. A test harness that falls 60// over under its own weight will be misread as a failure of the thing it tests. 61func containment_teeth(out: *u8, fails: *i64) -> i64 { 62 // ---- CONTAINMENT: an album is only a product once it holds something ---- 63 64 // T11 the owner puts items in and reads them back; a plain VIEWER can read them but must NOT add. 65 // "May look, may not change what is in it" is the whole difference between a viewer and a curator. 66 let p11: *u8 = px(11) 67 al_do(p11, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 68 al_do(p11, "add" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_ITEM1, out, AG_OUT) 69 let a1: i64 = a_has(out, "\x22added\x22:1" as *u8) 70 al_do(p11, "add" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_ITEM2, out, AG_OUT) 71 al_do(p11, "share" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 72 al_do(p11, "items" as *u8, AG_BOB, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 73 a_w(" -> " as *u8); a_w(out); a_w("\n" as *u8) 74 let sees: i64 = a_has(out, "item:cid_aaa" as *u8) 75 let sees2: i64 = a_has(out, "item:cid_bbb" as *u8) 76 al_do(p11, "add" as *u8, AG_BOB, 0, AG_KIND, AG_ALBUM, "cid_evil\x00" as *u8, out, AG_OUT) 77 let blocked: i64 = a_has(out, "not_permitted" as *u8) 78 var t11: i64=0 79 if a1==1 { if sees==1 { if sees2==1 { if blocked==1 { t11=1 } } } } 80 a_t(t11, "T11 a shared viewer SEES the items and CANNOT add one" as *u8, fails) 81 82 // T12 THE PRIVACY TOOTH: a stranger asking for an album's contents gets the SAME refusal whether the 83 // album is not theirs or does not exist. If those two answers differ, the API is an enumeration oracle 84 // and anyone can map the family's albums by probing ids. 85 let p12: *u8 = px(12) 86 al_do(p12, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 87 al_do(p12, "add" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_ITEM1, out, AG_OUT) 88 al_do(p12, "items" as *u8, AG_CARA, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 89 let denied_real: i64 = a_has(out, "not_permitted" as *u8) 90 let leaked: i64 = a_has(out, "cid_aaa" as *u8) 91 al_do(p12, "items" as *u8, AG_CARA, 0, AG_KIND, 999999, AG_NOITEM, out, AG_OUT) 92 let denied_ghost: i64 = a_has(out, "not_permitted" as *u8) 93 var t12: i64=0 94 if denied_real==1 { if denied_ghost==1 { if leaked==0 { t12=1 } } } 95 a_t(t12, "T12 PRIVACY an existing-but-unshared album and a nonexistent one are INDISTINGUISHABLE, and no item leaks" as *u8, fails) 96 97 // T13 a curator may add (that is what curator means), and removal tombstones rather than erases 98 let p13: *u8 = px(13) 99 al_do(p13, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 100 al_do(p13, "curate" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 101 al_do(p13, "add" as *u8, AG_BOB, 0, AG_KIND, AG_ALBUM, AG_ITEM1, out, AG_OUT) 102 let cadd: i64 = a_has(out, "\x22added\x22:1" as *u8) 103 al_do(p13, "add" as *u8, AG_BOB, 0, AG_KIND, AG_ALBUM, AG_ITEM2, out, AG_OUT) 104 al_do(p13, "remove" as *u8, AG_BOB, 0, AG_KIND, AG_ALBUM, AG_ITEM1, out, AG_OUT) 105 al_do(p13, "items" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 106 a_w(" -> " as *u8); a_w(out); a_w("\n" as *u8) 107 var t13: i64=0 108 if cadd==1 { if a_has(out, "cid_bbb" as *u8)==1 { if a_has(out, "cid_aaa" as *u8)==0 { t13=1 } } } 109 a_t(t13, "T13 a curator can add, and a removed item stops being listed" as *u8, fails) 110 111 // T14 an add with NO item is refused rather than writing a blank membership row 112 let p14: *u8 = px(14) 113 al_do(p14, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 114 al_do(p14, "add" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 115 let badarg: i64 = a_has(out, "bad_arg" as *u8) 116 al_do(p14, "items" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 117 var t14: i64=0 118 if badarg==1 { if a_has(out, "\x22items\x22:[]" as *u8)==1 { t14=1 } } 119 a_t(t14, "T14 an add with no item is refused and writes NOTHING" as *u8, fails) 120 121 return 0 122} 123 124func main() -> i64 { 125 let fails: *i64 = sys_mmap(16) as *i64 126 fails[0]=0 127 let out: *u8 = sys_mmap(AG_OUT) 128 a_w("=== nx_connect_album_gate -- the sharing API is judged by what it refuses ===\n" as *u8) 129 130 // T1 an unknown verb is refused by name, not silently ignored 131 let p1: *u8 = px(1) 132 al_do(p1, "teleport" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 133 a_w(" -> " as *u8); a_w(out); a_w("\n" as *u8) 134 a_t(a_has(out, "unknown_verb" as *u8), "T1 an unknown verb is refused BY NAME" as *u8, fails) 135 136 // T2 claim then share then may -- the happy path exists at all 137 let p2: *u8 = px(2) 138 al_do(p2, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 139 let c_ok: i64 = a_has(out, "\x22claimed\x22:1" as *u8) 140 al_do(p2, "share" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 141 let s_ok: i64 = a_has(out, "\x22shared\x22:1" as *u8) 142 al_do(p2, "may" as *u8, AG_BOB, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 143 let m_ok: i64 = a_has(out, "\x22may\x22:1" as *u8) 144 var t2: i64=0 145 if c_ok==1 { if s_ok==1 { if m_ok==1 { t2=1 } } } 146 a_t(t2, "T2 claim -> share -> may is a working loop over the canonical plane" as *u8, fails) 147 148 // T3 THE ONE THAT MATTERS: a signed-out caller (actor 0) may not mutate. Every mutating verb, not a 149 // sample of them -- a gate that checks one of five refusals proves one of five refusals. 150 let p3: *u8 = px(3) 151 al_do(p3, "claim" as *u8, 0, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 152 let r1: i64 = a_has(out, "sign_in_required" as *u8) 153 al_do(p3, "share" as *u8, 0, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 154 let r2: i64 = a_has(out, "sign_in_required" as *u8) 155 al_do(p3, "curate" as *u8, 0, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 156 let r3: i64 = a_has(out, "sign_in_required" as *u8) 157 al_do(p3, "unshare" as *u8, 0, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 158 let r4: i64 = a_has(out, "sign_in_required" as *u8) 159 al_do(p3, "gift" as *u8, 0, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 160 let r5: i64 = a_has(out, "sign_in_required" as *u8) 161 var t3: i64=0 162 if r1==1 { if r2==1 { if r3==1 { if r4==1 { if r5==1 { t3=1 } } } } } 163 a_t(t3, "T3 a signed-out caller is refused by ALL FIVE mutating verbs" as *u8, fails) 164 165 // T3b NON-VACUITY: prove the signed-out refusal actually prevented the mutation, rather than merely 166 // printing a refusal while the write went through anyway. 167 al_do(p3, "may" as *u8, AG_BOB, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 168 a_t(a_has(out, "\x22may\x22:0" as *u8), 169 "T3b NON-VACUOUS the refused signed-out writes left NOTHING behind" as *u8, fails) 170 171 // T4 sharing an unclaimed album is refused, and the reason is nameable 172 let p4: *u8 = px(4) 173 al_do(p4, "share" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 174 a_t(a_has(out, "unclaimed" as *u8), "T4 sharing an UNCLAIMED album is refused as 'unclaimed'" as *u8, fails) 175 176 // T5 ATTACK a plain viewer re-shares -> not_owner, and the third party still cannot see it 177 let p5: *u8 = px(5) 178 al_do(p5, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 179 al_do(p5, "share" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 180 al_do(p5, "share" as *u8, AG_BOB, AG_CARA, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 181 let esc: i64 = a_has(out, "not_owner" as *u8) 182 al_do(p5, "may" as *u8, AG_CARA, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 183 let leak: i64 = a_has(out, "\x22may\x22:0" as *u8) 184 var t5: i64=0 185 if esc==1 { if leak==1 { t5=1 } } 186 a_t(t5, "T5 ATTACK a viewer cannot re-share AND the third party still sees nothing" as *u8, fails) 187 188 // T6 a curator CAN re-share -- the delegated capability that survived the plane retirement 189 let p6: *u8 = px(6) 190 al_do(p6, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 191 al_do(p6, "curate" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 192 al_do(p6, "share" as *u8, AG_BOB, AG_CARA, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 193 let cok: i64 = a_has(out, "\x22shared\x22:1" as *u8) 194 al_do(p6, "may" as *u8, AG_CARA, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 195 var t6: i64=0 196 if cok==1 { if a_has(out, "\x22may\x22:1" as *u8)==1 { t6=1 } } 197 a_t(t6, "T6 a CURATOR can re-share (the delegated capability survived the retirement)" as *u8, fails) 198 199 // T7 unshare is immediate and the viewer panel forgets them 200 let p7: *u8 = px(7) 201 al_do(p7, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 202 al_do(p7, "share" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 203 al_do(p7, "share" as *u8, AG_ALICE, AG_CARA, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 204 al_do(p7, "unshare" as *u8, AG_ALICE, AG_CARA, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 205 al_do(p7, "may" as *u8, AG_CARA, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 206 let gone: i64 = a_has(out, "\x22may\x22:0" as *u8) 207 al_do(p7, "viewers" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 208 a_w(" -> " as *u8); a_w(out); a_w("\n" as *u8) 209 var t7: i64=0 210 if gone==1 { if a_has(out, "user:1002" as *u8)==1 { if a_has(out, "user:1003" as *u8)==0 { t7=1 } } } 211 a_t(t7, "T7 unshare is immediate and the viewer list forgets the revoked person" as *u8, fails) 212 213 // T8 a gift moves ownership and the chain is readable over the wire 214 let p8: *u8 = px(8) 215 al_do(p8, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 216 al_do(p8, "gift" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 217 let g1: i64 = a_has(out, "\x22gifted\x22:1" as *u8) 218 al_do(p8, "gift" as *u8, AG_BOB, AG_CARA, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 219 al_do(p8, "may" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 220 let giver_lost: i64 = a_has(out, "\x22may\x22:0" as *u8) 221 al_do(p8, "provenance" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 222 a_w(" -> " as *u8); a_w(out); a_w("\n" as *u8) 223 var t8: i64=0 224 if g1==1 { if giver_lost==1 { 225 if a_has(out, "user:1001" as *u8)==1 { if a_has(out, "user:1002" as *u8)==1 { 226 if a_has(out, "user:1003" as *u8)==1 { t8=1 } } } 227 } } 228 a_t(t8, "T8 a gift moves ownership and the whole chain is readable over the wire" as *u8, fails) 229 230 // T9 ATTACK a former owner re-gifts what they gave away 231 let p9: *u8 = px(9) 232 al_do(p9, "claim" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 233 al_do(p9, "gift" as *u8, AG_ALICE, AG_BOB, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 234 al_do(p9, "gift" as *u8, AG_ALICE, AG_CARA, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 235 a_t(a_has(out, "not_owner" as *u8), "T9 ATTACK a former owner cannot re-gift what they gave away" as *u8, fails) 236 237 // T10 every response is versioned -- an unversioned API contract cannot be evolved without breaking 238 // somebody silently (rule 19) 239 let p10: *u8 = px(10) 240 al_do(p10, "may" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 241 let v1: i64 = a_has(out, "\x22v\x22:1" as *u8) 242 al_do(p10, "nope" as *u8, AG_ALICE, 0, AG_KIND, AG_ALBUM, AG_NOITEM, out, AG_OUT) 243 let v2: i64 = a_has(out, "\x22v\x22:1" as *u8) 244 var t10: i64=0 245 if v1==1 { if v2==1 { t10=1 } } 246 a_t(t10, "T10 success AND failure responses both carry the contract version" as *u8, fails) 247 248 containment_teeth(out, fails) 249 250 a_w(" fails=" as *u8); a_n(fails[0]); a_w("\n" as *u8) 251 if fails[0]==0 { 252 a_w("VERDICT: verdict=GREEN (14/14 -- signed-out cannot mutate, viewers cannot widen, gifts move and stay traceable)\n" as *u8) 253 sys_exit(0) 254 } 255 a_w("VERDICT: RED\n" as *u8) 256 sys_exit(1) 257 return 1 258}