code wiki / _hdl_build / nx_doc_crypto.nx
nx_doc_crypto.nx source
↩ module page · 179 lines · 9031 B
1// nx_doc_crypto.nx -- ENCRYPTION-AT-REST core for CONFIDENTIAL docportal shards (client -prv-/-secret-/uploads;
2// NOT the public corpus). Envelope encryption (NIST SP 800-57): a master KEK wraps per-tenant DEKs; documents
3// are sealed with the DEK under a REAL AEAD. Grounds ENTIRELY in shipped, KAT-proven sovereign primitives --
4// invents NO crypto:
5// AEAD = ChaCha20-Poly1305 (RFC 8439 2.8; nx_chacha20_poly1305, KAT-verified vs the 2.8.2 Sunscreen vector)
6// cipher = ChaCha20 (256-bit key) ; MAC = Poly1305 (constant-time compare)
7// CSPRNG = nx_csprng_fill (getrandom(2), /dev/urandom fallback)
8//
9// CRYPTO-SHRED reconciles Rule 13 (never-delete DATA) with GDPR Art 17 (erasure): the DATA store stays additive
10// (ciphertext history preserved), but the KEY store is the ONE place true deletion is correct -- destroying the
11// wrapped DEK erases ACCESS while the ciphertext bytes remain, permanently unrecoverable. So DEKs live in a
12// per-domain FILE (overwrite-with-random + truncate + unlink), NOT the additive seg-store, precisely because the
13// seg-store's never-delete law is wrong for a key that must be destroyable.
14//
15// STANDALONE lib -- wires into no daemon. HONEST LIMITATION: the KEK is a software file (0600) on the NAS, not an
16// HSM/TPM -- root or a disk image can read it (flag for the operator). license_tier: ORIGINAL
17import "nx_chacha20_poly1305.nx" // nx_chacha20_poly1305_encrypt/_decrypt + NX_AEAD_* consts + verdict codes
18import "nx_csprng.nx" // nx_csprng_fill (CSPRNG: getrandom(2))
19import "nx_syscalls.nx" // file IO + AT_FDCWD + __syscall
20
21const DC_WRAP_BYTES: i64 = 60 // wrapped DEK = nonce(12) || ct(32) || tag(16)
22const DC_MODE_0600: i64 = 0x180 // rw------- : DEK + KEK files are owner-only
23
24func dc_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
25func dc_cat(out: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { out[o] = s[i]; o = o + 1; i = i + 1 } return o }
26// per-domain wrapped-DEK path: knowledge/store/dek-<domain>.wrap (a FILE, so crypto_shred can truly destroy it)
27func dc_dek_path(domain: *u8, out: *u8) -> i64 {
28 var o: i64 = dc_cat(out, 0, "knowledge/store/dek-" as *u8)
29 o = dc_cat(out, o, domain)
30 o = dc_cat(out, o, ".wrap" as *u8)
31 out[o] = 0 as u8
32 return o
33}
34// read up to cap bytes of `path` into buf; returns bytes read, or -1 if the file cannot be opened.
35func dc_readfile(path: *u8, buf: *u8, cap: i64) -> i64 {
36 let fd: i64 = sys_openat_rd(path)
37 if fd < 0 { return 0 - 1 }
38 var total: i64 = 0
39 var go: i64 = 1
40 while go == 1 {
41 if total >= cap { go = 0 } else {
42 let m: i64 = sys_read(fd, (buf as i64 + total) as *u8, cap - total)
43 if m <= 0 { go = 0 } else { total = total + m }
44 }
45 }
46 sys_close(fd)
47 return total
48}
49// write n bytes to `path` (O_CREAT|O_WRONLY|O_TRUNC, mode), fsync. 0 ok / -1 fail.
50func dc_writefile(path: *u8, buf: *u8, n: i64, mode: i64) -> i64 {
51 let fd: i64 = sys_openat_wr(path, mode)
52 if fd < 0 { return 0 - 1 }
53 var off: i64 = 0
54 var go: i64 = 1
55 while go == 1 {
56 if off >= n { go = 0 } else {
57 let m: i64 = sys_write(fd, (buf as i64 + off) as *u8, n - off)
58 if m <= 0 { sys_close(fd); return 0 - 1 } else { off = off + m }
59 }
60 }
61 sys_fsync(fd)
62 sys_close(fd)
63 return 0
64}
65
66// Load the master KEK (32 bytes) into out32. If the KEK file is ABSENT, generate a fresh one via CSPRNG and
67// persist it 0600 (first-use bootstrap). NEVER regenerates over an existing KEK (a corrupt/short KEK -> FAIL,
68// not silent re-key which would orphan every DEK). 0 ok / <0 fail.
69func kek_ensure(out32: *u8) -> i64 {
70 let path: *u8 = "knowledge/store/doc_kek.master" as *u8
71 let fd: i64 = sys_openat_rd(path)
72 if fd >= 0 {
73 var total: i64 = 0
74 var go: i64 = 1
75 while go == 1 {
76 if total >= 32 { go = 0 } else {
77 let m: i64 = sys_read(fd, (out32 as i64 + total) as *u8, 32 - total)
78 if m <= 0 { go = 0 } else { total = total + m }
79 }
80 }
81 sys_close(fd)
82 if total == 32 { return 0 }
83 return 0 - 1
84 }
85 if nx_csprng_fill(out32, 32) != 0 { return 0 - 2 }
86 if dc_writefile(path, out32, 32, DC_MODE_0600) != 0 { return 0 - 3 }
87 return 0
88}
89
90// Provision a per-tenant DEK: CSPRNG 32-byte DEK -> AEAD-wrap under the KEK (AAD = domain, binds the wrap to the
91// tenant) -> persist [nonce||ct||tag] to the domain's .wrap file 0600. 0 ok / <0 fail. Idempotent-by-overwrite.
92func dek_gen(domain: *u8) -> i64 {
93 let kek: *u8 = sys_mmap(32)
94 if kek_ensure(kek) != 0 { return 0 - 1 }
95 let dek: *u8 = sys_mmap(32)
96 if nx_csprng_fill(dek, 32) != 0 { return 0 - 2 }
97 let wnonce: *u8 = sys_mmap(12)
98 if nx_csprng_fill(wnonce, 12) != 0 { return 0 - 3 }
99 let wrapped: *u8 = sys_mmap(64)
100 var i: i64 = 0
101 while i < 12 { wrapped[i] = wnonce[i]; i = i + 1 }
102 nx_chacha20_poly1305_encrypt(kek, wnonce, domain, dc_slen(domain), dek, 32, (wrapped as i64 + 12) as *u8, (wrapped as i64 + 44) as *u8)
103 let path: *u8 = sys_mmap(512); dc_dek_path(domain, path)
104 if dc_writefile(path, wrapped, DC_WRAP_BYTES, DC_MODE_0600) != 0 { return 0 - 4 }
105 return 0
106}
107
108// Unwrap the per-tenant DEK into out32. Reads the .wrap file, AEAD-opens it under the KEK (AAD = domain).
109// 0 ok / <0 fail -- a MISSING file (shredded/absent) or a failed tag (corrupt/overwritten) both FAIL, which is
110// exactly the crypto-shred guarantee: no DEK -> no decrypt.
111func dek_load(domain: *u8, out32: *u8) -> i64 {
112 let path: *u8 = sys_mmap(512); dc_dek_path(domain, path)
113 let wrapped: *u8 = sys_mmap(64)
114 let got: i64 = dc_readfile(path, wrapped, 64)
115 if got < DC_WRAP_BYTES { return 0 - 1 }
116 let kek: *u8 = sys_mmap(32)
117 if kek_ensure(kek) != 0 { return 0 - 2 }
118 let v: i64 = nx_chacha20_poly1305_decrypt(kek, wrapped, domain, dc_slen(domain), (wrapped as i64 + 12) as *u8, 32, (wrapped as i64 + 44) as *u8, out32)
119 if v != NX_AEAD_VERDICT_OK { return 0 - 3 }
120 return 0
121}
122
123// Seal a document: out = nonce(12) || ciphertext(ptlen) || tag(16). FRESH CSPRNG nonce per call (nonce reuse is
124// CATASTROPHIC for ChaCha20-Poly1305 -- Joux 2006). AAD = domain. Returns total bytes written, or <0 (no DEK).
125func doc_encrypt(domain: *u8, pt: *u8, ptlen: i64, out: *u8) -> i64 {
126 let dek: *u8 = sys_mmap(32)
127 if dek_load(domain, dek) != 0 { return 0 - 1 }
128 let nonce: *u8 = sys_mmap(16)
129 if nx_csprng_fill(nonce, 12) != 0 { return 0 - 2 }
130 var i: i64 = 0
131 while i < 12 { out[i] = nonce[i]; i = i + 1 }
132 nx_chacha20_poly1305_encrypt(dek, nonce, domain, dc_slen(domain), pt, ptlen, (out as i64 + 12) as *u8, (out as i64 + 12 + ptlen) as *u8)
133 return 12 + ptlen + 16
134}
135
136// Open a document (nonce||ct||tag). AEAD verifies the tag BEFORE releasing plaintext. Returns plaintext length,
137// or <0 on FAIL (no DEK, too short, or tag mismatch -- tamper/wrong-key). out gets the plaintext only on success.
138func doc_decrypt(domain: *u8, ct: *u8, ctlen: i64, out: *u8) -> i64 {
139 if ctlen < 28 { return 0 - 1 }
140 let dek: *u8 = sys_mmap(32)
141 if dek_load(domain, dek) != 0 { return 0 - 2 }
142 let bodylen: i64 = ctlen - 12 - 16
143 let v: i64 = nx_chacha20_poly1305_decrypt(dek, ct, domain, dc_slen(domain), (ct as i64 + 12) as *u8, bodylen, (ct as i64 + 12 + bodylen) as *u8, out)
144 if v != NX_AEAD_VERDICT_OK { return 0 - 3 }
145 return bodylen
146}
147
148// CRYPTO-SHRED: truly destroy the per-tenant DEK so its documents become permanently unrecoverable while their
149// ciphertext bytes remain in the additive data store. Overwrite the wrapped-DEK bytes with CSPRNG random (in
150// place), truncate to zero, then unlink -- then VERIFY dek_load fails. 0 ok / -1 if the DEK is still loadable.
151func crypto_shred(domain: *u8) -> i64 {
152 let path: *u8 = sys_mmap(512); dc_dek_path(domain, path)
153 // 1. overwrite the key material in place with random (defense-in-depth vs. raw-disk recovery)
154 let fd: i64 = sys_openat_rdwr(path, DC_MODE_0600)
155 if fd >= 0 {
156 let rnd: *u8 = sys_mmap(64)
157 nx_csprng_fill(rnd, DC_WRAP_BYTES)
158 sys_lseek(fd, 0, 0)
159 var off: i64 = 0
160 var go: i64 = 1
161 while go == 1 {
162 if off >= DC_WRAP_BYTES { go = 0 } else {
163 let m: i64 = sys_write(fd, (rnd as i64 + off) as *u8, DC_WRAP_BYTES - off)
164 if m <= 0 { go = 0 } else { off = off + m }
165 }
166 }
167 sys_fsync(fd)
168 sys_close(fd)
169 }
170 // 2. truncate the (now-random) bytes to zero length
171 let fdt: i64 = sys_openat_wr(path, DC_MODE_0600)
172 if fdt >= 0 { sys_close(fdt) }
173 // 3. unlink the entry (raw x86_64 unlinkat = 263, passes untranslated -- the fsync-74/unlinkat-263 precedent)
174 __syscall(263, AT_FDCWD, path as i64, 0, 0, 0, 0)
175 // 4. verify-gone: the DEK must no longer load
176 let dek: *u8 = sys_mmap(32)
177 if dek_load(domain, dek) == 0 { return 0 - 1 }
178 return 0
179}