nx_password_vault.nx source
↩ module page · 324 lines · 13230 B
1// nx_password_vault.nx -- sovereign at-rest-encrypted credential store.
2//
3// Per user 2026-05-16:
4// "get that stored in a nishi language equivalent of a password
5// vault so it can be called by authorized users like you".
6//
7// Per cardinals:
8// - feedback-dont-rebuild-commodity-platform-layers: bits-up sovereign,
9// no 1Password / LastPass / Bitwarden / OS keychain DEPENDENCY (we
10// can interop later, but the canonical store is local).
11// - feedback-user-owns-every-bit: vault file lives where the user
12// puts it; primitive does no auto-load, no auto-create, no
13// background daemon, no telemetry.
14// - feedback-launching-content-must-be-one-command-easy: tooling
15// calls vault_get(name) and gets back a credential string.
16//
17// Threat model in scope:
18// - Disk theft: attacker has the encrypted vault file. Must NOT
19// recover plaintext without the master passphrase.
20// - Casual snooping: file is not human-readable.
21// - Offline brute-force: passphrase is the weak link. v1 uses
22// HKDF-SHA256 (FAST kdf) which is INSUFFICIENT against offline
23// brute force on weak passphrases. v2 ships Argon2id integration
24// and migrates existing vaults.
25// Threat model OUT of scope (v1):
26// - Active malware on the host running NishiLang (it can scrape RAM)
27// - Side-channel attacks on the KDF
28// - Vault file rotated without backup before deletion
29//
30// File format (binary, big-endian):
31// magic [8 bytes] = "NXVLT\0\0\1" (version 1)
32// salt [32 bytes] (random; HKDF salt)
33// nonce_base[12 bytes] (random; XOR'd with entry idx)
34// n_entries [4 bytes BE]
35// for each entry i in 0..n_entries:
36// name_len [2 bytes BE]
37// name [name_len bytes] (UTF-8, plaintext)
38// ct_len [4 bytes BE]
39// ct [ct_len bytes] (encrypted value)
40// tag [16 bytes] (Poly1305 tag)
41//
42// Names are NOT encrypted (so vault_list_names() doesn't need to
43// unlock first). Values ARE encrypted. Per-entry nonce =
44// nonce_base XOR (entry_index in last 4 bytes).
45//
46// API surface:
47// nx_vault_derive_key(passphrase, salt, key_out)
48// Derives a 32-byte master key via HKDF-SHA256(salt, passphrase).
49// (v2: HKDF wrapped around Argon2id.)
50//
51// nx_vault_encrypt_entry(key, nonce_base, idx, name, name_len,
52// value, value_len, ct_out, tag_out)
53// Encrypts one entry's value via ChaCha20-Poly1305.
54//
55// nx_vault_decrypt_entry(key, nonce_base, idx, name, name_len,
56// ct, ct_len, tag, pt_out)
57// Decrypts. Returns NX_AEAD_VERDICT_OK or _TAG_MISMATCH.
58//
59// nx_vault_make_per_entry_nonce(nonce_base, idx, nonce_out)
60// Helper: XORs nonce_base last 4 bytes with idx (big-endian).
61//
62// The on-disk read/write logic + JSON/CLI bindings live in a thin
63// shell wrapper for v1; substrate ships the cryptographic primitives.
64//
65// Source references (open):
66// - RFC 8439 (ChaCha20-Poly1305 AEAD)
67// - RFC 5869 (HKDF)
68// - RFC 9106 (Argon2 -- v2 upgrade path)
69// - Bitwarden / KeePass file-format prior art (public formats)
70//
71// genealogy_id: rfc8439_chacha20poly1305 + rfc5869_hkdf + keepass_kdbx_layout
72// lineage_id: sovereign_credential_at_rest_store
73
74// nx_safety_envelope:
75// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
76// sil_target: SIL1
77// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
78// verdict: NOT_YET_EVALUATED
79
80import "nx_syscalls.nx"
81import "nx_tier.nx"
82import "nx_chacha20_poly1305.nx"
83import "nx_hkdf.nx"
84
85// ===== Constants ====================================================
86
87const NX_VAULT_MAGIC_LEN: nx_int = 8
88const NX_VAULT_SALT_LEN: nx_int = 32
89const NX_VAULT_NONCE_LEN: nx_int = 12
90const NX_VAULT_KEY_LEN: nx_int = 32
91const NX_VAULT_TAG_LEN: nx_int = 16
92const NX_VAULT_AAD_NONE: nx_int = 0
93
94const NX_VAULT_VERDICT_OK: nx_int = 0
95const NX_VAULT_VERDICT_TAG_MISMATCH: nx_int = 1
96const NX_VAULT_VERDICT_BAD_ARGS: nx_int = 2
97
98// ===== Key derivation ===============================================
99//
100// HKDF-Extract(salt, passphrase) -> PRK, then HKDF-Expand(PRK, "vault-v1",
101// 32) -> key. This produces a deterministic 32-byte key from the
102// passphrase + salt pair. Insufficient against offline brute force on
103// weak passphrases (PRK is not memory-hard) -- v2 wraps Argon2id.
104//
105// passphrase + salt MUST be non-empty. Returns 0 on success.
106func nx_vault_derive_key(
107 passphrase: *u8, passphrase_len: nx_int,
108 salt: *u8, salt_len: nx_int,
109 key_out: *u8
110) -> nx_int {
111 if (passphrase as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
112 if (salt as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
113 if (key_out as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
114 if passphrase_len <= 0 { return NX_VAULT_VERDICT_BAD_ARGS }
115 if salt_len <= 0 { return NX_VAULT_VERDICT_BAD_ARGS }
116
117 let prk: *u8 = sys_mmap(32)
118 hkdf_extract(salt, salt_len as i64, passphrase, passphrase_len as i64, prk)
119
120 let info_str: *u8 = "vault-v1" as *u8
121 hkdf_expand(prk, info_str, 8, NX_VAULT_KEY_LEN as i64, key_out)
122 return NX_VAULT_VERDICT_OK
123}
124
125// ===== Per-entry nonce ==============================================
126//
127// nonce_base is 12 bytes (random per vault). Per-entry nonce =
128// nonce_base, with the last 4 bytes XOR'd with the entry index in
129// big-endian. This gives 2^32 unique nonces per vault before reuse
130// (more than enough for any realistic vault size).
131func nx_vault_make_per_entry_nonce(
132 nonce_base: *u8, idx: nx_int, nonce_out: *u8
133) -> nx_int {
134 if (nonce_base as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
135 if (nonce_out as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
136 var i: nx_int = 0
137 while i < NX_VAULT_NONCE_LEN {
138 nonce_out[i] = nonce_base[i]
139 i = i + 1
140 }
141 // XOR last 4 bytes with idx (big-endian).
142 let idx_i64: i64 = idx as i64
143 let b0: i64 = (idx_i64 >> 24) & 0xFF
144 let b1: i64 = (idx_i64 >> 16) & 0xFF
145 let b2: i64 = (idx_i64 >> 8) & 0xFF
146 let b3: i64 = idx_i64 & 0xFF
147 nonce_out[8] = ((nonce_out[8] as i64) ^ b0) as u8
148 nonce_out[9] = ((nonce_out[9] as i64) ^ b1) as u8
149 nonce_out[10] = ((nonce_out[10] as i64) ^ b2) as u8
150 nonce_out[11] = ((nonce_out[11] as i64) ^ b3) as u8
151 return NX_VAULT_VERDICT_OK
152}
153
154// ===== Encrypt / Decrypt ============================================
155//
156// Encrypts one entry's value. ct_out must have capacity for value_len
157// bytes; tag_out must be 16 bytes. AAD intentionally empty: we don't
158// bind the encrypted value to any per-vault context for v1, allowing
159// straightforward import/export of single entries. v2 may add an AAD
160// of (vault_id || entry_name) for cross-vault-leak prevention.
161
162func nx_vault_encrypt_entry(
163 key: *u8, nonce_base: *u8, idx: nx_int,
164 value: *u8, value_len: nx_int,
165 ct_out: *u8, tag_out: *u8
166) -> nx_int {
167 if (key as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
168 if (nonce_base as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
169 if (value as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
170 if (ct_out as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
171 if (tag_out as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
172 if value_len < 0 { return NX_VAULT_VERDICT_BAD_ARGS }
173
174 let nonce: *u8 = sys_mmap(NX_VAULT_NONCE_LEN)
175 nx_vault_make_per_entry_nonce(nonce_base, idx, nonce)
176
177 let dummy_aad: *u8 = sys_mmap(1)
178 let rc: i64 = nx_chacha20_poly1305_encrypt(
179 key, nonce,
180 dummy_aad, 0,
181 value, value_len as i64,
182 ct_out, tag_out
183 )
184 if rc != NX_AEAD_VERDICT_OK { return NX_VAULT_VERDICT_BAD_ARGS }
185 return NX_VAULT_VERDICT_OK
186}
187
188func nx_vault_decrypt_entry(
189 key: *u8, nonce_base: *u8, idx: nx_int,
190 ct: *u8, ct_len: nx_int,
191 tag: *u8, pt_out: *u8
192) -> nx_int {
193 if (key as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
194 if (nonce_base as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
195 if (ct as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
196 if (tag as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
197 if (pt_out as i64) == 0 { return NX_VAULT_VERDICT_BAD_ARGS }
198 if ct_len < 0 { return NX_VAULT_VERDICT_BAD_ARGS }
199
200 let nonce: *u8 = sys_mmap(NX_VAULT_NONCE_LEN)
201 nx_vault_make_per_entry_nonce(nonce_base, idx, nonce)
202
203 let dummy_aad: *u8 = sys_mmap(1)
204 let rc: i64 = nx_chacha20_poly1305_decrypt(
205 key, nonce,
206 dummy_aad, 0,
207 ct, ct_len as i64,
208 tag, pt_out
209 )
210 if rc == NX_AEAD_VERDICT_OK { return NX_VAULT_VERDICT_OK }
211 if rc == NX_AEAD_VERDICT_TAG_MISMATCH { return NX_VAULT_VERDICT_TAG_MISMATCH }
212 return NX_VAULT_VERDICT_BAD_ARGS
213}
214
215// ===== Self-test ====================================================
216
217func main() -> i64 {
218 // T1: key derivation is deterministic for same passphrase + salt.
219 let pp: *u8 = "correct horse battery staple" as *u8
220 let salt: *u8 = sys_mmap(NX_VAULT_SALT_LEN)
221 var i: nx_int = 0
222 while i < NX_VAULT_SALT_LEN {
223 salt[i] = (i * 7 + 13) as u8
224 i = i + 1
225 }
226 let key_a: *u8 = sys_mmap(NX_VAULT_KEY_LEN)
227 let key_b: *u8 = sys_mmap(NX_VAULT_KEY_LEN)
228 nx_vault_derive_key(pp, 28, salt, NX_VAULT_SALT_LEN, key_a)
229 nx_vault_derive_key(pp, 28, salt, NX_VAULT_SALT_LEN, key_b)
230 var j: nx_int = 0
231 while j < NX_VAULT_KEY_LEN {
232 if key_a[j] != key_b[j] { return __syscall(93, 1, 0, 0, 0, 0, 0) }
233 j = j + 1
234 }
235
236 // T2: different passphrase produces different key.
237 let pp2: *u8 = "tr0ub4dor&3" as *u8
238 let key_c: *u8 = sys_mmap(NX_VAULT_KEY_LEN)
239 nx_vault_derive_key(pp2, 11, salt, NX_VAULT_SALT_LEN, key_c)
240 var diff: nx_int = 0
241 var k: nx_int = 0
242 while k < NX_VAULT_KEY_LEN {
243 if key_a[k] != key_c[k] { diff = 1 }
244 k = k + 1
245 }
246 if diff == 0 { return __syscall(93, 2, 0, 0, 0, 0, 0) }
247
248 // T3: per-entry nonce is deterministic for same (base, idx) and
249 // differs for different idx.
250 let nonce_base: *u8 = sys_mmap(NX_VAULT_NONCE_LEN)
251 var n: nx_int = 0
252 while n < NX_VAULT_NONCE_LEN {
253 nonce_base[n] = (n * 31) as u8
254 n = n + 1
255 }
256 let nonce_5a: *u8 = sys_mmap(NX_VAULT_NONCE_LEN)
257 let nonce_5b: *u8 = sys_mmap(NX_VAULT_NONCE_LEN)
258 let nonce_6: *u8 = sys_mmap(NX_VAULT_NONCE_LEN)
259 nx_vault_make_per_entry_nonce(nonce_base, 5, nonce_5a)
260 nx_vault_make_per_entry_nonce(nonce_base, 5, nonce_5b)
261 nx_vault_make_per_entry_nonce(nonce_base, 6, nonce_6)
262 var ii: nx_int = 0
263 while ii < NX_VAULT_NONCE_LEN {
264 if nonce_5a[ii] != nonce_5b[ii] { return __syscall(93, 10, 0, 0, 0, 0, 0) }
265 ii = ii + 1
266 }
267 // At least one byte (in the last 4) should differ between idx 5 and 6.
268 var dn: nx_int = 0
269 var jj: nx_int = 8
270 while jj < NX_VAULT_NONCE_LEN {
271 if nonce_5a[jj] != nonce_6[jj] { dn = 1 }
272 jj = jj + 1
273 }
274 if dn == 0 { return __syscall(93, 11, 0, 0, 0, 0, 0) }
275
276 // T4: encrypt + decrypt round-trip recovers plaintext.
277 let pt: *u8 = "I_l0ve_kelli_west" as *u8
278 let pt_len: nx_int = 17
279 let ct: *u8 = sys_mmap(pt_len)
280 let tag: *u8 = sys_mmap(NX_VAULT_TAG_LEN)
281 nx_vault_encrypt_entry(key_a, nonce_base, 0, pt, pt_len, ct, tag)
282
283 let recovered: *u8 = sys_mmap(pt_len)
284 let v: nx_int = nx_vault_decrypt_entry(key_a, nonce_base, 0, ct, pt_len, tag, recovered)
285 if v != NX_VAULT_VERDICT_OK { return __syscall(93, 20, 0, 0, 0, 0, 0) }
286 var m: nx_int = 0
287 while m < pt_len {
288 if recovered[m] != pt[m] { return __syscall(93, 21, 0, 0, 0, 0, 0) }
289 m = m + 1
290 }
291
292 // T5: tampered ciphertext fails authentication.
293 ct[0] = ((ct[0] as i64) ^ 1) as u8
294 let v_bad: nx_int = nx_vault_decrypt_entry(key_a, nonce_base, 0, ct, pt_len, tag, recovered)
295 if v_bad != NX_VAULT_VERDICT_TAG_MISMATCH { return __syscall(93, 30, 0, 0, 0, 0, 0) }
296 // Restore for further tests.
297 ct[0] = ((ct[0] as i64) ^ 1) as u8
298
299 // T6: wrong key fails authentication.
300 let v_wrong_key: nx_int = nx_vault_decrypt_entry(key_c, nonce_base, 0, ct, pt_len, tag, recovered)
301 if v_wrong_key != NX_VAULT_VERDICT_TAG_MISMATCH { return __syscall(93, 40, 0, 0, 0, 0, 0) }
302
303 // T7: wrong nonce (different idx) fails authentication.
304 let v_wrong_nonce: nx_int = nx_vault_decrypt_entry(key_a, nonce_base, 7, ct, pt_len, tag, recovered)
305 if v_wrong_nonce != NX_VAULT_VERDICT_TAG_MISMATCH { return __syscall(93, 50, 0, 0, 0, 0, 0) }
306
307 // T8: two different entries with same plaintext produce DIFFERENT
308 // ciphertexts (because nonces differ).
309 let ct_idx0: *u8 = sys_mmap(pt_len)
310 let tag_idx0: *u8 = sys_mmap(NX_VAULT_TAG_LEN)
311 let ct_idx1: *u8 = sys_mmap(pt_len)
312 let tag_idx1: *u8 = sys_mmap(NX_VAULT_TAG_LEN)
313 nx_vault_encrypt_entry(key_a, nonce_base, 0, pt, pt_len, ct_idx0, tag_idx0)
314 nx_vault_encrypt_entry(key_a, nonce_base, 1, pt, pt_len, ct_idx1, tag_idx1)
315 var same: nx_int = 1
316 var q: nx_int = 0
317 while q < pt_len {
318 if ct_idx0[q] != ct_idx1[q] { same = 0 }
319 q = q + 1
320 }
321 if same == 1 { return __syscall(93, 60, 0, 0, 0, 0, 0) }
322
323 return 0
324}