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}