code wiki / (root) / nx_journal_log.nx

nx_journal_log.nx source

↩ module page · 356 lines · 13384 B

1// nx_journal_log.nx -- hash-chained append-only event log (L1 of 2// the storage substrate). 3// 4// ST-3 milestone of NISHI_STORAGE_SUBSTRATE_ROADMAP.md. Composes 5// L0 nx_blob_store + nx_sha256 to ship the substrate-native 6// replacement for bash lib_fact_emit.sh. 7// 8// Each appended event: 9// 1. Payload bytes get stored as an L0 blob -> blob_hash 10// 2. Entry struct = (seq_no, ts_us, schema_id, prev_entry_hash, 11// blob_hash) is canonicalized to bytes 12// 3. entry_hash = SHA-256(canonical_entry_bytes) 13// 4. The new entry's prev_entry_hash field points to the PRIOR 14// entry's entry_hash (chain link) 15// 5. Entry-0's prev_entry_hash is zeroed (chain root) 16// 17// To detect tamper anywhere in history: 18// - Recompute entry_hash(N-1) from the live entry struct 19// - Compare against entry_N.prev_entry_hash 20// - First mismatch returns its seq_no; -1 if chain is intact 21// Tampering ANY field in entry K (prev_hash, blob_hash, ts, schema) 22// changes entry_hash(K) -> next entry's prev pointer mismatches -> 23// verify_chain catches it at K+1. 24// 25// Per Cardinal 13 (additive-only): the log NEVER deletes; updates 26// to data write new events with new payloads; "removal" semantics 27// at higher layers go via new is_current=0 events, not mutation. 28// 29// V1 scope: 30// - In-memory entries (file-backed deferred to ST-3.5) 31// - Max NX_JOURNAL_LOG_CAPACITY = 256 entries 32// - Canary-bracketed for tamper-detect on the LOG itself (in 33// addition to chain verification on its CONTENTS) 34// - schema_id is operator-supplied (caller picks the meaning) 35// 36// Deferred per roadmap: 37// - File-backed persistence (ST-3.5) 38// - Merkle Mountain Range layout for O(log n) inclusion proofs 39// (ST-3.5 or later) 40// - Multi-process append (ST-4) 41// - Threshold-signed entries (ST-6+) 42// 43// genealogy_id: git_reflog_2005 + bitcoin_block_chain_2009 + 44// mimblewimble_mmr_2016 + tuf_2009 + slsa_2021 + 45// cardinal_2026-05-20_storage_substrate 46// lineage_id: substrate_journal_log_v1 47// 48// nx_capability_manifest: 49// variant_class: journal_log 50// variant_id: journal_log_v1_in_memory_hash_chain 51// requires_isa: [rv64imac, x86_64] 52// requires_syscalls: [mmap, clock_gettime_mono] 53// requires_ram_min_b: 16384 54// tier_floor: NX_TIER_MOBILE 55// tier_ceiling: NX_TIER_HPC 56// cost_model: 57// flops_per_n: 50.0 // SHA-256 per append 58// bytes_per_n: 96.0 // entry canonical bytes 59// syscalls_per_n: 1.0 // one clock_gettime per append 60// adversary_class: THREAT_AI_ADVERSARY 61// 62// nx_safety_envelope: 63// intended_use: "Substrate-native append-only event log; 64// replaces bash lib_fact_emit for hash-verified 65// persistence" 66// sil_target: SIL2 67// evidence: [canary_bracketed, hash_chain_verified, 68// additive_only, payload_content_addressed] 69// verdict: NOT_YET_EVALUATED 70 71import "nx_syscalls.nx" 72import "nx_sha256.nx" 73import "nx_blob_store.nx" 74const NX_MAGIC_1000000: i64 = 1000000 75 76// ===== Constants ================================================= 77const NX_JOURNAL_LOG_CAPACITY: i64 = 256 78 79// Verdicts. 80const NX_JOURNAL_OK: i64 = 0 81const NX_JOURNAL_BAD_INPUT: i64 = 1 82const NX_JOURNAL_FULL: i64 = 2 83const NX_JOURNAL_NOT_FOUND: i64 = 3 84const NX_JOURNAL_TAMPER: i64 = 4 85const NX_JOURNAL_CHAIN_BROKEN: i64 = 5 86 87// Canaries (distinct from blob_store / probe / calib / plan). 88const NX_JOURNAL_LOG_CANARY_PRE: i64 = 0x4E584A4F555250 // "NXJOURP" 89const NX_JOURNAL_LOG_CANARY_POST: i64 = 0x4E584A4F55524550 // "NXJOUREP" 90 91// ===== NxJournalEntry ============================================ 92// Inlines both prev_entry_hash (4 i64) and blob_hash (4 i64) so 93// canonical serialization is straight little-endian field-by-field. 94 95struct NxJournalEntry { 96 seq_no: i64, 97 ts_us: i64, 98 schema_id: i64, 99 prev_w0: i64, 100 prev_w1: i64, 101 prev_w2: i64, 102 prev_w3: i64, 103 blob_w0: i64, 104 blob_w1: i64, 105 blob_w2: i64, 106 blob_w3: i64, 107} 108 109// ===== NxJournalLog ============================================== 110struct NxJournalLog { 111 canary_pre: i64, 112 n_entries: i64, 113 max_entries: i64, 114 entries: *i64, // *i64 of NxJournalEntry pointers 115 blob_store: *NxBlobStore, // back-reference to L0 store 116 canary_post: i64, 117} 118 119// ===== Helpers =================================================== 120 121// Little-endian store of i64 into buf[off..off+8]. 122func _journal_store_i64_le(buf: *u8, off: i64, v: i64) -> i64 { 123 buf[off + 0] = (v & 255) as u8 124 buf[off + 1] = ((v >> 8) & 255) as u8 125 buf[off + 2] = ((v >> 16) & 255) as u8 126 buf[off + 3] = ((v >> 24) & 255) as u8 127 buf[off + 4] = ((v >> 32) & 255) as u8 128 buf[off + 5] = ((v >> 40) & 255) as u8 129 buf[off + 6] = ((v >> 48) & 255) as u8 130 buf[off + 7] = ((v >> 56) & 255) as u8 131 return off + 8 132} 133 134// Little-endian load of i64 from buf[off..off+8]. 135func _journal_load_i64_le(buf: *u8, off: i64) -> i64 { 136 let b0: i64 = (buf[off + 0] as i64) & 255 137 let b1: i64 = (buf[off + 1] as i64) & 255 138 let b2: i64 = (buf[off + 2] as i64) & 255 139 let b3: i64 = (buf[off + 3] as i64) & 255 140 let b4: i64 = (buf[off + 4] as i64) & 255 141 let b5: i64 = (buf[off + 5] as i64) & 255 142 let b6: i64 = (buf[off + 6] as i64) & 255 143 let b7: i64 = (buf[off + 7] as i64) & 255 144 return b0 145 | (b1 << 8) 146 | (b2 << 16) 147 | (b3 << 24) 148 | (b4 << 32) 149 | (b5 << 40) 150 | (b6 << 48) 151 | (b7 << 56) 152} 153 154// Canonicalize entry into a buffer (11 i64 fields = 88 bytes LE). 155func _journal_canonicalize(e: *NxJournalEntry, buf: *u8) -> i64 { 156 var off: i64 = 0 157 off = _journal_store_i64_le(buf, off, e.seq_no) 158 off = _journal_store_i64_le(buf, off, e.ts_us) 159 off = _journal_store_i64_le(buf, off, e.schema_id) 160 off = _journal_store_i64_le(buf, off, e.prev_w0) 161 off = _journal_store_i64_le(buf, off, e.prev_w1) 162 off = _journal_store_i64_le(buf, off, e.prev_w2) 163 off = _journal_store_i64_le(buf, off, e.prev_w3) 164 off = _journal_store_i64_le(buf, off, e.blob_w0) 165 off = _journal_store_i64_le(buf, off, e.blob_w1) 166 off = _journal_store_i64_le(buf, off, e.blob_w2) 167 off = _journal_store_i64_le(buf, off, e.blob_w3) 168 return off 169} 170 171// Compute entry_hash from a canonical entry struct. Writes 32 172// bytes into out_digest (4 i64 LE-packed via 4 separate writes). 173func _journal_entry_hash(e: *NxJournalEntry, 174 out_w0: *i64, out_w1: *i64, 175 out_w2: *i64, out_w3: *i64) -> i64 { 176 let buf: *u8 = sys_mmap(128) 177 let n: i64 = _journal_canonicalize(e, buf) 178 let digest: *u8 = sys_mmap(64) 179 sha256_digest(buf, n, digest) 180 *out_w0 = _journal_load_i64_le(digest, 0) 181 *out_w1 = _journal_load_i64_le(digest, 8) 182 *out_w2 = _journal_load_i64_le(digest, 16) 183 *out_w3 = _journal_load_i64_le(digest, 24) 184 return 0 185} 186 187// ===== Construction ============================================== 188func nx_journal_log_new(blob_store: *NxBlobStore) -> *NxJournalLog { 189 if (blob_store as i64) == 0 { return (0 as i64) as *NxJournalLog } 190 let j: *NxJournalLog = (sys_mmap(64)) as *NxJournalLog 191 j.canary_pre = NX_JOURNAL_LOG_CANARY_PRE 192 j.canary_post = NX_JOURNAL_LOG_CANARY_POST 193 j.n_entries = 0 194 j.max_entries = NX_JOURNAL_LOG_CAPACITY 195 j.entries = (sys_mmap(NX_JOURNAL_LOG_CAPACITY * 8)) as *i64 196 var i: i64 = 0 197 while i < NX_JOURNAL_LOG_CAPACITY { 198 j.entries[i] = 0 199 i = i + 1 200 } 201 j.blob_store = blob_store 202 return j 203} 204 205// ===== Validity gate ============================================= 206func nx_journal_log_is_valid(j: *NxJournalLog) -> i64 { 207 if (j as i64) == 0 { return 0 } 208 if j.canary_pre != NX_JOURNAL_LOG_CANARY_PRE { return 0 } 209 if j.canary_post != NX_JOURNAL_LOG_CANARY_POST { return 0 } 210 if j.n_entries < 0 { return 0 } 211 if j.n_entries > j.max_entries { return 0 } 212 if (j.blob_store as i64) == 0 { return 0 } 213 return 1 214} 215 216// ===== Append ==================================================== 217// Stores payload as L0 blob, builds entry, chains via prev_entry_hash. 218// Returns the new seq_no on success or a verdict on failure. 219 220func nx_journal_log_append( 221 j: *NxJournalLog, 222 payload: *u8, payload_len: i64, 223 schema_id: i64 224) -> i64 { 225 if nx_journal_log_is_valid(j) != 1 { return 0 - NX_JOURNAL_TAMPER } 226 if payload_len < 0 { return 0 - NX_JOURNAL_BAD_INPUT } 227 if payload_len > 0 { 228 if (payload as i64) == 0 { return 0 - NX_JOURNAL_BAD_INPUT } 229 } 230 if j.n_entries >= j.max_entries { return 0 - NX_JOURNAL_FULL } 231 232 // 1. Store payload in L0 -> blob_hash 233 let blob_hash: *NxBlobHash = nx_blob_hash_new() 234 let rc_put: i64 = nx_blob_store_put(j.blob_store, payload, payload_len, blob_hash) 235 if rc_put != NX_BLOB_OK { return 0 - NX_JOURNAL_BAD_INPUT } 236 237 // 2. Compute prev_entry_hash (chain link) 238 var prev_w0: i64 = 0 239 var prev_w1: i64 = 0 240 var prev_w2: i64 = 0 241 var prev_w3: i64 = 0 242 if j.n_entries > 0 { 243 let prev_e_addr: i64 = j.entries[j.n_entries - 1] 244 let prev_e: *NxJournalEntry = prev_e_addr as *NxJournalEntry 245 let pw0: *i64 = (sys_mmap(8)) as *i64 246 let pw1: *i64 = (sys_mmap(8)) as *i64 247 let pw2: *i64 = (sys_mmap(8)) as *i64 248 let pw3: *i64 = (sys_mmap(8)) as *i64 249 _journal_entry_hash(prev_e, pw0, pw1, pw2, pw3) 250 prev_w0 = *pw0 251 prev_w1 = *pw1 252 prev_w2 = *pw2 253 prev_w3 = *pw3 254 } 255 256 // 3. Allocate + populate new entry 257 let e: *NxJournalEntry = (sys_mmap(96)) as *NxJournalEntry 258 e.seq_no = j.n_entries 259 let ts: *i64 = (sys_mmap(16)) as *i64 260 let rc_ts: i64 = sys_clock_gettime_mono(ts) 261 if rc_ts == 0 { 262 e.ts_us = (ts[0] * NX_MAGIC_1000000) + (ts[1] / 1000) 263 } else { 264 e.ts_us = 0 265 } 266 e.schema_id = schema_id 267 e.prev_w0 = prev_w0 268 e.prev_w1 = prev_w1 269 e.prev_w2 = prev_w2 270 e.prev_w3 = prev_w3 271 e.blob_w0 = blob_hash.w0 272 e.blob_w1 = blob_hash.w1 273 e.blob_w2 = blob_hash.w2 274 e.blob_w3 = blob_hash.w3 275 276 // 4. Commit 277 j.entries[j.n_entries] = e as i64 278 let new_seq: i64 = j.n_entries 279 j.n_entries = j.n_entries + 1 280 return new_seq 281} 282 283// ===== Read ====================================================== 284// Reads payload bytes for entry `seq_no` into out_buf. Returns 285// payload length on success or -1 on miss / oversized / tamper. 286// Also writes the entry's schema_id into *out_schema_id (caller 287// can pass null to skip). 288 289func nx_journal_log_read( 290 j: *NxJournalLog, 291 seq_no: i64, 292 out_buf: *u8, max_len: i64, 293 out_schema_id: *i64 294) -> i64 { 295 if nx_journal_log_is_valid(j) != 1 { return -1 } 296 if seq_no < 0 { return -1 } 297 if seq_no >= j.n_entries { return -1 } 298 if (out_buf as i64) == 0 { return -1 } 299 if max_len < 0 { return -1 } 300 301 let e: *NxJournalEntry = (j.entries[seq_no]) as *NxJournalEntry 302 if (out_schema_id as i64) != 0 { *out_schema_id = e.schema_id } 303 304 let blob_hash: *NxBlobHash = nx_blob_hash_new() 305 blob_hash.w0 = e.blob_w0 306 blob_hash.w1 = e.blob_w1 307 blob_hash.w2 = e.blob_w2 308 blob_hash.w3 = e.blob_w3 309 return nx_blob_store_get(j.blob_store, blob_hash, out_buf, max_len) 310} 311 312// ===== Chain verification ======================================= 313// Walks the log and verifies the prev_entry_hash linkage at every 314// entry past 0. Returns -1 if the chain is intact, OR the seq_no 315// of the first broken entry (entry whose prev_w* does NOT match 316// the recomputed entry_hash of seq_no-1). 317 318func nx_journal_log_verify_chain(j: *NxJournalLog) -> i64 { 319 if nx_journal_log_is_valid(j) != 1 { return 0 } // K=0 broken (tamper) 320 if j.n_entries <= 1 { return -1 } // trivially intact 321 322 let pw0: *i64 = (sys_mmap(8)) as *i64 323 let pw1: *i64 = (sys_mmap(8)) as *i64 324 let pw2: *i64 = (sys_mmap(8)) as *i64 325 let pw3: *i64 = (sys_mmap(8)) as *i64 326 var k: i64 = 1 327 while k < j.n_entries { 328 let prev: *NxJournalEntry = (j.entries[k - 1]) as *NxJournalEntry 329 let curr: *NxJournalEntry = (j.entries[k]) as *NxJournalEntry 330 _journal_entry_hash(prev, pw0, pw1, pw2, pw3) 331 if curr.prev_w0 != *pw0 { return k } 332 if curr.prev_w1 != *pw1 { return k } 333 if curr.prev_w2 != *pw2 { return k } 334 if curr.prev_w3 != *pw3 { return k } 335 k = k + 1 336 } 337 return -1 338} 339 340// ===== Count + latest-hash ====================================== 341func nx_journal_log_count(j: *NxJournalLog) -> i64 { 342 if nx_journal_log_is_valid(j) != 1 { return -1 } 343 return j.n_entries 344} 345 346// Writes the entry_hash of the latest entry into out_w*. Returns 347// 0 on success, NX_JOURNAL_NOT_FOUND on empty log. 348func nx_journal_log_latest_hash(j: *NxJournalLog, 349 out_w0: *i64, out_w1: *i64, 350 out_w2: *i64, out_w3: *i64) -> i64 { 351 if nx_journal_log_is_valid(j) != 1 { return NX_JOURNAL_TAMPER } 352 if j.n_entries == 0 { return NX_JOURNAL_NOT_FOUND } 353 let e: *NxJournalEntry = (j.entries[j.n_entries - 1]) as *NxJournalEntry 354 _journal_entry_hash(e, out_w0, out_w1, out_w2, out_w3) 355 return NX_JOURNAL_OK 356}