diff --git a/data/gauthenticator.gresource.xml b/data/gauthenticator.gresource.xml index b4a064f..ea6b1ee 100644 --- a/data/gauthenticator.gresource.xml +++ b/data/gauthenticator.gresource.xml @@ -2,5 +2,6 @@ gauth-window.ui + otp-row.ui diff --git a/data/otp-row.ui b/data/otp-row.ui new file mode 100644 index 0000000..17a3795 --- /dev/null +++ b/data/otp-row.ui @@ -0,0 +1,66 @@ + + + + + + True + False + edit-copy + + + diff --git a/src/Makefile.am b/src/Makefile.am index 2c93349..af12b64 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -2,10 +2,16 @@ bin_PROGRAMS = gauthenticator gresource_file = $(top_srcdir)/data/gauthenticator.gresource.xml -gauthenticator_SOURCES = main.vala gauth-app.vala gauth-window.vala +BUILT_SOURCES = resources.c +gauthenticator_SOURCES = $(BUILT_SOURCES) base32.vala otp.vala otp-row.vala main.vala gauth-app.vala gauth-window.vala gauthenticator_CPPFLAGS = $(GAUTHENTICATOR_CFLAGS) gauthenticator_VALAFLAGS = --pkg gtk+-3.0 --gresources $(gresource_file) --target-glib=2.38 -gauthenticator_LDADD = $(GAUTHENTICATOR_LIBS) +gauthenticator_LDADD = $(GAUTHENTICATOR_LIBS) -lm -include $(top_srcdir)/git.mk + +resource_files = $(shell $(GLIB_COMPILE_RESOURCES) --generate-dependencies --sourcedir=$(top_srcdir)/data $(gresource_file)) +resources.c: $(gresource_file) $(resource_files) + $(AM_V_GEN) $(GLIB_COMPILE_RESOURCES) --target=$@ --sourcedir=$(top_srcdir)/data --generate-source $< + diff --git a/src/base32.vala b/src/base32.vala new file mode 100644 index 0000000..e0825a5 --- /dev/null +++ b/src/base32.vala @@ -0,0 +1,175 @@ +namespace Base32 { + public errordomain Base32Error { + INCORRECT_PADDING, + INCORRECT_MAP01; + } + + private const string base32_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + private Array _base32_tab = null; + private GLib.HashTable _b32rev = null; + + public GLib.Bytes b32encode(GLib.Bytes input) { + if (_base32_tab == null) { + _base32_tab = new Array(); + var b32tab = new GLib.Bytes(base32_alphabet.data); + + + foreach (var a in b32tab.get_data()) { + foreach (var b in b32tab.get_data()) { + _base32_tab.append_val("%c%c".printf(a, b)); + } + } + } + + var leftover = input.length % 5; + var s = new GLib.ByteArray(); + s.append(input.get_data()); + + if (leftover > 0) { + var padding = new uint8[leftover + 1]; + + for (var i = 0; i <= leftover; i++) { + padding[i] = 0; + } + + s.append(padding); + } + + string encoded = ""; + var length = 0; + + for (var i = 0; i < s.len; i += 5) { + var next5 = s.data[i:i + 5]; + uint64 c = ((uint64)next5[0] << 32) + + ((uint64)next5[1] << 24) + + ((uint64)next5[2] << 16) + + ((uint64)next5[3] << 8) + + (uint64)next5[4]; + + encoded += _base32_tab.index((uint)(c >> 30)); + encoded += _base32_tab.index((uint)((c >> 20) & 0x3ff)); + encoded += _base32_tab.index((uint)((c >> 10) & 0x3ff)); + encoded += _base32_tab.index((uint)(c & 0x3ff)); + length += 8; + } + + int padder = 0; + if (leftover == 1) { + padder = -6; + } else if (leftover == 2) { + padder = -4; + } else if (leftover == 3) { + padder = -3; + } else if (leftover == 4) { + padder = -1; + } + for (var i = padder; i < 0; i++) { + encoded.data[encoded.length + i] = '='; + } + + return new GLib.Bytes(encoded.data); + } + + public GLib.Bytes b32decode(GLib.Bytes input, bool casefold, char map01) throws Base32Error { + if (_b32rev == null) { + _b32rev = new GLib.HashTable(null, null); + + for (var i = 0; i < base32_alphabet.length; i++) { + _b32rev[base32_alphabet[i]] = i; + } + } + + if (input.length % 8 != 0) { + throw new Base32Error.INCORRECT_PADDING("Incorrect padding"); + } + + var s = new GLib.ByteArray(); + + if (map01 != 0) { + if ((map01 != 'L') && (map01 != 'I')) { + throw new Base32Error.INCORRECT_MAP01("Incorrect map01 value"); + } + + var translated = new uint8[input.length]; + + var idata = input.get_data(); + + for (var i = 0; i < input.length; i++) { + if (idata[i] == 'O') { + translated[i] = '0'; + } else if (idata[i] == map01) { + translated[i] = '1'; + } else { + translated[i] = idata[i]; + } + } + + s.append(translated); + } else { + s.append(input.get_data()); + } + + if (casefold) { + for (var i = 0; i < s.len; i++) { + s.data[i] = ((char)s.data[i]).toupper(); + } + } + + var padchars = 0; + while (s.data[s.len - padchars - 1] == '=') { + padchars++; + } + + s.remove_range(s.len - padchars, padchars); + + + var decoded = new GLib.ByteArray(); + + uint64 acc = 0; + + for (var i = 0; i < input.length; i += 8) { + var quanta = s.data[i: i + 8]; + acc = 0; + + for (var j = 0; j < 8; j++) { + var c = quanta[j]; + acc = (acc << 5) + _b32rev[c]; + } + + uint8 res[5]; + + for (var j = 0; j < 5; j++) { + res[j] = (uint8)((acc >> (32 - j * 8)) & 0xff); + } + + decoded.append(res); + } + + if (padchars > 0) { + uint8 last[5]; + + for (var j = 0; j < 5; j++) { + last[j] = (uint8)((acc >> (32 - j * 8)) & 0xff); + } + + var pad_offset = 0; + + if (padchars == 1) { + pad_offset = -1; + } else if (padchars == 3) { + pad_offset = -2; + } else if (padchars == 4) { + pad_offset = -3; + } else if (padchars == 6) { + pad_offset = -4; + } else { + throw new Base32Error.INCORRECT_PADDING("Incorrect padding"); + } + + decoded.remove_range(decoded.len - 5, 5); + decoded.append(last[0:5 + pad_offset]); + } + + return new GLib.Bytes(decoded.data); + } +} diff --git a/src/gauth-window.vala b/src/gauth-window.vala index 9492ef5..f9c1184 100644 --- a/src/gauth-window.vala +++ b/src/gauth-window.vala @@ -1,11 +1,48 @@ namespace GAuthenticator { - [GtkTemplate (ui = "/eu/polonkai/gergely/gauthenticator/gauth-window.ui")] - class Window : Gtk.ApplicationWindow { - [GtkChild] - private Gtk.ProgressBar countdown; + [GtkTemplate (ui = "/eu/polonkai/gergely/gauthenticator/gauth-window.ui")] + class Window : Gtk.ApplicationWindow { + [GtkChild] + private Gtk.ProgressBar countdown; - public Window(GAuthenticator.App app) { - Object(application: app); - } - } + [GtkChild] + private Gtk.ListBox auth_list; + + private List rows = null; + + private void update_totps(bool first = false) { + foreach (var row in rows) { + row.update(first); + } + } + + private void check_update_totps() { + var now = new GLib.DateTime.now_utc(); + var remaining = 30.0 - (now.get_seconds() % 30.0); + + if (remaining <= 1.0 / 24.0) { + update_totps(); + } + } + + private bool update_countdown() { + var timestamp = new GLib.DateTime.now_local(); + var current_seconds = timestamp.get_seconds(); + var start_seconds = (current_seconds > 30.0) ? 30.0 : 0.0; + + countdown.fraction = 1.0 - ((current_seconds - start_seconds) / 30.0); + + check_update_totps(); + + return true; + } + + public Window(GAuthenticator.App app) { + Object(application: app); + + // Roughly 1/24 second, for a smooth(ish) update + GLib.Timeout.add(41, update_countdown); + + update_totps(true); + } + } } diff --git a/src/otp-row.vala b/src/otp-row.vala new file mode 100644 index 0000000..1a9a463 --- /dev/null +++ b/src/otp-row.vala @@ -0,0 +1,42 @@ +namespace GAuthenticator { + [GtkTemplate (ui = "/eu/polonkai/gergely/gauthenticator/otp-row.ui")] + class OTPRow : Gtk.Grid { + [GtkChild] + private Gtk.Label code; + + [GtkChild] + private Gtk.Label provider_name; + + [GtkChild] + private Gtk.Label account_name; + + public string secret {get; set;} + public string provider {get; set;} + public string account {get; set;} + + private OTP.TOTP generator; + + public OTPRow(string secret, string provider, string account) { + Object(secret: secret, provider: provider, account: account); + + generator = new OTP.TOTP(secret, 6, GLib.ChecksumType.SHA1, 30); + + provider_name.set_text(provider); + account_name.set_text(account); + } + + public void update(bool first = false) { + uint64 val; + + // FIXME: The first run is OK, but the rest is behind one cycle + if (first) { + val = generator.now(); + } else { + var dt = new GLib.DateTime.now_utc(); + val = generator.at(dt, 1); + } + + code.set_text("%06lu".printf((ulong)val)); + } + } +} diff --git a/src/otp.vala b/src/otp.vala new file mode 100644 index 0000000..8efc842 --- /dev/null +++ b/src/otp.vala @@ -0,0 +1,72 @@ +namespace OTP { + abstract class Base : Object { + private GLib.Bytes _secret; + + public string secret { + get { + // TODO: Terminate it with a zero! + return (string)_secret.get_data(); + } + + set { + _secret = Base32.b32decode(new GLib.Bytes(value.data), true, 0); + } + } + + public int digits {get; set; default = 6;} + public GLib.ChecksumType digest {get; set; default = GLib.ChecksumType.SHA1;} + + public Base(string secret_key, int digits, GLib.ChecksumType digest) { + Object(secret: secret_key, digits: digits, digest: digest); + } + + private static GLib.Bytes int_to_bytestring(uint64 val) { + var size = (int) sizeof(uint64); + var result = new uint8[size]; + for (int i = size - 1; i >= 0; i--) { + result[i] = (uint8)(val & 0xFF); + val >>= 8; + } + + return new GLib.Bytes(result); + } + + public uint64 generate_otp(uint64 input) { + // TODO: Make this dynamic based on the digest + uint8[256] result = {0}; + size_t result_len = 256; + + var hasher = new GLib.Hmac(digest, _secret.get_data()); + hasher.update(int_to_bytestring(input).get_data()); + hasher.get_digest(result, ref result_len); + + var offset = result[result_len - 1] & 0xf; + uint64 code = ((uint64)(result[offset] & 0x7f) << 24 | + (uint64)(result[offset + 1] & 0xff) << 16 | + (uint64)(result[offset + 2] & 0xff) << 8 | + (uint64)(result[offset + 3] & 0xff)); + return Math.llrint(code % GLib.Math.pow(10, digits)); + } + } + + class TOTP : Base { + public int interval {get; set; default = 30;} + + public TOTP(string secret_key, int digits, GLib.ChecksumType digest, int interval) { + Object(secret: secret_key, digits: digits, digest: digest); + } + + public uint64 at(GLib.DateTime for_time, int counter_offset) { + return generate_otp(timecode(for_time) + counter_offset); + } + + public uint64 now() { + return generate_otp(timecode(new GLib.DateTime.now_utc())); + } + + public uint64 timecode(GLib.DateTime for_time) { + return Math.llrint(Math.ceil(for_time.to_unix() / interval)); + } + } + +}