code wiki / (root) / nx_email_auth.nx

nx_email_auth.nx source

↩ module page · 221 lines · 9138 B

1// nx_email_auth.nx -- EMAIL RUNG R5: sender authentication / anti-spoof. 2// 3// module: nishi-core.email.auth 4// depends: nishi-core.crypto.ed25519_signature, nishi-core.crypto.sha256 5// capability: CORE_EMAIL 6// 7// The trust layer that decides whether a message genuinely comes from 8// the domain it claims. Three standards, composed from existing 9// sovereign crypto (nx_sha256 + the REAL ed25519 sign/verify in 10// nx_ed25519_signature) -- nothing re-implemented: 11// 12// DKIM (RFC 6376 + RFC 8463 ed25519-sha256): body hash = SHA-256 of 13// the "simple"-canonicalised body (trailing empty lines folded 14// to one CRLF); the canonicalised signed-header set is hashed 15// with SHA-256 and that digest is signed/verified with Ed25519. 16// A flipped body byte changes bh=; a flipped signed header 17// fails the signature. 18// SPF (RFC 7208): evaluate "v=spf1 ip4:.../cidr ... -all" against a 19// connecting IPv4 -> PASS / FAIL / SOFTFAIL / NEUTRAL / NONE. 20// DMARC (RFC 7489): parse "v=DMARC1; p=..." -> none/quarantine/reject 21// (the "p=" tag, boundary-anchored so "sp=" never matches). 22// 23// SCOPE (honest, no over-claim): SPF supports ip4 + all (include:/a/mx 24// recursion and relaxed DKIM canonicalisation + RFC 2047 are later 25// sub-rungs). Per the no-wave law this rung is graded on the crypto 26// round-trip + parse correctness + tamper rejection, NOT byte-for-byte 27// interop with a third-party verifier yet. 28// 29// license_tier: INDEPENDENT_REDERIVE 30// genealogy_id: international-research-sources/ietf/rfc_6376 + rfc_8463 + rfc_7208 + rfc_7489 31// lineage_id: nishi_email_auth_r5 32// 33// nx_safety_envelope: 34// intended_use: "DKIM ed25519-sha256 sign/verify + body hash; 35// SPF ip4 eval; DMARC policy parse. Anti-spoof." 36// sil_target: SIL3 (a verify bug accepts forged mail) 37// evidence: [composes_real_ed25519+sha256, dkim_roundtrip, 38// body_canon_idempotent, tamper_rejected, 39// spf_cidr_match, dmarc_boundary_anchored] 40// hazard_register: [bug-tape-DKIM-forgery-accept, 41// bug-tape-SPF-cidr-offbyone] 42// residual_risk: "Key authenticity (selector TXT fetch via 43// nx_dns) + relaxed canon are upstream/later." 44// verdict: NOT_YET_EVALUATED 45 46import "nx_ed25519_signature.nx" 47import "nx_sha256.nx" 48import "nx_syscalls.nx" 49 50// ---- SPF result enum ---- 51const NX_SPF_NONE: i64 = 0 52const NX_SPF_PASS: i64 = 1 53const NX_SPF_FAIL: i64 = 2 54const NX_SPF_SOFTFAIL: i64 = 3 55const NX_SPF_NEUTRAL: i64 = 4 56 57// ---- DMARC policy enum ---- 58const NX_DMARC_NOREC: i64 = 0 59const NX_DMARC_NONE: i64 = 1 60const NX_DMARC_QUARANTINE: i64 = 2 61const NX_DMARC_REJECT: i64 = 3 62 63func nx_spf_name(v: i64) -> *u8 { 64 if v == NX_SPF_PASS { return "PASS" } 65 if v == NX_SPF_FAIL { return "FAIL" } 66 if v == NX_SPF_SOFTFAIL { return "SOFTFAIL" } 67 if v == NX_SPF_NEUTRAL { return "NEUTRAL" } 68 return "NONE" 69} 70func nx_dmarc_name(v: i64) -> *u8 { 71 if v == NX_DMARC_NONE { return "none" } 72 if v == NX_DMARC_QUARANTINE { return "quarantine" } 73 if v == NX_DMARC_REJECT { return "reject" } 74 return "norecord" 75} 76 77// ---- helpers ---- 78func ea_match(a: *u8, b: *u8, len: i64) -> i64 { 79 var i: i64 = 0 80 while i < len { if (a[i] & 0xff) != (b[i] & 0xff) { return 0 } i = i + 1 } 81 return 1 82} 83 84// ---- DKIM ------------------------------------------------------------ 85 86// Simple body canonicalization (RFC 6376 ยง3.4.3) then SHA-256. 87// Folds all trailing CRLFs to exactly one. Writes 32-byte digest to 88// out32. Returns canonical length (>= 2) or -1 on scratch overflow. 89func nx_dkim_body_hash(body: *u8, n: i64, scratch: *u8, scap: i64, out32: *u8) -> i64 { 90 var e: i64 = n 91 while e >= 2 && (body[e - 2] & 0xff) == 13 && (body[e - 1] & 0xff) == 10 { e = e - 2 } 92 if e + 2 > scap { return 0 - 1 } 93 var i: i64 = 0 94 while i < e { scratch[i] = body[i]; i = i + 1 } 95 scratch[e] = 13 as u8 96 scratch[e + 1] = 10 as u8 97 let canlen: i64 = e + 2 98 sha256_digest(scratch, canlen, out32) 99 return canlen 100} 101 102// Sign the canonicalized signed-header set: h = SHA-256(headers), then 103// Ed25519-sign h (RFC 8463 ed25519-sha256). sig64 receives 64 bytes. 104func nx_dkim_sign(headers: *u8, hn: i64, priv32: *u8, sig64: *u8) -> i64 { 105 let h: *u8 = sys_mmap(32) 106 sha256_digest(headers, hn, h) 107 return ed25519_sign_full(priv32, h, 32, sig64) 108} 109 110// Verify a DKIM ed25519-sha256 header signature. Returns 1 valid, 0 not. 111func nx_dkim_verify(headers: *u8, hn: i64, pub32: *u8, sig64: *u8) -> i64 { 112 let h: *u8 = sys_mmap(32) 113 sha256_digest(headers, hn, h) 114 let v: i64 = ed25519_verify_full(pub32, h, 32, sig64) 115 if v == 1 { return 1 } 116 return 0 117} 118 119// ---- SPF ------------------------------------------------------------- 120 121func spf_qual(q: i64) -> i64 { 122 if q == 45 { return NX_SPF_FAIL } // '-' 123 if q == 126 { return NX_SPF_SOFTFAIL } // '~' 124 if q == 63 { return NX_SPF_NEUTRAL } // '?' 125 return NX_SPF_PASS // '+' (default) 126} 127 128// Parse "a.b.c.d" or "a.b.c.d/bits" -> packed BE net + mask. 1 ok / 0 bad. 129func spf_parse_ip4(s: *u8, slen: i64, net_p: *i64, mask_p: *i64) -> i64 { 130 var a: i64 = 0; var b: i64 = 0; var c: i64 = 0; var d: i64 = 0 131 var part: i64 = 0; var cur: i64 = 0; var bits: i64 = 32; var slash: i64 = 0 132 var i: i64 = 0 133 while i < slen { 134 let ch: i64 = s[i] & 0xff 135 if ch >= 48 && ch <= 57 { 136 cur = cur * 10 + (ch - 48) 137 } else { 138 if ch == 46 { 139 if slash == 1 { return 0 } 140 if part == 0 { a = cur } else { if part == 1 { b = cur } else { if part == 2 { c = cur } else { return 0 } } } 141 part = part + 1; cur = 0 142 } else { 143 if ch == 47 { 144 if part != 3 { return 0 } 145 d = cur; slash = 1; cur = 0 146 } else { return 0 } 147 } 148 } 149 i = i + 1 150 } 151 if slash == 1 { bits = cur } else { if part != 3 { return 0 } d = cur } 152 if a > 255 || b > 255 || c > 255 || d > 255 { return 0 } 153 if bits < 0 || bits > 32 { return 0 } 154 *net_p = (a << 24) | (b << 16) | (c << 8) | d 155 if bits == 0 { *mask_p = 0 } else { *mask_p = (0xFFFFFFFF << (32 - bits)) & 0xFFFFFFFF } 156 return 1 157} 158 159// Evaluate an SPF record against a packed-BE IPv4. ip4/all only. 160func nx_spf_eval(rec: *u8, rlen: i64, ip4: i64) -> i64 { 161 if rlen < 6 { return NX_SPF_NONE } 162 if ea_match(rec, "v=spf1" as *u8, 6) == 0 { return NX_SPF_NONE } 163 var i: i64 = 6 164 var result: i64 = NX_SPF_NEUTRAL 165 var decided: i64 = 0 166 while i < rlen && decided == 0 { 167 while i < rlen && (rec[i] & 0xff) == 32 { i = i + 1 } 168 if i < rlen { 169 let ts: i64 = i 170 while i < rlen && (rec[i] & 0xff) != 32 { i = i + 1 } 171 let te: i64 = i 172 let tl: i64 = te - ts 173 if tl > 0 { 174 var q: i64 = 43 175 var ms: i64 = ts 176 let c0: i64 = rec[ts] & 0xff 177 if c0 == 43 || c0 == 45 || c0 == 126 || c0 == 63 { q = c0; ms = ts + 1 } 178 let mlen: i64 = te - ms 179 if mlen == 3 && ea_match(rec + ms, "all" as *u8, 3) == 1 { 180 result = spf_qual(q); decided = 1 181 } else { 182 if mlen > 4 && ea_match(rec + ms, "ip4:" as *u8, 4) == 1 { 183 var net: i64 = 0 184 var mask: i64 = 0 185 if spf_parse_ip4(rec + ms + 4, mlen - 4, &net, &mask) == 1 { 186 if (ip4 & mask) == (net & mask) { result = spf_qual(q); decided = 1 } 187 } 188 } 189 } 190 } 191 } 192 } 193 return result 194} 195 196// ---- DMARC ----------------------------------------------------------- 197 198// Parse the DMARC "p=" policy tag (boundary-anchored so "sp=" is ignored). 199func nx_dmarc_policy(rec: *u8, rlen: i64) -> i64 { 200 if rlen < 8 { return NX_DMARC_NOREC } 201 if ea_match(rec, "v=DMARC1" as *u8, 8) == 0 { return NX_DMARC_NOREC } 202 var i: i64 = 8 203 while i < rlen { 204 if (rec[i] & 0xff) == 112 && i + 1 < rlen && (rec[i + 1] & 0xff) == 61 { 205 let prev: i64 = rec[i - 1] & 0xff 206 if prev == 59 || prev == 32 { 207 var vs: i64 = i + 2 208 while vs < rlen && (rec[vs] & 0xff) == 32 { vs = vs + 1 } 209 var ve: i64 = vs 210 while ve < rlen && (rec[ve] & 0xff) != 59 && (rec[ve] & 0xff) != 32 { ve = ve + 1 } 211 let vl: i64 = ve - vs 212 if vl == 4 && ea_match(rec + vs, "none" as *u8, 4) == 1 { return NX_DMARC_NONE } 213 if vl == 10 && ea_match(rec + vs, "quarantine" as *u8, 10) == 1 { return NX_DMARC_QUARANTINE } 214 if vl == 6 && ea_match(rec + vs, "reject" as *u8, 6) == 1 { return NX_DMARC_REJECT } 215 return NX_DMARC_NOREC 216 } 217 } 218 i = i + 1 219 } 220 return NX_DMARC_NOREC 221}