aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
commit97a366e8b7c540b246c7bf9d04625526cbdc88ba (patch)
tree131e2574a4ca927f99a6d82cc1117d45343e80b2
parent8ac04a0c2f5cd949a45866f938bd1aa72fbe56e2 (diff)
docs: document the storage format and record M3 results; bump to 0.3.0v0.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md24
-rw-r--r--README.md8
-rw-r--r--docs/DEVELOPER_GUIDE.md3
-rw-r--r--docs/FEASIBILITY.md6
-rw-r--r--docs/PERFORMANCE.md16
-rw-r--r--docs/SECURITY.md150
-rw-r--r--docs/TESTING.md13
-rw-r--r--source/app/AppInfo.mc2
-rw-r--r--test/golden/fr255s.pngbin1106 -> 1121 bytes
-rw-r--r--test/golden/fr70.pngbin5383 -> 5435 bytes
-rw-r--r--test/golden/fr965.pngbin7487 -> 7492 bytes
-rw-r--r--test/golden/venusq2.pngbin1684 -> 1704 bytes
12 files changed, 218 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0cee853..c4a7737 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,30 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
+## [0.3.0] - 2026-10-19
+
+Milestone M3: storage, keys and PIN logic. Nothing user-visible yet; the
+screens come with M4.
+
+### Added
+
+- Storage (`source/storage/`): the vault (ordered entries, add, update, move,
+ remove, HOTP counter), the deterministic TLV codec, the record format (readable
+ header plus an encrypt-then-MAC box bound to the entry ID), the device key and
+ the PIN-wrapped master key, resumable PIN derivation, the wrong-PIN lockout
+ (trusted clock with an uptime fallback, optional erase after 10 failures), the
+ session with auto-lock and key wiping, the pending queue for locked entries,
+ schema migrations, the 150-entry cap and clean store-full errors.
+- `Bytes.toUtf8` with strict UTF-8 validation.
+- `docs/SECURITY.md`: the stored byte formats and what each protection is
+ tested by.
+- 34 Monkey C tests (65 in total per device) and storage benchmarks.
+
+### Changed
+
+- The PIN wrap and the lockout state are stored under their own keys (`w`,
+ `lk`) instead of inside the metadata (README 6.2 updated).
+
## [0.2.0] - 2026-10-18
Milestones M1 (prepared; your phone test pending) and M2: crypto and OTP core.
diff --git a/README.md b/README.md
index 806d3df..9c1543d 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
A Garmin Connect IQ watch app that shows one-time passwords (TOTP, HOTP, and optional Steam Guard codes) and stores barcodes and QR codes (loyalty cards, tickets) to show at a scanner. Everything lives encrypted on the watch. There is no cloud, no backend and no analytics, and the app requests no permissions, so it cannot use the network.
-> **Status: M2 (crypto and OTP core) done; M1 (settings spike) ready for your phone test.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline; M2 added SHA-512, HMAC, PBKDF2, HKDF, AES-256 records, Base32, HOTP, TOTP and Steam codes, verified against the official test vectors on four simulated watches. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test; M3 (storage) continues meanwhile. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md); the daily workflow is in [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
+> **Status: M3 (storage, keys and PIN logic) done; M1 (settings spike) ready for your phone test.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline. M2 added the crypto and OTP core (SHA-512, HMAC, PBKDF2, HKDF, AES-256 records, Base32, HOTP, TOTP, Steam), verified against official test vectors. M3 added the encrypted vault: records, device and PIN keys, wrong-PIN lockout, session auto-lock, pending entries and migrations ([docs/SECURITY.md](docs/SECURITY.md)). Next is M4, the first real screens. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md); the daily workflow is in [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
"OTP Wallet" is a working title.
@@ -201,14 +201,16 @@ Design rules:
| Key | Content | Protected by |
| --- | --- | --- |
-| `m` | Metadata: schema version, entry index (the order of IDs), KDF salt and iteration count, wrapped locked master key, lockout state, hashes of applied forms | The wrapped key is encrypted. The rest is not secret |
+| `m` | Metadata: schema version, entry index (the order of IDs), pending IDs, hashes of applied forms (M7) | Not secret |
| `dk` | Device key, 32 random bytes | Stored as is ([7.4](#74-what-stays-readable)) |
+| `w` | KDF salt, iteration count and the wrapped locked master key | The PIN key |
+| `lk` | Wrong-PIN count and delay | Not secret |
| `e.<id>` | One entry record | Device key, or the locked master key for locked entries |
| `x.<id>` | Cached barcode matrix for a code entry | The same key as its entry |
| `p.<id>` | Pending locked entry, waiting for your PIN | Device key ([7.7](#77-pending-locked-entries)) |
| `imp` | Partial import text, kept between syncs | Device key |
-Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). The entry cap, initially 150, is set after M3 measures the real record size. A full store produces a clear message, never a crash.
+Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). M3 measured 239 bytes for a typical OTP record, so the entry cap stays at 150 (byte formats in [docs/SECURITY.md](docs/SECURITY.md)). A full store produces a clear message, never a crash.
### 6.3 Schema versions and migration
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index eacea84..283e51a 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -75,6 +75,9 @@ The simulator is a GTK program. The scripts start it on a virtual display (Xvfb
- Native HMAC supports only SHA-256; HMAC-SHA1 and HMAC-SHA512 go through `Hmac.generic`.
- Function calls are expensive in the VM; hot loops inline small helpers (SHA-512 went 37 % faster that way).
- The simulator doesn't enforce the watchdog in test runs. Anything long runs as a `Jobs.Job` with a bounded step.
+- `StringUtil.convertEncodedString` stops the app with an uncatchable "System Error" on invalid UTF-8; `try` doesn't help. Decode untrusted bytes with `Bytes.toUtf8`, which validates first.
+- `Application.Storage` stores ByteArrays (its `ValueType` includes them, unlike `Application.PropertyValueType`), so records are stored as single ByteArrays of known size. Dictionaries passed to it must be typed `Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>`; `Dictionary<String, ...>` doesn't type-check.
+- `Array.slice` returns `Array<Object or Null>`; cast it back (`as Array<String>`).
### Screenshots
diff --git a/docs/FEASIBILITY.md b/docs/FEASIBILITY.md
index 8665139..c16b0ce 100644
--- a/docs/FEASIBILITY.md
+++ b/docs/FEASIBILITY.md
@@ -261,6 +261,12 @@ With SDK 9.2.0 and the device files of all 72 products installed:
- **64-bit arithmetic:** `Long` overflow wraps like unsigned arithmetic, `>>` copies the sign bit, and hex literals above 2^63 parse correctly, which is what the SHA-512 implementation relies on. ByteArrays throw on values above 255 rather than wrapping.
- **Watchdog in the simulator:** not enforced in test runs (a 300,000-iteration loop passed on a 120,000-bytecode profile), so the job design is the protection and the real limits are checked on the watch.
+## Verified during M3 (2026-10-19)
+
+- **Storage (4): ByteArrays are storable.** `Application.Storage.ValueType` includes `ByteArray`, so each record is one value whose size is known exactly. A typical OTP record is 239 bytes; the entry cap stays at 150 ([SECURITY.md](SECURITY.md)).
+- **Invalid UTF-8 crashes the string conversion.** `StringUtil.convertEncodedString` stops the app with an uncatchable system error instead of throwing; stored text is validated before decoding.
+- **Store-full handling is tested only against a simulated store.** The in-memory test store throws `StorageFullException` at a set size. How the real store behaves when full is checked on the watch (real-watch checklist in [TESTING.md](TESTING.md)).
+
## Decisions
Answered on 2026-10-18:
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index daab3c2..fbc9709 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -25,6 +25,22 @@ The targets are in [README section 18](../README.md#18-performance-budgets). Thi
## Results
+### M3, 2026-10-19 (version 0.3.0, storage)
+
+`test/BenchTest.mc` with an in-memory store, simulator in Docker, Forerunner 70 profile:
+
+| Operation | Time | Notes |
+| --- | --- | --- |
+| Read 20 entry headers | 6.8 ms | 0.34 ms per header, mostly TLV decoding in Monkey C |
+| Open one entry (verify + decrypt) | 1.05 ms | Device-key entry |
+| Add and remove one entry | 1.4 ms | Seal, two metadata writes |
+
+Record sizes: 239 bytes for a typical OTP entry, 208 for a short code, 4,279 for the largest allowed payload.
+
+`make perf` and `make memtest`: in-app start-up 1 to 6 ms, and launch to first frame 10.5 s, unchanged. Memory after the first frame rose to 4.0 % (768 KB watches) and 5.9 % (Forerunner 255S), because the debug program is larger; the app doesn't open the vault yet. The signed package for 72 products is 2.5 MB.
+
+Consequence for M4: reading 150 headers would take about 50 ms in the simulator, and on the watch probably many times that. The list will cache headers and read only the visible rows when it opens, instead of reading all of them at start-up.
+
### M2, 2026-10-18 (version 0.2.0, crypto and OTP primitives)
`test/BenchTest.mc`, simulator in Docker, average per call (identical within noise on all four test devices):
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
new file mode 100644
index 0000000..6a30397
--- /dev/null
+++ b/docs/SECURITY.md
@@ -0,0 +1,150 @@
+<!--
+ Author: cirakg & claude ai
+ Summary: How stored data is protected: storage keys, the record and PIN-wrap byte formats, lockout, session, migration and limits.
+-->
+# Security and storage format
+
+This document records how the watch stores your data, byte for byte. The design rationale is in [README section 7](../README.md#7-security-design) and the threat model in [section 8](../README.md#8-threat-model). The code is in `source/storage/` and `source/crypto/`. Every claim below is covered by a unit test in `test/storage/` or `test/crypto/`.
+
+## Storage keys
+
+Everything lives in the app's private `Application.Storage`:
+
+| Key | Type | Content | Written by |
+| --- | --- | --- | --- |
+| `m` | dictionary | `schema` (data version, now 1), `ids` (entry IDs in display order), `pending` (IDs of pending locked entries) | `Vault`, `Migration` |
+| `dk` | 32 bytes | Device key, random, created on first use | `KeyManager` |
+| `w` | bytes | PIN wrap of the locked master key (format below); absent until a PIN is set | `KeyManager` |
+| `lk` | dictionary | Wrong-PIN state: `f` failures, `d` delay in seconds, `t` trusted time of the last attempt; absent after a success | `Lockout` |
+| `e.<id>` | bytes | One entry record (format below) | `Vault` |
+| `p.<id>` | bytes | Pending locked entry: a record sealed with the device key | `Vault` |
+
+`x.<id>` (barcode cache, M6) and `imp` (import buffer, M5) are added by later milestones.
+
+The PIN wrap and the lockout state have their own keys instead of living in `m` as README 6.2 first planned. Each wrong PIN rewrites the lockout state before the check, and a small separate value makes that write cheap and keeps it from risking the entry index.
+
+## Keys
+
+```text
+device key (32 random bytes, "dk") --HKDF--> Kenc, Kmac seals unlocked entries and pending items
+PIN --PBKDF2-HMAC-SHA256--> PIN key (RAM only)
+PIN key --HKDF--> Kenc, Kmac seals the master key in "w"
+master key (32 random bytes, only in "w", wrapped) --HKDF--> Kenc, Kmac seals locked entries
+```
+
+The HKDF labels are `OTPW enc v1` and `OTPW mac v1` (empty salt). Every sealed value is a SealedBox:
+
+```text
+SealedBox = 0x01 | IV (16 random bytes) | AES-256-CBC(Kenc, IV, PKCS#7(plaintext)) | tag (32 bytes)
+tag = HMAC-SHA256(Kmac, "OTPW-REC-1" || len(aad) as 4 bytes, big-endian || aad || 0x01 || IV || ciphertext)
+```
+
+The tag is checked in constant time before anything is decrypted. Any failure (wrong key, changed byte, truncation, unknown version) gives the same "can't open" result.
+
+## Entry record, version 1
+
+```text
+offset 0 v 1 byte record format version (1)
+offset 1 kr 1 byte 0 = sealed with the device key, 1 = with the master key
+offset 2 hlen 2 bytes header length, big-endian
+offset 4 header hlen TLV of the header fields, readable without a key
+then box SealedBox of the TLV of the sensitive fields
+aad = UTF-8 ID || v || kr || hlen || header
+```
+
+TLV field: `tag (1 byte) | type (1 byte) | length (2 bytes, big-endian) | value`. The types are 1 bytes, 2 UTF-8 text, 3 64-bit signed integer (8 bytes, big-endian) and 4 boolean (1 byte, 0 or 1). Fields are written in tag order, so the encoding is canonical. Decoding is strict: truncated fields, unknown types, wrong lengths, duplicate tags, invalid UTF-8 and NUL characters all make the record count as damaged.
+
+| Tag | Field | Type | Where |
+| --- | --- | --- | --- |
+| 1 | ID | text | header |
+| 2 | kind (0 OTP, 1 code) | int | header |
+| 3 | type (0 TOTP, 1 HOTP, 2 Steam) or symbology | int | header |
+| 4, 5, 6 | name, account, group | text | header, unless "Encrypt names" is on |
+| 7 | locked | bool | header |
+| 8 | source (0 manual, 1 import) | int | header |
+| 9 | favourite | bool | header |
+| 10 | HOTP counter | int | header |
+| 11 | created (unix seconds) | int | header |
+| 12 | names encrypted | bool | header |
+| 20, 21, 22 | algorithm (0 SHA-1, 1 SHA-256, 2 SHA-512), digits, period | int | box (OTP) |
+| 23 | secret (raw bytes) | bytes | box (OTP) |
+| 24 | payload (UTF-8 bytes) | bytes | box (code) |
+| 25 | options | int | box (code) |
+| 26, 27, 28 | name, account, group | text | box, when "Encrypt names" is on |
+
+What the aad gives you:
+
+- **The ID is bound.** A record copied or swapped under another `e.<id>` key doesn't open (`vaultDetectsSwappedRecords`).
+- **The header is bound.** Changing the HOTP counter, the locked flag, a name or `kr` makes the record fail to open. Every single-byte change of a record is detected (`recordDetectsEveryTamperedByte`).
+- **The header is not verified when only the list is drawn.** Reading headers needs no key, so the list can show names without decrypting anything. Someone who can write to app storage could change a displayed name, but could also read the device key stored next to it, so a header MAC with that key would add nothing. The record fails to open as soon as its code is shown.
+- **The lock state must agree.** The vault reports a record as damaged unless the locked flag in its header matches `kr`.
+
+### Sizes and the entry cap
+
+Measured by `recordSizesAreSmall` with fake data (M3):
+
+| Record | Size |
+| --- | --- |
+| OTP entry ("GitHub", an account, a group, 20-byte secret) | 239 bytes |
+| Code entry (a 24-byte payload) | 208 bytes |
+| Code entry with the largest allowed payload (4096 bytes) | 4,279 bytes |
+
+The per-value limit is 8 KB, and records over 8,000 bytes are refused. **The entry cap stays at 150.** That many OTP entries take about 36 KB, well under the conservative 100 KB total (README 6.2). Large barcode payloads can fill the store before the cap is reached. The vault then reports "storage full" cleanly, and nothing is half-written (`vaultHandlesAFullStore`). The field limits are 64 bytes for name and account, 32 bytes for the group, 128 bytes for an OTP secret and 4,096 bytes for a payload.
+
+## PIN wrap, version 1
+
+```text
+"w" = salt (16 random bytes) | n (4 bytes, big-endian) | SealedBox(Keys(PIN key), aad, master key)
+aad = "OTPW-WRAP-1" || salt || n
+PIN key = PBKDF2-HMAC-SHA256(PIN as ASCII digits, salt, n, 32 bytes)
+```
+
+- **A correct PIN is exactly "the box opens".** No PIN hash is stored. Changing the salt or `n` makes the box fail to open (`pinWrapIsAuthenticated`).
+- **`n` must lie between 2,000 and 200,000** or the wrap counts as damaged before any derivation. A tampered huge count can't hang the watch.
+- **PINs are 4 to 10 digits.** `n` is calibrated on the watch to about one second (UI in M4).
+- **Changing the PIN re-wraps the same master key.** Entries are not re-encrypted (`pinChangeKeepsEntries`).
+- **Reset PIN deletes things the PIN protected:** the wrap, every locked entry and every pending item. Unlocked entries stay (`pinResetErasesOnlyLockedData`).
+
+## Wrong PINs
+
+`Lockout` implements README 7.6:
+
+- **Attempts are counted before the check.** Each attempt is written to `lk` before the PIN is checked, so killing the app mid-check still costs an attempt. A success deletes `lk`.
+- **Delays:** four free attempts, then 30 s after the 5th failure, doubling up to a 1 hour cap (`lockoutDelaySchedule`). An attempt during a delay is refused and not counted.
+- **Delays use the trusted (GPS-synced) clock when it is available,** so restarting the app doesn't shorten them (`lockoutBlocksAndSurvivesRestart`).
+- **Without a synced trusted clock, a delay counts system uptime.** It restarts in full when the app restarts or the system timer goes backwards: never shorter, sometimes longer (`lockoutUsesUptimeWithoutTrustedClock`).
+- **An unreadable `lk` is not a reset:** no free attempts remain (`lockoutTreatsDamagedStateConservatively`). Deleting `lk` does reset the count, but only someone who can already write app storage can do that, and app storage isn't reachable from outside the app (threat model).
+- **With "Erase locked entries after 10 wrong PINs" on,** the 10th failure deletes the wrap, locked entries and pending items (`pinEraseAfterTenFailures`).
+
+## Session
+
+- **Unlocking puts the master key in RAM** and derives its two keys there.
+- **`lock()` zeroes all three and drops them** (`sessionAutoLocksAndWipesKeys`).
+- **Auto-lock:** `checkExpiry()` locks after the inactivity time, default 1 minute, where 0 means immediately. A system timer that goes backwards counts as expired.
+
+## Pending locked entries
+
+A locked entry that arrives while the session is locked, or before a PIN exists, is sealed with the device key under `p.<id>` and listed in `m.pending`. It doesn't appear in the list. After the next unlock, `promotePending()` re-seals it with the master key, writes `e.<id>`, updates the index and only then deletes `p.<id>` (`vaultQueuesLockedEntriesWhileLocked`).
+
+## Write order and crashes
+
+- **Adding:** the record is written first, the index second. If the index write fails, the record is deleted again. A crash between the two leaves an unreferenced record, never an index entry without a record.
+- **Deleting:** the index is written first, then the record is deleted.
+- **Store full:** a failed write becomes VaultError FULL with nothing half-added (tested at many fill levels, so that both the record write and the index write fail in turn).
+
+## Migration
+
+- **`m.schema` is the data version.** `Migration.run()` creates fresh metadata on first start and migrates older schemas step by step. It refuses newer schemas (VaultError NEWER_SCHEMA) and damaged metadata (DAMAGED) without changing them (`migrationRefusesNewerAndDamagedMetadata`).
+- **Version 0 never shipped.** It is a fixture layout (`m = {"order": [...]}`) that tests the migration machinery (`migrationV0ToV1`).
+- **The 0-to-1 step changes only the metadata, in one write.** Future migrations that rewrite records must write each new record under a temporary key and switch the index before deleting the old one (README 6.3).
+
+## Memory hygiene and its limits
+
+- **Keys and decrypted secrets are ByteArrays and are zeroed after use:** `Entry.wipeSecrets()`, `Session.lock()`, `SealedBox.Keys.wipe()`, and the PBKDF2 job's buffers. The tests check that the original buffers are all zeros afterwards.
+- **What can't be wiped:**
+ - Monkey C strings (names, and the PIN as typed).
+ - The native HMAC object's internal copy of its key.
+ - Any copy the VM makes.
+
+ This is best effort, as README 7.8 says.
+- **Invalid UTF-8 is checked before decoding.** `StringUtil.convertEncodedString` stops the app with an uncatchable error on invalid UTF-8, so `Bytes.toUtf8` validates first, and damaged text can't crash the app.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 98d984c..a38fd76 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -26,6 +26,19 @@ Test devices (`TEST_DEVICES`): Forerunner 255S (weakest: 218 px round MIP, butto
## Results
+### M3, 2026-10-19 (version 0.3.0)
+
+`make ci` passed in full. Monkey C unit tests: 65 of 65 on each of the four test devices. The 34 new tests in `test/storage/` and `test/util/` cover:
+
+- **Codec:** round trips of every TLV type, and rejection of malformed input (truncation, unknown types, wrong lengths, duplicate tags, invalid UTF-8).
+- **Records:** round trips of TOTP, HOTP (64-bit counter), Steam and code entries, with and without encrypted names. Every single-byte change of a record is detected, as are wrong IDs, wrong keys and truncation. Entry validation and wiping are covered, and record sizes are measured.
+- **Vault:** persistence across a restart, update, move, remove, the HOTP counter, damaged and swapped records, locked entries with the session locked, the pending queue, the 150-entry cap, and a full store at 67 fill levels with no half-written state.
+- **PIN:** create, unlock and wrong PINs; malformed PINs; the delay schedule; lockout across an app restart on the trusted clock; uptime fallback with restart and timer reset; damaged lockout state; erase after 10 failures; PIN change (entries untouched) and reset; a tampered wrap; auto-lock with zeroed keys; wiped PBKDF2 buffers.
+- **Migration:** fresh metadata, the v0 fixture to v1 (idempotent), and refusal of newer or damaged metadata without changing it.
+- **UTF-8 validation:** overlong forms, surrogates, code points above U+10FFFF, truncation, NUL.
+
+Python tool tests: 44 passed.
+
### M2, 2026-10-18 (version 0.2.0)
`make ci` passed in full. Monkey C unit tests: 31 of 31 on each of the four test devices, covering 57 hash vectors, 21 HMAC vectors, 5 PBKDF2, 3 HKDF, 2 AES-CBC (encrypt and decrypt), 12 Base32, 100 HOTP/TOTP and 12 Steam vectors, plus tamper detection on every byte of a sealed record, PKCS#7 edge cases, Base32 error positions, period boundaries, the 2038 widening and the job runner. Python tool tests: 44 passed.
diff --git a/source/app/AppInfo.mc b/source/app/AppInfo.mc
index 1dc82cf..ab65f57 100644
--- a/source/app/AppInfo.mc
+++ b/source/app/AppInfo.mc
@@ -12,5 +12,5 @@ import Toybox.Lang;
module AppInfo {
// Semantic version of the app: MAJOR.MINOR.PATCH.
- const VERSION = "0.2.0";
+ const VERSION = "0.3.0";
}
diff --git a/test/golden/fr255s.png b/test/golden/fr255s.png
index fa5b0e5..66128b3 100644
--- a/test/golden/fr255s.png
+++ b/test/golden/fr255s.png
Binary files differ
diff --git a/test/golden/fr70.png b/test/golden/fr70.png
index f629cf6..d7bc624 100644
--- a/test/golden/fr70.png
+++ b/test/golden/fr70.png
Binary files differ
diff --git a/test/golden/fr965.png b/test/golden/fr965.png
index 5b49651..744baa8 100644
--- a/test/golden/fr965.png
+++ b/test/golden/fr965.png
Binary files differ
diff --git a/test/golden/venusq2.png b/test/golden/venusq2.png
index 028e185..08f9225 100644
--- a/test/golden/venusq2.png
+++ b/test/golden/venusq2.png
Binary files differ