1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
|
// Author: cirakg & claude ai
// Summary: One parsed import candidate (an entry, an error or a skip), its packed form, and the shared field validation of both formats.
//
// Both parsers (otpauth URIs and Aegis JSON) turn text fields into an Entry
// through ImportFields.toEntry, so the same rules (README 10.4) and the same
// messages apply to both. Messages name the field and the problem, never the
// secret itself.
//
// Memory: an item with its Entry takes about 760 bytes as objects (measured
// on the Forerunner 255S in M8), so 150 parsed items would take 110 KB. The
// parsers keep each finished item packed into one ByteArray (Codec TLV,
// about 110 bytes) instead, and the importer unpacks one item per step.
// The packed form holds the secret in clear, like the Entry it replaces;
// the importer wipes it as soon as the item is unpacked.
import Toybox.Lang;
class ImportItem {
// 1-based position in the import (URI number or Aegis entry number).
var index as Number;
// A short name for messages: the issuer, else the account, else "".
var label as String = "";
var entry as Entry? = null;
// Why the item can't be imported (shown as an error), or null.
var problem as String? = null;
// Why the item was deliberately skipped (unsupported type), or null.
var skipped as String? = null;
// A remark that doesn't stop the import (issuer mismatch).
var note as String? = null;
// Aegis groups: a group UUID (database version 3) or a name, resolved by
// the reader once the whole document is read.
var groupRef as String? = null;
// Packed field tags.
private static const P_INDEX = 1;
private static const P_LABEL = 2;
private static const P_PROBLEM = 3;
private static const P_SKIPPED = 4;
private static const P_NOTE = 5;
private static const P_GROUP_REF = 6;
private static const P_TYPE = 10;
private static const P_NAME = 11;
private static const P_ACCOUNT = 12;
private static const P_FAVORITE = 13;
private static const P_ALGORITHM = 14;
private static const P_DIGITS = 15;
private static const P_PERIOD = 16;
private static const P_COUNTER = 17;
private static const P_SECRET = 18;
function initialize(position as Number) {
index = position;
}
// This item as one ByteArray. Wipes the entry's secret: the packed copy
// is the only one left.
function pack() as ByteArray {
var w = new Codec.Writer();
w.addInt(P_INDEX, index);
w.addText(P_LABEL, label);
addText(w, P_PROBLEM, problem);
addText(w, P_SKIPPED, skipped);
addText(w, P_NOTE, note);
addText(w, P_GROUP_REF, groupRef);
var e = entry;
if (e != null && e.secret != null) {
w.addInt(P_TYPE, e.type);
w.addText(P_NAME, e.name);
w.addText(P_ACCOUNT, e.account);
w.addBool(P_FAVORITE, e.favorite);
w.addInt(P_ALGORITHM, e.algorithm);
w.addInt(P_DIGITS, e.digits);
w.addInt(P_PERIOD, e.period);
w.addInt(P_COUNTER, e.counter);
w.addBytes(P_SECRET, e.secret as ByteArray);
e.wipeSecrets();
}
return w.bytes;
}
private static function addText(w as Codec.Writer, tag as Number, value as String?) as Void {
if (value != null) {
w.addText(tag, value);
}
}
// The item packed by pack(), with its group resolved: groupRef is
// "name:<group>" or "uuid:<id>" looked up in groups (Aegis db.groups).
// The caller wipes `packed`.
static function unpack(packed as ByteArray, groups as Dictionary<String, String>) as ImportItem {
var map = Codec.decode(packed);
var item = new ImportItem(0);
if (map == null) {
item.problem = "invalid"; // only if memory was corrupted
return item;
}
item.index = (map[P_INDEX] as Long).toNumber();
item.label = map[P_LABEL] as String;
item.problem = map[P_PROBLEM] as String?;
item.skipped = map[P_SKIPPED] as String?;
item.note = map[P_NOTE] as String?;
var secret = map[P_SECRET];
if (secret instanceof ByteArray) {
var e = new Entry();
e.kind = Entry.KIND_OTP;
e.source = Entry.SOURCE_IMPORT;
e.type = (map[P_TYPE] as Long).toNumber();
e.name = map[P_NAME] as String;
e.account = map[P_ACCOUNT] as String;
e.favorite = map[P_FAVORITE] as Boolean;
e.algorithm = (map[P_ALGORITHM] as Long).toNumber() as Hashes.Algorithm;
e.digits = (map[P_DIGITS] as Long).toNumber();
e.period = (map[P_PERIOD] as Long).toNumber();
e.counter = map[P_COUNTER] as Long;
e.secret = secret;
e.group = groupName(map[P_GROUP_REF] as String?, groups);
item.entry = e;
}
return item;
}
private static function groupName(ref as String?, groups as Dictionary<String, String>) as String {
if (ref == null) {
return "";
}
var key = ref.substring(5, ref.length()) as String;
var name = ref.find("uuid:") == 0 ? groups[key] : key;
return name != null ? AegisReader.fitBytes(Text.trim(name), Entry.MAX_GROUP_BYTES) : "";
}
// "entry 7 'GitHub'" or "URI 3", for messages.
function describe(kind as String) as String {
return label.length() > 0 ? kind + " " + index + " '" + label + "'" : kind + " " + index;
}
}
// The raw fields one source item provides, as text, and their conversion.
class ImportFields {
var type as String = "totp";
var issuer as String = "";
var account as String = "";
var secret as String? = null;
var algorithm as String = "SHA1";
var digits as Long = 6l;
var period as Long = 30l;
var counter as Long? = null;
var favorite as Boolean = false;
function initialize() {
}
// Fill item.entry, or set item.problem / item.skipped.
function toEntry(item as ImportItem) as Void {
item.label = issuer.length() > 0 ? issuer : account;
var t = type.toLower();
var otpType = -1;
if (t.equals("totp")) {
otpType = Entry.OTP_TOTP;
} else if (t.equals("hotp")) {
otpType = Entry.OTP_HOTP;
} else if (t.equals("steam")) {
otpType = Entry.OTP_STEAM;
} else if (t.equals("motp")) {
item.skipped = "mOTP is not supported";
return;
} else if (t.equals("yandex")) {
item.skipped = "Yandex is not supported";
return;
} else {
item.skipped = "type '" + Text.clip(t, 12) + "' is not supported";
return;
}
if (issuer.length() == 0 && account.length() == 0) {
item.problem = "needs an issuer or an account name";
return;
}
if (Bytes.fromUtf8(issuer).size() > Entry.MAX_NAME_BYTES || Bytes.fromUtf8(account).size() > Entry.MAX_NAME_BYTES) {
item.problem = "issuer or account longer than " + Entry.MAX_NAME_BYTES + " bytes";
return;
}
var e = new Entry();
e.kind = Entry.KIND_OTP;
e.type = otpType;
e.name = issuer;
e.account = account;
e.source = Entry.SOURCE_IMPORT;
e.favorite = favorite;
if (otpType == Entry.OTP_STEAM) {
// Steam always uses SHA-1, 30 s and 5 characters.
e.algorithm = Hashes.SHA1;
e.digits = Otp.MIN_DIGITS;
e.period = Otp.STEAM_PERIOD;
} else {
var a = algorithm.toUpper();
if (a.equals("SHA1")) {
e.algorithm = Hashes.SHA1;
} else if (a.equals("SHA256")) {
e.algorithm = Hashes.SHA256;
} else if (a.equals("SHA512")) {
e.algorithm = Hashes.SHA512;
} else {
item.problem = "algorithm '" + Text.clip(algorithm, 10) + "' is not supported (SHA1, SHA256, SHA512)";
return;
}
if (digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) {
item.problem = digits + " digits not supported (6-8)";
return;
}
e.digits = digits.toNumber();
if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
item.problem = "period " + period + " s not supported (1-300)";
return;
}
e.period = period.toNumber();
}
if (otpType == Entry.OTP_HOTP) {
var c = counter;
if (c == null) {
item.problem = "HOTP needs a counter";
return;
}
if (c < 0) {
item.problem = "counter can't be negative";
return;
}
e.counter = c;
}
var s = secret;
if (s == null || s.length() == 0) {
item.problem = "secret missing";
return;
}
var decoded = Base32.decode(s);
if (decoded instanceof Base32.DecodeError) {
item.problem = "secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : "");
return;
}
if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) {
item.problem = "secret must be 1 to " + Entry.MAX_SECRET_BYTES + " bytes";
Bytes.wipe(decoded);
return;
}
e.secret = decoded;
item.entry = e;
}
}
|