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}