code wiki / hub / nx_save_file_format.nx

nx_save_file_format.nx source

↩ module page · 374 lines · 14415 B

1// nx_save_file_format.nx -- NSAV save file format encoder + decoder. 2// 3// V-CLIENT-STORE-1 per NISHI_CLIENT_STORE_CHARTER §2.2 + §8 M1. 4// 5// CARDINAL CONTEXT: per CS-2 client-owned storage cardinal, game saves 6// live on the user's OWN FILESYSTEM, NEVER on substrate servers. This 7// module provides the canonical wire format so saves are: 8// - portable across browsers / devices / save paths (Path A / B / C) 9// - integrity-checked (HMAC-SHA-256 footer detects tampering) 10// - versioned (forward-compat via header version field) 11// - cross-game distinguishable (game_id field; SHA-256 of identifier) 12// - sovereign substrate-canonical (no proprietary blob format) 13// 14// FILE LAYOUT (per charter §2.2): 15// offset 0..4 magic ascii "NSAV" 16// offset 4..6 version u16 BE (V1 = 0x0001) 17// offset 6..8 flags u16 BE (bit 0: encrypted [V+1]; bits 1-15 reserved) 18// offset 8..40 game_id SHA-256("site_id|||game_name") 32 bytes 19// offset 40..48 created_at i64 BE unix seconds since epoch 20// offset 48..56 payload_len i64 BE byte length of payload 21// offset 56..56+payload_len payload (opaque to substrate; game-defined) 22// offset (..)..(..)+32 hmac_sha256 over bytes[0..(end-32)] 23// with file_key = SHA-256("nishi-save|||" 24// || game_id 25// || passphrase) 26// 27// FILE_KEY derivation: HMAC key = SHA-256("nishi-save|||" || game_id || 28// user_passphrase). For unencrypted V1 saves, passphrase = "" (empty); 29// the key still provides integrity binding to game_id so a save for 30// game A cannot be passed off as a save for game B. 31// 32// COMPOSES (per "avoid duplicate primitives" cardinal): 33// nx_hmac hmac_sha256 (RFC 2104) 34// sha256 digest helper for game_id derivation 35// nx_syscalls sys_mmap 36// 37// COMPOSED BY: 38// hub/nx_client_storage_emit V-CLIENT-STORE-3/4 emits JS that 39// serializes/deserializes NSAV via this 40// bench/nx_client_store_smoke V-CLIENT-STORE-6 vector tests 41// 42// WINNER-TIER: BASELINE-A provisional (format simplicity is the 43// objective; competing with no incumbent sovereign 44// cross-game save format) 45// INCUMBENTS: Steam Cloud (proprietary; per-game schema), iCloud 46// key-value store (Apple proprietary), Google Play Games 47// SnapshotData (proprietary blob with metadata) 48// NUMBERS: per-save cost: 1 sha256 (game_id derive if not cached) + 49// 1 hmac_sha256 over (56 + payload) bytes (~1ms for 50// typical save sizes < 64KB) 51// GAP: encryption (bit 0 of flags) deferred V+1 with 52// ChaCha20-Poly1305 + separate KDF; current V1 is 53// integrity-only 54 55import "nx_syscalls.nx" 56import "nx_hmac.nx" 57import "sha256.nx" 58 59// ===== Sealed verdict surface (codes 1360-1379) ================================================= 60const NX_NSAV_OK: i64 = 0 61const NX_NSAV_BAD_INPUT: i64 = 1360 62const NX_NSAV_BUF_OVERFLOW: i64 = 1361 63const NX_NSAV_MAGIC_MISMATCH: i64 = 1362 64const NX_NSAV_VERSION_UNSUPPORTED: i64 = 1363 65const NX_NSAV_GAME_ID_MISMATCH: i64 = 1364 66const NX_NSAV_HMAC_MISMATCH: i64 = 1365 67const NX_NSAV_TRUNCATED: i64 = 1366 68const NX_NSAV_PAYLOAD_TOO_LARGE: i64 = 1367 69const NX_NSAV_FLAG_UNSUPPORTED: i64 = 1368 70 71// ===== Named constants (per Cardinal M7) ================================================= 72const NX_NSAV_MAGIC_BYTES: i64 = 4 73const NX_NSAV_VERSION_BYTES: i64 = 2 74const NX_NSAV_FLAGS_BYTES: i64 = 2 75const NX_NSAV_GAME_ID_BYTES: i64 = 32 76const NX_NSAV_CREATED_AT_BYTES: i64 = 8 77const NX_NSAV_PAYLOAD_LEN_BYTES: i64 = 8 78const NX_NSAV_HMAC_BYTES: i64 = 32 79const NX_NSAV_HEADER_BYTES: i64 = 56 // sum of fields before payload 80const NX_NSAV_MIN_FILE_BYTES: i64 = 88 // header (56) + min payload (0) + hmac (32) 81 82const NX_NSAV_OFF_MAGIC: i64 = 0 83const NX_NSAV_OFF_VERSION: i64 = 4 84const NX_NSAV_OFF_FLAGS: i64 = 6 85const NX_NSAV_OFF_GAME_ID: i64 = 8 86const NX_NSAV_OFF_CREATED_AT: i64 = 40 87const NX_NSAV_OFF_PAYLOAD_LEN: i64 = 48 88const NX_NSAV_OFF_PAYLOAD: i64 = 56 89 90const NX_NSAV_VERSION_V1: i64 = 0x0001 91const NX_NSAV_FLAG_ENCRYPTED: i64 = 0x0001 // V+1 92const NX_NSAV_FLAGS_VALID_MASK: i64 = 0x0000 // V1 supports no flags yet 93 94const NX_NSAV_MAX_PAYLOAD_BYTES: i64 = 16777216 // 16 MiB; sanity cap 95 96const NX_NSAV_FILE_KEY_BYTES: i64 = 32 97 98// ===== I/O helpers ================================================= 99 100func _nsav_put_u16_be(out: *u8, v: i64) -> i64 { 101 if v < 0 { return 0 - NX_NSAV_BAD_INPUT } 102 if v > 65535 { return 0 - NX_NSAV_BAD_INPUT } 103 out[0] = ((v >> 8) & 0xFF) as u8 104 out[1] = (v & 0xFF) as u8 105 return NX_NSAV_OK 106} 107 108func _nsav_get_u16_be(b: *u8) -> i64 { 109 return ((b[0] as i64) << 8) | (b[1] as i64) 110} 111 112func _nsav_put_i64_be(out: *u8, v: i64) -> i64 { 113 out[0] = ((v >> 56) & 0xFF) as u8 114 out[1] = ((v >> 48) & 0xFF) as u8 115 out[2] = ((v >> 40) & 0xFF) as u8 116 out[3] = ((v >> 32) & 0xFF) as u8 117 out[4] = ((v >> 24) & 0xFF) as u8 118 out[5] = ((v >> 16) & 0xFF) as u8 119 out[6] = ((v >> 8) & 0xFF) as u8 120 out[7] = (v & 0xFF) as u8 121 return NX_NSAV_OK 122} 123 124func _nsav_get_i64_be(b: *u8) -> i64 { 125 return ((b[0] as i64) << 56) 126 | ((b[1] as i64) << 48) 127 | ((b[2] as i64) << 40) 128 | ((b[3] as i64) << 32) 129 | ((b[4] as i64) << 24) 130 | ((b[5] as i64) << 16) 131 | ((b[6] as i64) << 8) 132 | (b[7] as i64) 133} 134 135// ===== Game ID derivation ================================================= 136// 137// game_id = SHA-256("site_id|||game_name") 138// e.g., for nishifamily.com / chess: SHA-256("nishifamily|||chess") 139// 140// Triple-pipe separator prevents canonicalization ambiguity (e.g., 141// "abc"+"def" vs "ab"+"cdef" hashing same). 32-byte output binds saves 142// to a specific (site, game) tuple cryptographically. 143 144func nx_nsav_derive_game_id( 145 site_id: *u8, site_id_n: i64, 146 game_name: *u8, game_name_n: i64, 147 out_32: *u8 148) -> i64 { 149 if (site_id as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 150 if site_id_n < 1 { return 0 - NX_NSAV_BAD_INPUT } 151 if site_id_n > 256 { return 0 - NX_NSAV_BAD_INPUT } 152 if (game_name as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 153 if game_name_n < 1 { return 0 - NX_NSAV_BAD_INPUT } 154 if game_name_n > 256 { return 0 - NX_NSAV_BAD_INPUT } 155 if (out_32 as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 156 157 let sep_n: i64 = 3 158 let total_n: i64 = site_id_n + sep_n + game_name_n 159 let buf: *u8 = sys_mmap(total_n) 160 var i: i64 = 0 161 while i < site_id_n { buf[i] = site_id[i]; i = i + 1 } 162 buf[site_id_n + 0] = 0x7C as u8 // '|' 163 buf[site_id_n + 1] = 0x7C as u8 164 buf[site_id_n + 2] = 0x7C as u8 165 var j: i64 = 0 166 while j < game_name_n { 167 buf[site_id_n + sep_n + j] = game_name[j] 168 j = j + 1 169 } 170 sha256_digest(buf, total_n, out_32) 171 return NX_NSAV_OK 172} 173 174// ===== File key derivation ================================================= 175// 176// file_key = SHA-256("nishi-save|||" || game_id || passphrase) 177// 178// passphrase may be empty for V1 unencrypted saves; the key still 179// binds integrity to the specific game. 180 181func nx_nsav_derive_file_key( 182 game_id_32: *u8, 183 passphrase: *u8, passphrase_n: i64, 184 out_32: *u8 185) -> i64 { 186 if (game_id_32 as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 187 if passphrase_n < 0 { return 0 - NX_NSAV_BAD_INPUT } 188 if passphrase_n > 1024 { return 0 - NX_NSAV_BAD_INPUT } 189 if (out_32 as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 190 191 let prefix: *u8 = "nishi-save|||" as *u8 192 let prefix_n: i64 = 13 193 let total_n: i64 = prefix_n + NX_NSAV_GAME_ID_BYTES + passphrase_n 194 let buf: *u8 = sys_mmap(total_n) 195 var i: i64 = 0 196 while i < prefix_n { buf[i] = prefix[i]; i = i + 1 } 197 var j: i64 = 0 198 while j < NX_NSAV_GAME_ID_BYTES { 199 buf[prefix_n + j] = game_id_32[j] 200 j = j + 1 201 } 202 var k: i64 = 0 203 while k < passphrase_n { 204 buf[prefix_n + NX_NSAV_GAME_ID_BYTES + k] = passphrase[k] 205 k = k + 1 206 } 207 sha256_digest(buf, total_n, out_32) 208 return NX_NSAV_OK 209} 210 211// ===== Encode ================================================= 212// 213// Inputs: 214// game_id_32 derived via nx_nsav_derive_game_id 215// file_key_32 derived via nx_nsav_derive_file_key 216// created_at_unix caller-supplied epoch seconds (substrate-pure) 217// payload opaque game-defined bytes 218// payload_n length 219// out caller-allocated buffer (must hold at least 220// NX_NSAV_HEADER_BYTES + payload_n + NX_NSAV_HMAC_BYTES) 221// out_cap buffer capacity 222// out_total_n receives actual bytes written 223 224func nx_nsav_encode( 225 game_id_32: *u8, 226 file_key_32: *u8, 227 created_at_unix: i64, 228 payload: *u8, payload_n: i64, 229 out: *u8, out_cap: i64, 230 out_total_n: *i64 231) -> i64 { 232 if (game_id_32 as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 233 if (file_key_32 as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 234 if created_at_unix < 0 { return 0 - NX_NSAV_BAD_INPUT } 235 if (payload as i64) == 0 { if payload_n != 0 { return 0 - NX_NSAV_BAD_INPUT } } 236 if payload_n < 0 { return 0 - NX_NSAV_BAD_INPUT } 237 if payload_n > NX_NSAV_MAX_PAYLOAD_BYTES { return 0 - NX_NSAV_PAYLOAD_TOO_LARGE } 238 if (out as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 239 if (out_total_n as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 240 241 let total_n: i64 = NX_NSAV_HEADER_BYTES + payload_n + NX_NSAV_HMAC_BYTES 242 if total_n > out_cap { return 0 - NX_NSAV_BUF_OVERFLOW } 243 244 // Magic "NSAV" 245 out[0] = 0x4E as u8 // 'N' 246 out[1] = 0x53 as u8 // 'S' 247 out[2] = 0x41 as u8 // 'A' 248 out[3] = 0x56 as u8 // 'V' 249 250 // Version 251 _nsav_put_u16_be((out as i64 + NX_NSAV_OFF_VERSION) as *u8, NX_NSAV_VERSION_V1) 252 253 // Flags (V1 = 0) 254 _nsav_put_u16_be((out as i64 + NX_NSAV_OFF_FLAGS) as *u8, 0) 255 256 // game_id (32 bytes) 257 var i: i64 = 0 258 while i < NX_NSAV_GAME_ID_BYTES { 259 out[NX_NSAV_OFF_GAME_ID + i] = game_id_32[i] 260 i = i + 1 261 } 262 263 // created_at (i64 BE) 264 _nsav_put_i64_be((out as i64 + NX_NSAV_OFF_CREATED_AT) as *u8, created_at_unix) 265 266 // payload_len (i64 BE) 267 _nsav_put_i64_be((out as i64 + NX_NSAV_OFF_PAYLOAD_LEN) as *u8, payload_n) 268 269 // payload bytes 270 var j: i64 = 0 271 while j < payload_n { 272 out[NX_NSAV_OFF_PAYLOAD + j] = payload[j] 273 j = j + 1 274 } 275 276 // HMAC-SHA-256 over bytes[0..total_n - 32] 277 let signed_len: i64 = total_n - NX_NSAV_HMAC_BYTES 278 hmac_sha256(file_key_32, NX_NSAV_FILE_KEY_BYTES, 279 out, signed_len, 280 (out as i64 + signed_len) as *u8) 281 282 out_total_n[0] = total_n 283 return NX_NSAV_OK 284} 285 286// ===== Decode + verify ================================================= 287// 288// Validates: 289// - Magic "NSAV" 290// - Version supported 291// - Flags within valid mask (V1 = 0) 292// - game_id matches expected (caller derives + passes) 293// - payload_len consistent with file size 294// - HMAC verifies (rejects tampering OR wrong file_key) 295// 296// On success, writes payload bytes to caller's payload_out buffer 297// (if payload_out_cap large enough) and sets payload_out_n. 298 299func nx_nsav_decode_and_verify( 300 file_bytes: *u8, file_n: i64, 301 file_key_32: *u8, 302 expected_game_id_32: *u8, 303 payload_out: *u8, payload_out_cap: i64, 304 payload_out_n: *i64, 305 out_created_at_unix: *i64 306) -> i64 { 307 if (file_bytes as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 308 if file_n < NX_NSAV_MIN_FILE_BYTES { return 0 - NX_NSAV_TRUNCATED } 309 if (file_key_32 as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 310 if (payload_out_n as i64) == 0 { return 0 - NX_NSAV_BAD_INPUT } 311 312 // Magic 313 if file_bytes[0] != (0x4E as u8) { return 0 - NX_NSAV_MAGIC_MISMATCH } 314 if file_bytes[1] != (0x53 as u8) { return 0 - NX_NSAV_MAGIC_MISMATCH } 315 if file_bytes[2] != (0x41 as u8) { return 0 - NX_NSAV_MAGIC_MISMATCH } 316 if file_bytes[3] != (0x56 as u8) { return 0 - NX_NSAV_MAGIC_MISMATCH } 317 318 // Version 319 let version: i64 = _nsav_get_u16_be((file_bytes as i64 + NX_NSAV_OFF_VERSION) as *u8) 320 if version != NX_NSAV_VERSION_V1 { return 0 - NX_NSAV_VERSION_UNSUPPORTED } 321 322 // Flags (V1 supports 0 only; future versions widen the mask) 323 let flags: i64 = _nsav_get_u16_be((file_bytes as i64 + NX_NSAV_OFF_FLAGS) as *u8) 324 if (flags & (0xFFFF - NX_NSAV_FLAGS_VALID_MASK)) != 0 { 325 return 0 - NX_NSAV_FLAG_UNSUPPORTED 326 } 327 328 // game_id match (if caller provided expected; pass 0 to skip) 329 if (expected_game_id_32 as i64) != 0 { 330 var i: i64 = 0 331 while i < NX_NSAV_GAME_ID_BYTES { 332 if file_bytes[NX_NSAV_OFF_GAME_ID + i] != expected_game_id_32[i] { 333 return 0 - NX_NSAV_GAME_ID_MISMATCH 334 } 335 i = i + 1 336 } 337 } 338 339 // payload_len consistency 340 let payload_n: i64 = _nsav_get_i64_be((file_bytes as i64 + NX_NSAV_OFF_PAYLOAD_LEN) as *u8) 341 if payload_n < 0 { return 0 - NX_NSAV_TRUNCATED } 342 if payload_n > NX_NSAV_MAX_PAYLOAD_BYTES { return 0 - NX_NSAV_PAYLOAD_TOO_LARGE } 343 let expected_total: i64 = NX_NSAV_HEADER_BYTES + payload_n + NX_NSAV_HMAC_BYTES 344 if file_n != expected_total { return 0 - NX_NSAV_TRUNCATED } 345 346 // HMAC verify 347 let signed_len: i64 = file_n - NX_NSAV_HMAC_BYTES 348 let expected_hmac: *u8 = sys_mmap(NX_NSAV_HMAC_BYTES) 349 hmac_sha256(file_key_32, NX_NSAV_FILE_KEY_BYTES, 350 file_bytes, signed_len, expected_hmac) 351 var j: i64 = 0 352 while j < NX_NSAV_HMAC_BYTES { 353 if expected_hmac[j] != file_bytes[signed_len + j] { 354 return 0 - NX_NSAV_HMAC_MISMATCH 355 } 356 j = j + 1 357 } 358 359 // Extract created_at + payload 360 if (out_created_at_unix as i64) != 0 { 361 out_created_at_unix[0] = _nsav_get_i64_be( 362 (file_bytes as i64 + NX_NSAV_OFF_CREATED_AT) as *u8) 363 } 364 if (payload_out as i64) != 0 { 365 if payload_out_cap < payload_n { return 0 - NX_NSAV_BUF_OVERFLOW } 366 var k: i64 = 0 367 while k < payload_n { 368 payload_out[k] = file_bytes[NX_NSAV_OFF_PAYLOAD + k] 369 k = k + 1 370 } 371 } 372 payload_out_n[0] = payload_n 373 return NX_NSAV_OK 374}