@@ -398,30 +398,68 @@ def setUp(self):
398398 self .version = _define (self .h , "STORAGE_VERSION" )
399399 self .last_shipped = _define (self .h , "STORAGE_VERSION_LAST_SHIPPED" )
400400
401- def test_active_flash_format_is_v17 (self ):
402- """alpha writes V17, the same format shipped v7.14.1.
401+ def test_active_flash_format_is_v20 (self ):
402+ """7.16 writes V20. The bump is argued here, which is the point of the
403+ test: it asserts a LITERAL so that raising a header constant cannot
404+ quietly satisfy it.
403405
404- This literal is an INDEPENDENT witness, on purpose. The compile-time
405- assert in storage.c compares STORAGE_VERSION against
406+ The compile-time assert in storage.c compares STORAGE_VERSION against
406407 STORAGE_VERSION_LAST_SHIPPED -- two numbers in the same header, both
407- editable in one commit, and raising LAST_SHIPPED to make a build
408- compile is the exact edit docs/StorageVersionGate.md calls the highest
409- severity review item in the file.
410-
411- 7.15 reverted the flash format from V19 back to V17 (6bebde7b2). V19
412- migrated 17 -> 19 on the first boot, with no prompt, after which no
413- downgrade was possible without a wipe; V18's clear-sign identity block
414- is dead. The V19 serializer is still in the tree behind
415- STORAGE_PIN_KDF_V19 == 0. If a release re-lands it, this test must
416- fail and the bump must be argued for, not discovered in the field.
408+ editable in one commit -- so raising LAST_SHIPPED to make a build
409+ compile is the edit docs/StorageVersionGate.md calls the highest
410+ severity review item in the file. An independent witness is the only
411+ thing that catches it.
412+
413+ WHY 20 AND NOT 18. 18 was the clear-sign identity block and 19 the
414+ PIN-KDF migration. Both were ACTIVE, not merely drafted: e109404ee made
415+ 19 live and 6bebde7b2 reverted the format to V17 for 7.15. Any device
416+ that ran an alpha build in that window carries a blob stamped 18 or 19
417+ whose layout has nothing to do with passkeys, and reading one as CTAP2
418+ state would misparse it rather than refuse it. 20 is unburned.
419+
420+ THE READER CHAIN. V17 blobs are read by storage_readV17 and restamped
421+ to STORAGE_VERSION; V20 blobs by storage_readV20. There is deliberately
422+ NO reader for 18 or 19: they remain in the ladder because the enum is
423+ positional and removing an entry renumbers everything after it, but a
424+ blob stamped with either falls through to the default and the device
425+ wipes. That is the documented behaviour for an unrecognised format and
426+ is strictly better than misparsing one.
427+
428+ ANTI-ROLLBACK. Once a device writes V20, installing 7.15 -- which knows
429+ only up to V17 -- maps the blob to StorageVersion_NONE and storage_init
430+ resets it. The device wipes. That is normal downgrade behaviour and is
431+ stated here so it is a known consequence rather than a field report.
432+ A signed UPGRADE never wipes; only going backwards does.
433+
434+ RELEASE NOTE. "7.16 changes the on-device storage format to hold
435+ passkey credentials. Upgrading preserves your wallet. Downgrading to
436+ 7.15 or earlier will ERASE it -- back up your recovery phrase before
437+ downgrading."
417438 """
418439 self .assertEqual (
419- 17 , self .version ,
420- "STORAGE_VERSION is %d, not the V17 format 7.15 reverted to . A bump "
440+ 20 , self .version ,
441+ "STORAGE_VERSION is %d, not the V20 format 7.16 introduces . A bump "
421442 "is a deliberate release act (docs/StorageVersionGate.md): confirm "
422443 "the reader chain, the anti-rollback story, and the release notes, "
423444 "then update this test." % self .version )
424- self .assertEqual (17 , self .last_shipped )
445+ self .assertEqual (20 , self .last_shipped )
446+
447+ def test_burned_versions_have_no_reader (self ):
448+ """18 and 19 must never be parsed by 7.16.
449+
450+ They were real formats in alpha builds before the 7.15 revert, so
451+ devices carrying them exist. A reader for either would parse a
452+ clear-sign identity block or a PIN-KDF blob as passkey state. The
453+ absence of a case in the dispatch is what sends them to the wipe path,
454+ and this test is what stops one being added back by someone tidying up
455+ the switch.
456+ """
457+ self .assertNotIn ("case StorageVersion_18:" , self .c ,
458+ "18 is a burned format; a reader would misparse blobs "
459+ "written by pre-revert alpha builds" )
460+ self .assertNotIn ("case StorageVersion_19:" , self .c ,
461+ "19 is a burned format; a reader would misparse blobs "
462+ "written by pre-revert alpha builds" )
425463
426464 def test_version_never_drops_below_a_shipped_release (self ):
427465 """Lowering STORAGE_VERSION wipes every device upgrading FROM a shipped
0 commit comments