aboutsummaryrefslogtreecommitdiff
path: root/source/storage
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
commitbc228753bfb4a2faea540cddb20fc39ebd265db5 (patch)
treecd959eb8a037cea49282fc5cfef53bc9209efbb0 /source/storage
parent81a5b903297e59d599dd0d6f580b89597630909d (diff)
feat(storage): add keyed secret fingerprints to record headers
An 8-byte HMAC of kind, type and secret (key derived from the device key) in header tag 13 lets imports find duplicates of locked entries while the watch is locked. Vault gains anyHeader() and storage(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/storage')
-rw-r--r--source/storage/Entry.mc3
-rw-r--r--source/storage/KeyManager.mc24
-rw-r--r--source/storage/Record.mc8
-rw-r--r--source/storage/Vault.mc19
4 files changed, 53 insertions, 1 deletions
diff --git a/source/storage/Entry.mc b/source/storage/Entry.mc
index 57e0309..23c95c0 100644
--- a/source/storage/Entry.mc
+++ b/source/storage/Entry.mc
@@ -52,6 +52,9 @@ class Entry {
var counter as Long = 0l;
var created as Long = 0l;
var namesEncrypted as Boolean = false;
+ // Keyed fingerprint of the type and secret (or payload), set by the
+ // vault: finds duplicates without decrypting (docs/SECURITY.md).
+ var fingerprint as ByteArray? = null;
// Sensitive fields (encrypted at rest).
var algorithm as Number = Hashes.SHA1;
diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc
index ebb0a62..567288f 100644
--- a/source/storage/KeyManager.mc
+++ b/source/storage/KeyManager.mc
@@ -33,6 +33,9 @@ class KeyManager {
private var _store as Persist.Backend;
private var _deviceKeys as SealedBox.Keys? = null;
+ private var _fingerprintKey as ByteArray? = null;
+
+ static const FINGERPRINT_BYTES = 8;
function initialize(store as Persist.Backend) {
_store = store;
@@ -55,11 +58,32 @@ class KeyManager {
_store.setValue(DEVICE_KEY, raw);
}
keys = new SealedBox.Keys(raw);
+ _fingerprintKey = Kdf.hkdf(raw, []b, Bytes.fromUtf8("OTPW fp v1"), KEY_BYTES);
Bytes.wipe(raw);
_deviceKeys = keys;
return keys;
}
+ // An 8-byte keyed fingerprint of an entry's kind, type and secret (or
+ // payload): equal for duplicates, meaningless without the device key.
+ // Stored in the readable header so imports can find duplicates of locked
+ // entries while the watch is locked (docs/SECURITY.md).
+ function fingerprint(entry as Entry) as ByteArray {
+ deviceKeys();
+ var input = Bytes.fromUtf8("OTPW-FP-1");
+ input.add(entry.kind);
+ input.add(entry.type);
+ var material = entry.kind == Entry.KIND_OTP ? entry.secret : entry.payload;
+ if (material != null) {
+ input.addAll(material);
+ }
+ var mac = Hmac.nativeSha256(_fingerprintKey as ByteArray, input);
+ Bytes.wipe(input);
+ var out = mac.slice(0, FINGERPRINT_BYTES);
+ Bytes.wipe(mac);
+ return out;
+ }
+
// True when a PIN has been set.
function hasPin() as Boolean {
return _store.getValue(WRAP_KEY) != null;
diff --git a/source/storage/Record.mc b/source/storage/Record.mc
index 01c3c53..b26a4ba 100644
--- a/source/storage/Record.mc
+++ b/source/storage/Record.mc
@@ -6,7 +6,7 @@
// v (1 byte, = 1) | kr (1 byte) | hlen (2 bytes, big-endian) | header (hlen bytes) | box
//
// kr = 0: sealed with the device key, 1: sealed with the locked master key
-// header = Codec TLV of the header fields (tags 1-12), readable without a key
+// header = Codec TLV of the header fields (tags 1-13), readable without a key
// box = SealedBox of the Codec TLV of the sensitive fields (tags 20-28)
// aad = UTF-8 id || v || kr || hlen || header
//
@@ -47,6 +47,7 @@ module Record {
const H_COUNTER = 10;
const H_CREATED = 11;
const H_NAMES_ENCRYPTED = 12;
+ const H_FINGERPRINT = 13;
// Sensitive tags (inside the box).
const S_ALGORITHM = 20;
@@ -107,6 +108,7 @@ module Record {
entry.counter = f.long(H_COUNTER);
entry.created = f.long(H_CREATED);
entry.namesEncrypted = f.flag(H_NAMES_ENCRYPTED);
+ entry.fingerprint = f.bytes(H_FINGERPRINT);
if (!f.ok || !Entry.isValidId(entry.id) || entry.counter < 0) {
return null;
}
@@ -170,6 +172,10 @@ module Record {
w.addInt(H_COUNTER, entry.counter);
w.addInt(H_CREATED, entry.created);
w.addBool(H_NAMES_ENCRYPTED, entry.namesEncrypted);
+ var fingerprint = entry.fingerprint;
+ if (fingerprint != null) {
+ w.addBytes(H_FINGERPRINT, fingerprint);
+ }
return w.bytes;
}
diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc
index 95f77c9..e77bbf2 100644
--- a/source/storage/Vault.mc
+++ b/source/storage/Vault.mc
@@ -51,6 +51,12 @@ class Vault {
// ---- Reading -------------------------------------------------------
+ // The underlying store (for the import buffer, which lives next to the
+ // entries and uses the same device key).
+ function storage() as Persist.Backend {
+ return _store;
+ }
+
// Entry IDs in display order (a copy).
function ids() as Array<String> {
return _ids.slice(0, null) as Array<String>;
@@ -82,6 +88,17 @@ class Vault {
return entry;
}
+ // The header of a listed entry or of a pending one ("p.<id>"), or null
+ // when it doesn't exist or is damaged. Never throws.
+ function anyHeader(id as String) as Entry? {
+ var key = _ids.indexOf(id) >= 0 ? "e." + id : (_pending.indexOf(id) >= 0 ? "p." + id : null);
+ if (key == null) {
+ return null;
+ }
+ var stored = _store.getValue(key);
+ return stored instanceof ByteArray ? Record.decodeHeader(stored) : null;
+ }
+
// The full entry with its secret. Throws NOT_FOUND, LOCKED (locked entry
// while the session is locked) or DAMAGED. Call wipeSecrets() when done.
function open(id as String) as Entry {
@@ -109,6 +126,7 @@ class Vault {
throw new VaultError(VaultError.LIMIT, "at most " + MAX_ENTRIES + " entries");
}
entry.id = newId();
+ entry.fingerprint = keyManager.fingerprint(entry);
if (entry.created == 0) {
entry.created = Clock.nowUnix(0);
}
@@ -130,6 +148,7 @@ class Vault {
function update(entry as Entry) as Void {
var record = recordFor(entry.id);
checkStorable(entry);
+ entry.fingerprint = keyManager.fingerprint(entry);
if ((record != null && Record.keyRef(record) == Record.KEY_MASTER) || entry.locked) {
session.keys(); // throws LOCKED
}