aboutsummaryrefslogtreecommitdiff
path: root/source/storage/Entry.mc
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
commitdd5d0f68726fe56ee4d199867d414aea81905cc4 (patch)
tree1cb40b024ac13f454633198ad50cbc55f7283f39 /source/storage/Entry.mc
parent94181d58cf66c5b7b263fd435a344f1cd8872cff (diff)
feat(storage): add the encrypted vault, PIN keys, lockout, session and migrations
Records are one ByteArray each: a readable TLV header plus an encrypt-then-MAC box whose aad binds the entry ID and the header. The device key seals unlocked entries and pending items; a random master key, wrapped under a PBKDF2 PIN key, seals locked ones. Wrong PINs are counted before the check, with delays on the trusted clock and an uptime fallback. Bytes.toUtf8 validates UTF-8 first, because the native conversion crashes on invalid input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/storage/Entry.mc')
-rw-r--r--source/storage/Entry.mc149
1 files changed, 149 insertions, 0 deletions
diff --git a/source/storage/Entry.mc b/source/storage/Entry.mc
new file mode 100644
index 0000000..57e0309
--- /dev/null
+++ b/source/storage/Entry.mc
@@ -0,0 +1,149 @@
+// Author: cirakg & claude ai
+// Summary: One wallet entry (an OTP account or a barcode card): its fields, limits, validation and wiping.
+//
+// The fields follow README 6.1. Header fields are readable without a key so
+// the list draws without decrypting; the sensitive fields (algorithm, digits,
+// period, secret, payload, options) only exist in memory after the vault has
+// opened the record, and wipeSecrets() zeroes them again.
+//
+// An Entry that came from Vault.header() has secret == null and
+// payload == null; one from Vault.open() has them filled in.
+
+import Toybox.Lang;
+
+class Entry {
+
+ // kind
+ static const KIND_OTP = 0;
+ static const KIND_CODE = 1;
+
+ // type, for OTP entries (code entries store their symbology, M6)
+ static const OTP_TOTP = 0;
+ static const OTP_HOTP = 1;
+ static const OTP_STEAM = 2;
+
+ // source
+ static const SOURCE_MANUAL = 0;
+ static const SOURCE_IMPORT = 1;
+
+ // ID alphabet: digits and capitals without 0, 1, I, L, O and U, so IDs
+ // are easy to read off the watch and type into the Manage form.
+ static const ID_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; // lint: allow-literal
+ static const ID_LENGTH = 4;
+
+ // Limits, in UTF-8 bytes. They keep one record far below the 8 KB
+ // per-value limit of Application.Storage (README 6.2).
+ static const MAX_NAME_BYTES = 64;
+ static const MAX_GROUP_BYTES = 32;
+ static const MAX_SECRET_BYTES = 128;
+ static const MAX_PAYLOAD_BYTES = 4096;
+ static const MAX_SYMBOLOGY = 31;
+
+ // Header fields.
+ var id as String = "";
+ var kind as Number = KIND_OTP;
+ var type as Number = OTP_TOTP;
+ var name as String = "";
+ var account as String = "";
+ var group as String = "";
+ var locked as Boolean = false;
+ var source as Number = SOURCE_MANUAL;
+ var favorite as Boolean = false;
+ var counter as Long = 0l;
+ var created as Long = 0l;
+ var namesEncrypted as Boolean = false;
+
+ // Sensitive fields (encrypted at rest).
+ var algorithm as Number = Hashes.SHA1;
+ var digits as Number = 6;
+ var period as Number = 30;
+ var secret as ByteArray? = null;
+ var payload as ByteArray? = null;
+ var options as Number = 0;
+
+ function initialize() {
+ }
+
+ // True for a well-formed ID: ID_LENGTH characters from ID_ALPHABET.
+ static function isValidId(value as String) as Boolean {
+ if (value.length() != ID_LENGTH) {
+ return false;
+ }
+ var chars = value.toCharArray();
+ for (var i = 0; i < chars.size(); i++) {
+ if (ID_ALPHABET.find(chars[i].toString()) == null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // null when the entry can be stored, otherwise a short English reason.
+ // Checks the ID only when one is set (the vault assigns new IDs).
+ // Settings and import validate user input in more detail (M5, M7); this is
+ // the last line of defence before anything is written.
+ function validate() as String? {
+ if (id.length() > 0 && !isValidId(id)) {
+ return "bad id";
+ }
+ if (Bytes.fromUtf8(name).size() > MAX_NAME_BYTES || Bytes.fromUtf8(account).size() > MAX_NAME_BYTES) {
+ return "name too long";
+ }
+ if (Bytes.fromUtf8(group).size() > MAX_GROUP_BYTES) {
+ return "group too long";
+ }
+ if (source != SOURCE_MANUAL && source != SOURCE_IMPORT) {
+ return "bad source";
+ }
+ if (counter < 0) {
+ return "bad counter";
+ }
+ if (kind == KIND_OTP) {
+ return validateOtp();
+ }
+ if (kind == KIND_CODE) {
+ return validateCode();
+ }
+ return "bad kind";
+ }
+
+ private function validateOtp() as String? {
+ if (type != OTP_TOTP && type != OTP_HOTP && type != OTP_STEAM) {
+ return "bad otp type";
+ }
+ if (!Hashes.isValid(algorithm)) {
+ return "bad algorithm";
+ }
+ if (digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) {
+ return "bad digits";
+ }
+ if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
+ return "bad period";
+ }
+ var s = secret;
+ if (s == null || s.size() == 0 || s.size() > MAX_SECRET_BYTES) {
+ return "bad secret";
+ }
+ return null;
+ }
+
+ private function validateCode() as String? {
+ if (type < 0 || type > MAX_SYMBOLOGY) {
+ return "bad symbology";
+ }
+ var p = payload;
+ if (p == null || p.size() == 0 || p.size() > MAX_PAYLOAD_BYTES) {
+ return "bad payload";
+ }
+ return null;
+ }
+
+ // Zero the secret and payload in place and drop them. Call as soon as a
+ // code has been computed or a barcode encoded (README 7.8).
+ function wipeSecrets() as Void {
+ Bytes.wipe(secret);
+ Bytes.wipe(payload);
+ secret = null;
+ payload = null;
+ }
+}