code wiki / (root) / nx_password_vault.nx

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}