From bc4cc19df5f53519ca1aabd3cf161b6d78ffc16c Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 27 Oct 2026 01:01:18 +0100 Subject: fix(pin): the work after a PIN and the change queue run in steps After a correct or new PIN, promoting pending entries and applying waiting "Require PIN" changes ran in the event of the last PBKDF2 step; 16 waiting changes tripped the watchdog on the Forerunner 70. ChangeQueue.LockJob now does it in steps while the PIN screen shows "Checking...", saves what it did to the results and refreshes the glance after Set PIN too. The list title decrypted every waiting change at each start. Queue items are now [action, box] (aad OTPW-Q-2 + action code), so they are counted without decrypting; 0.9.0 items are still read. A replacement is checked when it arrives and its material is sealed apart (qm.). Large buffers are wiped and UTF-8-checked in pieces (Jobs.Wiper, Bytes.utf8Prefix, SealedBox.openKeep/sealKeep). "Phone changes" reads behind "Reading...", and Confirm all / Reject all act only on the changes shown. Storage full no longer stops the app at the PIN. Found by the review of b8addc8/096b5a4 and a new make watchdog test; the diff was reviewed again and its findings fixed. fr70: make lint, make test (176), make watchdog (19 cases at two steps per event), make screenshots pass; list_locked and list_reset goldens show "Results ready (menu)". Co-Authored-By: Claude Opus 5.5 --- source/settings/ChangeQueue.mc | 553 ++++++++++++++++++++++++++++++++--------- 1 file changed, 430 insertions(+), 123 deletions(-) (limited to 'source/settings/ChangeQueue.mc') diff --git a/source/settings/ChangeQueue.mc b/source/settings/ChangeQueue.mc index 5de74e0..dc6facd 100644 --- a/source/settings/ChangeQueue.mc +++ b/source/settings/ChangeQueue.mc @@ -4,15 +4,25 @@ // Anyone who controls the phone can send settings, so edits, new secrets, // "Don't require PIN" and deletes of locked entries are not applied when // they arrive: they wait here until someone confirms them with the PIN on -// the watch (or rejects them). Each item is one ManageAction sealed on its -// own with the device key (aad "OTPW-Q-1"), so a damaged item costs only -// itself. A new secret in an item is no more exposed than it was in the -// settings it came from, as with pending entries (README 7.7). +// the watch (or rejects them). A new secret in an item is no more exposed +// than it was in the settings it came from, as with pending entries +// (README 7.7). +// +// Each item is [action code, box] or, for a REPLACE, [action code, box, +// material number]: one ManageAction sealed on its own with the device key +// (aad "OTPW-Q-2" and the code byte, so a changed code doesn't open), so a +// damaged item costs only itself. Counting reads only the codes. The new +// secret or contents of a REPLACE (checked before queueing, up to 4 KB) are +// a storage value of their own, "qm.", sealed with an aad naming the +// number and the entry, opened only when that action is applied, and +// deleted with its item. Items from 0.9.0 are a bare box (aad "OTPW-Q-1", +// material inside) and stay readable; they count as "to confirm". // // "Require PIN" waits here only while the watch is locked (it needs the -// master key). It only adds protection, so applyLocks() applies it at the -// next correct (or newly set) PIN, without "Confirm all", the way pending -// entries are promoted. +// master key). It only adds protection, so LockJob applies it at the next +// correct (or newly set) PIN, without "Confirm all", the way pending +// entries are promoted. Every walk that opens items is a job, one item per +// step (the watchdog: opening an item is a few byte loops). import Toybox.Application; import Toybox.Lang; @@ -21,78 +31,202 @@ module ChangeQueue { const KEY = "q"; const MAX_ITEMS = 16; - const AAD = "OTPW-Q-1"; + const AAD = "OTPW-Q-2"; + // Items queued by 0.9.0. + const AAD_V1 = "OTPW-Q-1"; + const MATERIAL_KEY = "qm."; + const MATERIAL_AAD = "OTPW-QM-1"; - // What applyLocks() did at the last PIN, for the results of a "Confirm - // all" that follows it (ChangeQueueJob). Replaced at every PIN. + // What LockJob did at this PIN, for the results of a "Confirm all" that + // follows it (ChangeQueueJob). A PIN's first LockJob starts them afresh, + // a legacy PIN's second one (the new PIN) adds to them, and the PIN + // screen takes them when it closes, however it closes (they are in the + // results by then). var _lockLines as Array = [] as Array; // countToConfirm()'s cached result and the vault it is for. var _toConfirm as Number? = null; var _toConfirmFor as Vault? = null; - function count(vault as Vault) as Number { + // The stored items (not opened). + function items(vault as Vault) as Array { var stored = vault.storage().getValue(KEY); - return stored instanceof Array ? stored.size() : 0; + return stored instanceof Array ? stored as Array : [] as Array; + } + + function count(vault as Vault) as Number { + return items(vault).size(); } - // Append an action. Throws VaultError LIMIT (too many waiting) or FULL. + // Append an action (a REPLACE's material goes to a value of its own). + // Throws VaultError LIMIT (too many waiting) or FULL. function add(vault as Vault, action as ManageAction) as Void { - var stored = vault.storage().getValue(KEY); - var items = stored instanceof Array ? stored as Array : [] as Array; - if (items.size() >= MAX_ITEMS) { + addWith(vault, action, null); + } + + // As add(); the copy sealing makes of up to 4 KB of material goes to + // `wiper` when given (ManageRun wipes it in steps). + function addWith(vault as Vault, action as ManageAction, wiper as Jobs.Wiper?) as Void { + var stored = items(vault); + if (stored.size() >= MAX_ITEMS) { throw new VaultError(VaultError.LIMIT, MAX_ITEMS + " changes already wait for your PIN. Confirm or reject them on the watch first"); } + var keys = vault.keyManager.deviceKeys(); + var material = action.material; + var ref = material != null ? freeRef(stored) : -1; + if (material != null) { + try { + var leftovers = [] as Array; + vault.storage().setValue(MATERIAL_KEY + ref, SealedBox.sealKeep(keys, materialAad(ref, action.id), material, + wiper != null ? leftovers : null)); + if (wiper != null) { + wiper.addAll(leftovers); + } + } catch (e instanceof Lang.Exception) { + throw new VaultError(VaultError.FULL, "storage full"); + } + } + action.material = null; + action.ref = ref; var plain = action.toBytes(); - items.add(SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(AAD), plain)); + action.material = material; + action.ref = -1; + var code = action.action; + var item = [code, SealedBox.seal(keys, itemAad(code), plain)] as Array; + if (ref >= 0) { + item.add(ref); + } Bytes.wipe(plain); + stored.add(item as Application.Storage.ValueType); changed(); try { - vault.storage().setValue(KEY, items); + vault.storage().setValue(KEY, stored); } catch (e instanceof Lang.Exception) { + if (ref >= 0) { + vault.storage().deleteValue(MATERIAL_KEY + ref); + } throw new VaultError(VaultError.FULL, "storage full"); } } - // The waiting actions in arrival order; items that don't verify are - // left out (and dropped by the next clear()). - function load(vault as Vault) as Array { - var out = [] as Array; - var stored = vault.storage().getValue(KEY); - if (!(stored instanceof Array)) { - return out; + // The action code of a stored item, read without opening it; -1 for a + // 0.9.0 item or a malformed one. + function codeOf(item as Application.Storage.ValueType) as Number { + if (item instanceof Array && item.size() >= 2 && item[0] instanceof Number) { + return item[0] as Number; + } + return -1; + } + + // The material number of a stored item, -1 for none. + function refOf(item as Application.Storage.ValueType) as Number { + if (item instanceof Array && item.size() >= 3 && item[2] instanceof Number) { + return item[2] as Number; } + return -1; + } + + // Open one stored item: the action, or null when it doesn't verify. + // With `material`, a REPLACE's material is opened too (null when it + // doesn't verify); without, it isn't (descriptions, locks). A 0.9.0 item + // always has its material inside (release() hands it to the wiper). + // Kilobyte buffers to zero go to `wiper`, for the caller's next steps. + function open(vault as Vault, item as Application.Storage.ValueType, wiper as Jobs.Wiper, material as Boolean) as ManageAction? { var keys = vault.keyManager.deviceKeys(); - var aad = Bytes.fromUtf8(AAD); + var leftovers = [] as Array; + if (item instanceof ByteArray) { + var old = SealedBox.openKeep(keys, Bytes.fromUtf8(AAD_V1), item, leftovers); + wiper.addAll(leftovers); + if (old == null) { + return null; + } + var a = ManageAction.fromBytes(old); + wiper.add(old); + return a; + } + var code = codeOf(item); + var box = code >= 0 ? (item as Array)[1] : null; + var plain = box instanceof ByteArray ? SealedBox.open(keys, itemAad(code), box) : null; + if (plain == null) { + return null; + } + var action = ManageAction.fromBytes(plain); + Bytes.wipe(plain); // small: names and an ID + if (action == null || action.action != code || action.ref != refOf(item)) { + return null; + } + if (action.ref >= 0 && material) { + var stored = vault.storage().getValue(MATERIAL_KEY + action.ref); + var opened = stored instanceof ByteArray ? SealedBox.openKeep(keys, materialAad(action.ref, action.id), stored, leftovers) + : null; + wiper.addAll(leftovers); + if (opened == null) { + return null; + } + action.material = opened; + } + return action; + } + + // Done with an action: its material goes to the wiper. + function release(action as ManageAction, wiper as Jobs.Wiper) as Void { + wiper.add(action.material); + action.material = null; + action.text = null; + } + + // An item's identity while it waits: its box's random IV, as hex ("-" + // for a malformed item). The menu, "Confirm all", "Reject all" and + // LockJob act on items by it, so changes that arrive meanwhile (or + // items another job removed) are never mixed up with the ones read. + function keyOf(item as Application.Storage.ValueType) as String { + var box = item instanceof ByteArray ? item : (codeOf(item) >= 0 ? (item as Array)[1] : null); + if (!(box instanceof ByteArray) || box.size() < SealedBox.MIN_SIZE) { + return "-"; + } + return Bytes.toHex(box.slice(1, 1 + SealedBox.IV_BYTES)); + } + + // Remove the items with these keys (and their material); the others + // keep waiting, in order. Returns how many are left. + function remove(vault as Vault, keys as Array) as Number { + changed(); + var stored = items(vault); + var kept = [] as Array; for (var i = 0; i < stored.size(); i++) { - var box = stored[i]; - var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null; - if (plain != null) { - var action = ManageAction.fromBytes(plain); - Bytes.wipe(plain); - if (action != null) { - out.add(action); - } + if (keys.indexOf(keyOf(stored[i])) >= 0) { + deleteMaterial(vault, stored[i]); + } else { + kept.add(stored[i]); } } - return out; + if (kept.size() == stored.size()) { + return kept.size(); + } + if (kept.size() == 0) { + vault.storage().deleteValue(KEY); + return 0; + } + try { + vault.storage().setValue(KEY, kept); + } catch (e instanceof Lang.Exception) { + // Can't happen when shrinking; if it did, the items stay (an + // applied one again is harmless: "already requires the PIN"). + } + return kept.size(); } - // Short descriptions for the confirmation screen ("Delete: K7F2 Bank"). + // Short descriptions for the confirmation screen ("Delete: K7F2 Bank"), + // in one event: for tests (the menu runs DescribeJob). function descriptions(vault as Vault) as Array { - var actions = load(vault); - var out = [] as Array; - for (var i = 0; i < actions.size(); i++) { - out.add(actions[i].describe()); - actions[i].wipe(); - } - return out; + var job = new DescribeJob(vault); + Jobs.runToCompletion(job, 1); + return job.lines; } // The number of waiting changes that need "Confirm all": all but - // "Require PIN", which applies at the next PIN by itself. For the list's - // title and the menu; cached until the queue changes, because the list - // asks at every draw and counting opens every item. + // "Require PIN", which applies at the next PIN by itself. Cached until + // the queue changes, because the list asks at every draw. function countToConfirm(vault as Vault) as Number { var n = _toConfirm; if (n == null || _toConfirmFor != vault) { @@ -109,67 +243,27 @@ module ChangeQueue { _toConfirm = null; } - // The number of waiting "Require PIN" actions (applied at the next PIN). + // The number of waiting "Require PIN" actions (applied at the next PIN), + // from the item codes alone. function countLocks(vault as Vault) as Number { - var actions = load(vault); + var stored = items(vault); var n = 0; - for (var i = 0; i < actions.size(); i++) { - if (actions[i].action == ManageAction.LOCK) { + for (var i = 0; i < stored.size(); i++) { + if (codeOf(stored[i]) == ManageAction.LOCK) { n++; } - actions[i].wipe(); } return n; } - // After a correct or new PIN (session unlocked): apply the waiting - // "Require PIN" actions and remove them from the queue; the other items - // keep waiting, in order. Each is one re-seal (ManageRun, no code - // check), like promotePending. One that fails (the entry is gone or - // already locked) is dropped too. - function applyLocks(vault as Vault) as Void { - _lockLines = [] as Array; + // LockJob's lines (added to those of the PIN's first job, for a legacy + // PIN's second); the queue changed. + function addLockLines(lines as Array) as Void { + _lockLines.addAll(lines); changed(); - var stored = vault.storage().getValue(KEY); - if (!(stored instanceof Array) || !vault.session.isUnlocked()) { - return; - } - var keys = vault.keyManager.deviceKeys(); - var aad = Bytes.fromUtf8(AAD); - var kept = [] as Array; - for (var i = 0; i < stored.size(); i++) { - var box = stored[i]; - var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null; - var action = plain != null ? ManageAction.fromBytes(plain) : null; - Bytes.wipe(plain); - if (action != null && action.action == ManageAction.LOCK) { - var run = new ManageRun(vault, action, true); - Jobs.runToCompletion(run, 1); - _lockLines.add(run.failed ? "Not applied: " + action.describe() + ". " + run.message : run.message); - } else { - if (action != null) { - action.wipe(); - } - kept.add(box as Application.Storage.ValueType); // damaged items are dropped by the next clear() - } - } - Diag.data("locks_applied", "count=" + _lockLines.size()); // for UI scripts: no names - if (kept.size() == stored.size()) { - return; - } - if (kept.size() == 0) { - clear(vault); - return; - } - try { - vault.storage().setValue(KEY, kept); - } catch (e instanceof Lang.Exception) { - // Can't happen when shrinking; if it did, the locks just apply - // again at the next PIN ("already requires the PIN"). - } } - // The lines of the last applyLocks(), once (then forgotten). + // The lines of the last LockJob, once (then forgotten). function takeLockLines() as Array { var lines = _lockLines; _lockLines = [] as Array; @@ -178,68 +272,281 @@ module ChangeQueue { function clear(vault as Vault) as Void { changed(); + var stored = items(vault); + for (var i = 0; i < stored.size(); i++) { + deleteMaterial(vault, stored[i]); + } vault.storage().deleteValue(KEY); } - // Remove the first n items (the ones just applied), keeping any that - // arrived from the phone meanwhile. - function drop(vault as Vault, n as Number) as Void { - changed(); - var stored = vault.storage().getValue(KEY); - if (!(stored instanceof Array) || stored.size() <= n) { - clear(vault); - return; + function deleteMaterial(vault as Vault, item as Application.Storage.ValueType) as Void { + var ref = refOf(item); + if (ref >= 0) { + vault.storage().deleteValue(MATERIAL_KEY + ref); + } + } + + // The lowest material number no stored item uses. + function freeRef(stored as Array) as Number { + var ref = 0; + var used = true; + while (used) { + used = false; + for (var i = 0; i < stored.size(); i++) { + if (refOf(stored[i]) == ref) { + used = true; + ref++; + break; + } + } + } + return ref; + } + + function itemAad(code as Number) as ByteArray { + var aad = Bytes.fromUtf8(AAD); + aad.add(code & 0xFF); + return aad; + } + + function materialAad(ref as Number, id as String) as ByteArray { + return Bytes.fromUtf8(MATERIAL_AAD + "." + ref + "." + id); + } +} + +// The queue's descriptions ("Delete: K7F2 Bank"), one item per step, and +// the keys of every item read (damaged ones too): "Confirm all" and +// "Reject all" act on exactly these. +class DescribeJob extends Jobs.Job { + + var lines as Array = [] as Array; + var keys as Array = [] as Array; + + private var _vault as Vault; + private var _items as Array; + private var _at as Number = 0; + private var _wiper as Jobs.Wiper = new Jobs.Wiper(); + + function initialize(vault as Vault) { + Job.initialize(); + _vault = vault; + _items = ChangeQueue.items(vault); + } + + function step(budget as Number) as Boolean { + if (_wiper.left() > 0) { + // A 0.9.0 item's contents: zeroed in steps of their own. + return _wiper.step(Jobs.Wiper.STEP_BYTES) && _at >= _items.size(); + } + if (_at < _items.size()) { + var item = _items[_at]; + keys.add(ChangeQueue.keyOf(item)); + var action = ChangeQueue.open(_vault, item, _wiper, false); + if (action != null) { + lines.add(action.describe()); + ChangeQueue.release(action, _wiper); + } + _at++; + } + return _at >= _items.size() && _wiper.left() == 0; + } +} + +// After a correct or new PIN (PinView, while it shows its busy state): +// promotes the pending locked entries (Vault.promoteNext, a few per step), +// then applies the waiting "Require PIN" actions, one re-seal (ManageRun, +// no code check) per item, and removes them from the queue; the other +// items keep waiting, in order, unopened. One that fails (the entry is gone +// or already locked) is dropped too, except for a full store: then it waits +// for the next PIN. The queue is written once at the end, so a stop midway +// re-applies the locks already done at the next PIN, reported as "already +// requires the PIN"; promotion saves after each entry. +class LockJob extends Jobs.Job { + + // Record bytes promoted per step: one large code, or a few OTP entries + // (each is an open, a re-seal and the metadata). + static const PROMOTE_BYTES = 600; + + // One line per lock applied or failed (also kept for takeLockLines). + var lines as Array = [] as Array; + + private var _vault as Vault; + // The items when promotion ended; null before. + private var _items as Array? = null; + // Keys of the items applied or dropped. + private var _done as Array = [] as Array; + private var _index as Number = 0; + private var _run as ManageRun? = null; + private var _action as ManageAction? = null; + private var _wiper as Jobs.Wiper = new Jobs.Wiper(); + + // first: the PIN's first job, which starts the lock lines afresh (a + // legacy PIN's second job, for the new PIN, adds to them). + function initialize(vault as Vault, first as Boolean) { + Job.initialize(); + _vault = vault; + if (first) { + ChangeQueue.takeLockLines(); + } + ChangeQueue.changed(); + } + + function step(budget as Number) as Boolean { + if (_wiper.left() > Jobs.Wiper.INLINE_BYTES) { + _wiper.step(Jobs.Wiper.STEP_BYTES); + return false; + } + _wiper.step(Jobs.Wiper.INLINE_BYTES); + var items = _items; + if (!_vault.session.isUnlocked()) { + // Locked meanwhile: what is left waits for the next PIN. + if (items != null) { + finish(); + } + return true; + } + if (items == null) { + promote(); + return false; + } + var run = _run; + if (run != null) { + if (!run.step(budget)) { + return false; + } + var action = _action as ManageAction; + lines.add(run.failed ? "Not applied: " + action.describe() + ". " + run.message : run.message); + if (!run.failed || !run.message.equals("storage full")) { + _done.add(ChangeQueue.keyOf(items[_index])); // a full store: it waits for the next PIN + } + ChangeQueue.release(action, _wiper); + _run = null; + _action = null; + _index++; + return false; + } + if (_index >= items.size()) { + finish(); + return true; } + var item = items[_index]; + var code = ChangeQueue.codeOf(item); + // A 0.9.0 item must be opened to know what it is. + var action = code == ManageAction.LOCK || item instanceof ByteArray ? ChangeQueue.open(_vault, item, _wiper, false) : null; + if (action != null && action.action == ManageAction.LOCK) { + _action = action; + _run = new ManageRun(_vault, action, true); + return false; + } + if (action != null) { + ChangeQueue.release(action, _wiper); + } + _index++; // damaged items stay until confirmed or rejected + return false; + } + + // Promote up to PROMOTE_BYTES of pending entries. A full store stops + // promotion (the rest wait for the next PIN) but not the locks. + private function promote() as Void { + var bytes = 0; try { - vault.storage().setValue(KEY, stored.slice(n, null) as Array); - } catch (e instanceof Lang.Exception) { - clear(vault); // can't happen when shrinking; never re-apply + while (bytes < PROMOTE_BYTES) { + var size = _vault.promoteNext(_wiper); + if (size == 0) { + _items = ChangeQueue.items(_vault); + return; + } + bytes += size > 0 ? size : 1; + } + } catch (e instanceof VaultError) { + var left = _vault.pendingIds().size(); + lines.add((e.code == VaultError.FULL ? "Storage full: " : "Not promoted: ") + left + + (left == 1 ? " locked entry still waits" : " locked entries still wait") + " for your PIN"); + _items = ChangeQueue.items(_vault); + } + } + + // Remove the locks just applied (and the failed ones) from the queue. + private function finish() as Void { + Diag.data("locks_applied", "count=" + lines.size()); // for UI scripts: no names + ChangeQueue.addLockLines(lines); + if (_done.size() > 0) { + ChangeQueue.remove(_vault, _done); } } } -// Applies every waiting change after the PIN was entered, one per step -// (each re-seals a record; a code may need its CodeCheck). The applied -// items leave the queue only at the end, so a crash midway loses nothing; +// Applies the waiting changes after the PIN was entered: per step, it opens +// one item or advances its ManageRun (each re-seals a record; a code may +// need its CodeCheck). Only the items the menu showed (their keys) are +// applied; changes that arrived meanwhile keep waiting. The applied items +// leave the queue only at the end, so a crash midway loses nothing; // applying one again is harmless (a deleted entry is then just missing). class ChangeQueueJob extends Jobs.Job { var lines as Array = [] as Array; private var _vault as Vault; - private var _actions as Array; - private var _loaded as Number = 0; + private var _keys as Array?; + private var _items as Array = [] as Array; + private var _done as Array = [] as Array; private var _index as Number = 0; + private var _action as ManageAction? = null; private var _run as ManageRun? = null; + private var _wiper as Jobs.Wiper = new Jobs.Wiper(); - function initialize(vault as Vault) { + // keys: the items to apply (DescribeJob.keys), or null for all. + function initialize(vault as Vault, keys as Array?) { Job.initialize(); _vault = vault; - var stored = vault.storage().getValue(ChangeQueue.KEY); - _loaded = stored instanceof Array ? stored.size() : 0; - _actions = ChangeQueue.load(vault); - // "Require PIN" changes the PIN just applied (ChangeQueue.applyLocks). + _keys = keys; + var stored = ChangeQueue.items(vault); + for (var i = 0; i < stored.size(); i++) { + if (keys == null || keys.indexOf(ChangeQueue.keyOf(stored[i])) >= 0) { + _items.add(stored[i]); + } + } + // "Require PIN" changes the PIN just applied (LockJob). lines.addAll(ChangeQueue.takeLockLines()); } function step(budget as Number) as Boolean { + if (_wiper.left() > Jobs.Wiper.INLINE_BYTES) { + _wiper.step(Jobs.Wiper.STEP_BYTES); + return false; + } + _wiper.step(Jobs.Wiper.INLINE_BYTES); var run = _run; if (run == null) { - if (_index >= _actions.size()) { - ChangeQueue.drop(_vault, _loaded); + if (_index >= _items.size()) { + var left = ChangeQueue.remove(_vault, _done); + if (_keys != null && left > 0) { + lines.add(left + (left == 1 ? " change" : " changes") + " arrived meanwhile and still " + + (left == 1 ? "waits" : "wait") + " (menu, Phone changes)"); + } if (lines.size() == 0) { lines.add("No changes were waiting."); } return true; } - run = new ManageRun(_vault, _actions[_index], true); - _run = run; + _done.add(ChangeQueue.keyOf(_items[_index])); + var action = ChangeQueue.open(_vault, _items[_index], _wiper, true); + if (action == null) { + lines.add("Not applied: a change was damaged"); + _index++; + return false; + } + _action = action; + _run = new ManageRun(_vault, action, true); + return false; } if (run.step(budget)) { - var problem = run.failed ? "Not applied: " + _actions[_index].describe() + ". " : ""; + var action = _action as ManageAction; + var problem = run.failed ? "Not applied: " + action.describe() + ". " : ""; lines.add(problem + run.message); lines.addAll(run.notes); - _actions[_index].wipe(); + ChangeQueue.release(action, _wiper); + _action = null; _index++; _run = null; } -- cgit v1.2.3