From 2a5d289c98a9869111c632787996e0ed2592017d Mon Sep 17 00:00:00 2001 From: kryptonbutterfly Date: Fri, 10 Jan 2025 20:48:54 +0100 Subject: [PATCH] update import, export logic and add capability to change the algorithm used to generate passwords --- pom.xml | 4 +- src/kryptonbutterfly/totp/TinyTotp.java | 2 + src/kryptonbutterfly/totp/TotpConstants.java | 1 + .../totp/misc/TotpGenerator.java | 2 +- .../totp/misc/UrlQueryParams.java | 42 ---- .../totp/misc/otp/Digits.java | 26 +++ .../totp/misc/otp/OtpUri.java | 97 +++++++++ .../totp/misc/otp/TotpUri.java | 190 ++++++++++++++++++ src/kryptonbutterfly/totp/prefs/OtpAlgo.java | 15 ++ .../totp/prefs/TotpEntry.java | 7 + .../totp/ui/add/manual/AddKey.java | 21 +- .../totp/ui/add/manual/BL.java | 11 +- src/kryptonbutterfly/totp/ui/main/BL.java | 29 ++- .../totp/ui/main/TotpComponent.java | 9 +- 14 files changed, 387 insertions(+), 69 deletions(-) delete mode 100644 src/kryptonbutterfly/totp/misc/UrlQueryParams.java create mode 100644 src/kryptonbutterfly/totp/misc/otp/Digits.java create mode 100644 src/kryptonbutterfly/totp/misc/otp/OtpUri.java create mode 100644 src/kryptonbutterfly/totp/misc/otp/TotpUri.java create mode 100644 src/kryptonbutterfly/totp/prefs/OtpAlgo.java diff --git a/pom.xml b/pom.xml index f82e251..dad629f 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 kryptonbutterfly tiny_totp - 4.1.0 + 4.2.0 TinyTOTP A TOTP client @@ -89,7 +89,7 @@ kryptonbutterfly linear_algebra - 3.0.0 + 4.0.0 diff --git a/src/kryptonbutterfly/totp/TinyTotp.java b/src/kryptonbutterfly/totp/TinyTotp.java index 7954166..6746a02 100644 --- a/src/kryptonbutterfly/totp/TinyTotp.java +++ b/src/kryptonbutterfly/totp/TinyTotp.java @@ -125,6 +125,7 @@ private static void setLookAndFeel() | SecurityException e) { e.printStackTrace(); + System.err.println("Failed setting program dock name. — Ignoring"); } try @@ -138,6 +139,7 @@ private static void setLookAndFeel() | UnsupportedLookAndFeelException e) { e.printStackTrace(); + System.err.println("Failed to apply System look and feel. – Ignoring"); } } diff --git a/src/kryptonbutterfly/totp/TotpConstants.java b/src/kryptonbutterfly/totp/TotpConstants.java index d8b07a7..160f9ae 100644 --- a/src/kryptonbutterfly/totp/TotpConstants.java +++ b/src/kryptonbutterfly/totp/TotpConstants.java @@ -42,6 +42,7 @@ public interface TotpConstants public static final String LABEL_ACCOUNT_NAME = "Account Name"; public static final String LABEL_SECRET_KEY = "Secret Key"; + public static final String LABEL_HASH_ALGO = "Hash Algorithm"; public static final String LABEL_ISSUER = "Issuer"; public static final String LABEL_CATEGORY = "Category"; public static final String LABEL_PASSWORD_INTERVAL = "Password interval in s"; diff --git a/src/kryptonbutterfly/totp/misc/TotpGenerator.java b/src/kryptonbutterfly/totp/misc/TotpGenerator.java index 80c5707..6a3c677 100644 --- a/src/kryptonbutterfly/totp/misc/TotpGenerator.java +++ b/src/kryptonbutterfly/totp/misc/TotpGenerator.java @@ -66,7 +66,7 @@ public static String generateTotp(TotpEntry entry, char[] password, long current entry.decryptSecret(password), steps, entry.totpLength, - "HmacSHA1"); + "Hmac" + entry.algorithm); } @SneakyThrows diff --git a/src/kryptonbutterfly/totp/misc/UrlQueryParams.java b/src/kryptonbutterfly/totp/misc/UrlQueryParams.java deleted file mode 100644 index 3269046..0000000 --- a/src/kryptonbutterfly/totp/misc/UrlQueryParams.java +++ /dev/null @@ -1,42 +0,0 @@ -package kryptonbutterfly.totp.misc; - -import kryptonbutterfly.monads.opt.Opt; - -public record UrlQueryParams(String issuer, String secretKey, String account) -{ - - private static final String ISSUER = "\\&issuer\\="; - private static final String SECRET = "\\?secret\\="; - - private static final String KEY_ISSUER = "&issuer="; - private static final String KEY_SECRET = "?secret="; - - public final String toUrl() - { - return toUrl(account, secretKey, issuer); - } - - private static final String toUrl(String account, String secretKey, String issuer) - { - return "otpauth://totp/" + account + KEY_SECRET + secretKey + KEY_ISSUER + issuer; - } - - public static final Opt parseUrl(String url) - { - try - { - var split = url.split(ISSUER); - final var issuer = split[1]; - split = split[0].split(SECRET); - final var secretKey = split[1]; - final var remainder = split[0]; - final int end = Math.max(remainder.lastIndexOf("/"), remainder.lastIndexOf(":")) + 1; - final var account = remainder.substring(end); - return Opt.of(new UrlQueryParams(issuer, secretKey, account)); - } - catch (ArrayIndexOutOfBoundsException e) - { - return Opt.empty(); - } - } -} diff --git a/src/kryptonbutterfly/totp/misc/otp/Digits.java b/src/kryptonbutterfly/totp/misc/otp/Digits.java new file mode 100644 index 0000000..b1690a1 --- /dev/null +++ b/src/kryptonbutterfly/totp/misc/otp/Digits.java @@ -0,0 +1,26 @@ +package kryptonbutterfly.totp.misc.otp; + +enum Digits +{ + _6(6), + _7(7), + _8(8); + + public final int digits; + + Digits(int digits) + { + this.digits = digits; + } + + public static Digits of(String value) + { + return switch (value) + { + case "6" -> _6; + case "7" -> _7; + case "8" -> _8; + default -> null; + }; + } +} diff --git a/src/kryptonbutterfly/totp/misc/otp/OtpUri.java b/src/kryptonbutterfly/totp/misc/otp/OtpUri.java new file mode 100644 index 0000000..1e4dd32 --- /dev/null +++ b/src/kryptonbutterfly/totp/misc/otp/OtpUri.java @@ -0,0 +1,97 @@ +package kryptonbutterfly.totp.misc.otp; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.util.HashMap; + +import kryptonbutterfly.totp.prefs.OtpAlgo; + +public sealed interface OtpUri permits TotpUri +{ + static final String PROTOCOL_TERMINATOR = "://"; + static final String PROTOCOL = "otpauth://"; + static final String TYPE_TERMINATOR = "/"; + static final String PATH_TERMINATOR = "?"; + static final String PARAM_DELIM = "="; + static final String QUERY_DELIM = "&"; + static final String ISSUER_TERMINATOR = ":"; + + static final String SECRET = "secret"; + static final String ALGORITHM = "algorithm"; + static final String DIGITS = "digits"; + static final String COUNTER = "counter"; + static final String PERIOD = "period"; + static final String ISSUER = "issuer"; + + public static final int DEFAULT_PERIOD = 30; + public static final Digits DEFAULT_DIGITS = Digits._6; + public static final OtpAlgo DEFAULT_ALGO = OtpAlgo.SHA1; + + public String toStringUrl(); + + public String account(); + + public String secret(); + + public OtpAlgo algorithm(); + + public int digits(); + + public int counter(); + + public int period(); + + public String type(); + + public String issuer(); + + public static OtpUri parseOtpUrl(String url) throws MalformedURLException, URISyntaxException + { + if (!url.startsWith(PROTOCOL)) + { + final int term = url.indexOf(PROTOCOL_TERMINATOR); + if (term < 0) + throw new MalformedURLException("No protocol specified."); + final var protocol = url.substring(term); + throw new MalformedURLException( + "Invalid protocol '%s' for OTP URL. Expected %s.".formatted(protocol, PROTOCOL)); + } + final var urlPart = url.substring(PROTOCOL.length()); + + final int typeTerm = urlPart.indexOf(TYPE_TERMINATOR); + if (typeTerm < 0) + throw new MalformedURLException("OTP URL is missing type declaration!"); + final var type = urlPart.substring(0, typeTerm); + return switch (type) + { + case "totp" -> TotpUri.parse(url); + case "htop" -> throw new IllegalStateException( + "HTOP's currently are not supported."); + default -> throw new MalformedURLException( + "'%s' is not a valid OTP type!".formatted(type)); + }; + } + + public static String buildOtpUriString(OtpUri uri, HashMap query) + { + final var sb = new StringBuilder(PROTOCOL) + .append(uri.type()) + .append(TYPE_TERMINATOR) + .append(uri.account()) + .append(PATH_TERMINATOR); + + boolean isFirst = true; + for (final var p : query.entrySet()) + { + if (isFirst) + isFirst = false; + else + sb.append(QUERY_DELIM); + sb.append(p.getKey()) + .append(PARAM_DELIM) + .append(p.getValue()); + } + + return sb.toString(); + } +} diff --git a/src/kryptonbutterfly/totp/misc/otp/TotpUri.java b/src/kryptonbutterfly/totp/misc/otp/TotpUri.java new file mode 100644 index 0000000..c20482b --- /dev/null +++ b/src/kryptonbutterfly/totp/misc/otp/TotpUri.java @@ -0,0 +1,190 @@ +package kryptonbutterfly.totp.misc.otp; + +import static kryptonbutterfly.math.utils.range.Range.*; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Objects; + +import kryptonbutterfly.monads.opt.Opt; +import kryptonbutterfly.totp.prefs.OtpAlgo; + +public final class TotpUri implements OtpUri +{ + private final String issuer, account, secret; + private final OtpAlgo algo; + private final Digits digits; + private final Opt counter; + private final int period; + + private TotpUri( + String issuer, + String account, + String secret, + OtpAlgo algo, + Digits digits, + Opt counter, + int period) + { + Objects.requireNonNull(issuer); + Objects.requireNonNull(account); + Objects.requireNonNull(secret); + Objects.requireNonNull(algo); + Objects.requireNonNull(digits); + + this.issuer = issuer; + this.account = account; + this.secret = secret; + this.algo = algo; + this.digits = digits; + this.counter = counter; + this.period = period; + } + + public TotpUri( + String issuer, + String account, + String secret, + int digits, + int period) + { + this(issuer, account, secret, DEFAULT_ALGO, Digits.of("" + digits), Opt.empty(), period); + } + + private TotpUri( + String issuer, + String account, + String secret, + Opt algo, + Opt digits, + Opt counter, + Opt period) + { + this(issuer, account, secret, algo.get(() -> OtpAlgo.SHA1), digits + .get(() -> DEFAULT_DIGITS), counter, period.get(() -> DEFAULT_PERIOD)); + } + + static TotpUri parse(String url) throws URISyntaxException, MalformedURLException + { + final var uri = new URI(url); + final var query = uri.getQuery().split(QUERY_DELIM); + final var params = new HashMap(); + for (final var ie : range(query)) + { + final var split = ie.element().split(PARAM_DELIM); + if (split.length == 2) + params.put(split[0], split[1]); + else + System.err.printf( + "[WARN]\tQueryparam contains '%s' %d times. Expected 1. Ignoring this param.", + QUERY_DELIM, + split.length); + } + + final String secret = params.get(SECRET); + if (secret == null) + throw new MalformedURLException("OTP URL secret missing."); + final Opt algorithm = Opt.of(params.get(ALGORITHM)).map(OtpAlgo::valueOf); + final Opt digits = Opt.of(params.get(DIGITS)).map(Digits::of); + final Opt counter = Opt.of(params.get(COUNTER)).map(Integer::valueOf); + final Opt period = Opt.of(params.get(PERIOD)).map(Integer::valueOf); + + final String label = uri.getPath().substring(TYPE_TERMINATOR.length()); + + final String account; + final String issuer; + if (label.contains(ISSUER_TERMINATOR)) + { + final int issuerTerm = label.indexOf(ISSUER_TERMINATOR); + issuer = label.substring(0, issuerTerm); + if (params.containsKey(ISSUER) && !params.get(ISSUER).equals(issuer)) + throw new MalformedURLException( + "conflicting issuer: %s – %s".formatted(issuer, params.get(ISSUER))); + account = label.substring(issuerTerm + ISSUER_TERMINATOR.length()); + } + else + { + account = label; + issuer = params.get(ISSUER); + } + if (!Objects.nonNull(issuer)) + throw new MalformedURLException("OTP URL issuer missing."); + + return new TotpUri(issuer, account, secret, algorithm, digits, counter, period); + } + + @Override + public String toStringUrl() + { + final var map = new HashMap(); + map.put(SECRET, secret); + map.put(ISSUER, issuer); + if (algo != DEFAULT_ALGO) + map.put(ALGORITHM, account); + if (digits != DEFAULT_DIGITS) + map.put(DIGITS, "" + digits.digits); + counter.if_(c -> map.put(COUNTER, "" + c)); + if (period != DEFAULT_PERIOD) + map.put(PERIOD, "" + period); + + return OtpUri.buildOtpUriString(this, map); + } + + @Override + public String account() + { + return account; + } + + @Override + public String secret() + { + return secret; + } + + @Override + public OtpAlgo algorithm() + { + return algo; + } + + @Override + public int digits() + { + return digits.digits; + } + + public boolean hasCounter() + { + return counter.isPresent(); + } + + @Override + /** + * @return The counter or {@code Integer.MIN_VALUE} if not present. + */ + public int counter() + { + return counter.get(() -> Integer.MIN_VALUE); + } + + @Override + public int period() + { + return period; + } + + @Override + public String issuer() + { + return issuer; + } + + @Override + public String type() + { + return "totp"; + } +} diff --git a/src/kryptonbutterfly/totp/prefs/OtpAlgo.java b/src/kryptonbutterfly/totp/prefs/OtpAlgo.java new file mode 100644 index 0000000..4a9ce8c --- /dev/null +++ b/src/kryptonbutterfly/totp/prefs/OtpAlgo.java @@ -0,0 +1,15 @@ +package kryptonbutterfly.totp.prefs; + +public enum OtpAlgo +{ + SHA1, + SHA256, + SHA512; + + public final String algo; + + private OtpAlgo() + { + this.algo = this.name(); + } +} diff --git a/src/kryptonbutterfly/totp/prefs/TotpEntry.java b/src/kryptonbutterfly/totp/prefs/TotpEntry.java index 6db0e51..65c7895 100644 --- a/src/kryptonbutterfly/totp/prefs/TotpEntry.java +++ b/src/kryptonbutterfly/totp/prefs/TotpEntry.java @@ -14,6 +14,7 @@ import com.google.gson.annotations.Expose; import kryptonbutterfly.totp.TotpConstants; +import kryptonbutterfly.totp.misc.otp.OtpUri; import lombok.SneakyThrows; public class TotpEntry implements TotpConstants @@ -24,12 +25,18 @@ public TotpEntry() @Expose public String encryptedSecret = null; + @Expose + public OtpAlgo algorithm = OtpUri.DEFAULT_ALGO; + @Expose public String salt = createSalt(); @Expose public int totpLength = 6; + @Expose + public Integer counter = null; + @Expose public int totpValidForSeconds = 30; diff --git a/src/kryptonbutterfly/totp/ui/add/manual/AddKey.java b/src/kryptonbutterfly/totp/ui/add/manual/AddKey.java index 757019b..499e703 100644 --- a/src/kryptonbutterfly/totp/ui/add/manual/AddKey.java +++ b/src/kryptonbutterfly/totp/ui/add/manual/AddKey.java @@ -21,7 +21,8 @@ import kryptonbutterfly.totp.TinyTotp; import kryptonbutterfly.totp.TotpConstants; import kryptonbutterfly.totp.misc.Assets; -import kryptonbutterfly.totp.misc.UrlQueryParams; +import kryptonbutterfly.totp.misc.otp.OtpUri; +import kryptonbutterfly.totp.prefs.OtpAlgo; import kryptonbutterfly.totp.prefs.TotpCategory; import kryptonbutterfly.totp.prefs.TotpEntry; import kryptonbutterfly.totp.ui.components.ComboIcon; @@ -45,6 +46,8 @@ public final class AddKey extends ObservableDialog implem final JSpinner spinnerTimeFrame = new JSpinner(new SpinnerNumberModel(30, 0, 180, 5)); final JSpinner spinnerTotpLength = new JSpinner(new SpinnerNumberModel(6, 6, 10, 1)); + final JComboBox cbAlgo = new JComboBox<>(OtpAlgo.values()); + JButton btnApply; public AddKey( @@ -61,7 +64,7 @@ public AddKey( Window owner, ModalityType modality, Consumer> closeListener, - UrlQueryParams parsed, + OtpUri otpUri, char[] password, String title) { @@ -71,9 +74,9 @@ public AddKey( businessLogic.if_(this::init); - txtAccountname.setText(parsed.account()); - txtSecretkey.setText(parsed.secretKey()); - txtIssuer.setText(parsed.issuer()); + txtAccountname.setText(otpUri.account()); + txtSecretkey.setText(otpUri.secret()); + txtIssuer.setText(otpUri.issuer()); setVisible(true); } @@ -103,6 +106,7 @@ public AddKey( TinyTotp.imageCache.getImage(iconName).if_(comboIcon::setIssuerIcon); TinyTotp.imageCache.getImage(userIconName).if_(comboIcon::setUserIcon); comboIcon.setIconBG(original.iconBG); + cbAlgo.setSelectedItem(original.algorithm); spinnerTimeFrame.setValue(original.totpValidForSeconds); spinnerTotpLength.setValue(original.totpLength); } @@ -159,6 +163,13 @@ private void init(BL bl) txtSecretkey.addKeyListener(bl.escapeListener); } + { + gridPanel.add(new JLabel(LABEL_HASH_ALGO), BorderLayout.CENTER); + + gridPanel.add(cbAlgo); + cbAlgo.addKeyListener(bl.escapeListener); + } + { gridPanel.add(new JLabel(LABEL_PASSWORD_INTERVAL)); diff --git a/src/kryptonbutterfly/totp/ui/add/manual/BL.java b/src/kryptonbutterfly/totp/ui/add/manual/BL.java index 0b494b7..3e42ad2 100644 --- a/src/kryptonbutterfly/totp/ui/add/manual/BL.java +++ b/src/kryptonbutterfly/totp/ui/add/manual/BL.java @@ -10,6 +10,7 @@ import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; +import java.util.Objects; import javax.swing.event.PopupMenuEvent; import javax.swing.event.PopupMenuListener; @@ -21,8 +22,9 @@ import kryptonbutterfly.functions.void_.Consumer_; import kryptonbutterfly.monads.opt.Opt; import kryptonbutterfly.totp.TinyTotp; -import kryptonbutterfly.totp.misc.UrlQueryParams; import kryptonbutterfly.totp.misc.Utils; +import kryptonbutterfly.totp.misc.otp.TotpUri; +import kryptonbutterfly.totp.prefs.OtpAlgo; import kryptonbutterfly.totp.prefs.TotpCategory; import kryptonbutterfly.totp.prefs.TotpEntry; import kryptonbutterfly.totp.ui.misc.KeyTypedAdapter; @@ -139,8 +141,10 @@ void exportQR(ActionEvent ae) final var account = gui.txtAccountname.getText(); final var secretKey = gui.txtSecretkey.getText(); final var issuer = gui.txtIssuer.getText(); - final var url = new UrlQueryParams(issuer, secretKey, account).toUrl(); - Utils.generateQr(url, Color.BLACK, Color.WHITE, 200, ErrorCorrectionLevel.H) + final int digits = (int) gui.spinnerTotpLength.getValue(); + final int period = (int) gui.spinnerTimeFrame.getValue(); + final var url = new TotpUri(issuer, account, secretKey, digits, period); + Utils.generateQr(url.toStringUrl(), Color.BLACK, Color.WHITE, 200, ErrorCorrectionLevel.H) .if_( qr -> EventQueue .invokeLater(() -> new QrExportGui(gui, ModalityType.APPLICATION_MODAL, Consumer_.sink(), qr))); @@ -169,6 +173,7 @@ void apply(ActionEvent ae) totpEntry.userIcon = gui.userIconName; totpEntry.iconBG = gui.comboIcon.getIconBG(); + totpEntry.algorithm = Objects.requireNonNull((OtpAlgo) gui.cbAlgo.getSelectedItem()); totpEntry.totpLength = (int) gui.spinnerTotpLength.getValue(); totpEntry.totpValidForSeconds = (int) gui.spinnerTimeFrame.getValue(); diff --git a/src/kryptonbutterfly/totp/ui/main/BL.java b/src/kryptonbutterfly/totp/ui/main/BL.java index 2c541ac..6e1acd9 100644 --- a/src/kryptonbutterfly/totp/ui/main/BL.java +++ b/src/kryptonbutterfly/totp/ui/main/BL.java @@ -5,13 +5,15 @@ import java.awt.Dialog.ModalityType; import java.awt.EventQueue; import java.awt.event.ActionEvent; +import java.net.MalformedURLException; +import java.net.URISyntaxException; import java.util.ArrayList; import javax.swing.JOptionPane; import kryptonbutterfly.totp.TinyTotp; -import kryptonbutterfly.totp.misc.UrlQueryParams; import kryptonbutterfly.totp.misc.TotpGenerator; +import kryptonbutterfly.totp.misc.otp.OtpUri; import kryptonbutterfly.totp.prefs.TotpEntry; import kryptonbutterfly.totp.ui.add.manual.AddKey; import kryptonbutterfly.totp.ui.categories.CategoriesGui; @@ -61,26 +63,31 @@ void addQrEntry(ActionEvent ae) ModalityType.APPLICATION_MODAL, gce -> gce.getReturnValue().if_(url -> { - UrlQueryParams.parseUrl(url) - .if_(e -> createEntry(gui, e)) - .else_( - () -> JOptionPane.showMessageDialog( - gui, - "Unable to import secret.\nUnexpected qr content.", - "Import failed", - JOptionPane.ERROR_MESSAGE)); + try + { + final var uri = OtpUri.parseOtpUrl(url); + createEntry(gui, uri); + } + catch (MalformedURLException | URISyntaxException e) + { + JOptionPane.showMessageDialog( + gui, + "Unable to import secret.\n\n%s".formatted(e), + "Import failed", + JOptionPane.ERROR_MESSAGE); + } }), "Import Secret"))); } - private void createEntry(MainGui gui, UrlQueryParams entry) + private void createEntry(MainGui gui, OtpUri uri) { EventQueue.invokeLater( () -> new AddKey( gui, ModalityType.APPLICATION_MODAL, this::addEntry, - entry, + uri, password, "Add TOTP Secret")); } diff --git a/src/kryptonbutterfly/totp/ui/main/TotpComponent.java b/src/kryptonbutterfly/totp/ui/main/TotpComponent.java index 8f76b92..884c4d4 100644 --- a/src/kryptonbutterfly/totp/ui/main/TotpComponent.java +++ b/src/kryptonbutterfly/totp/ui/main/TotpComponent.java @@ -49,11 +49,10 @@ public final class TotpComponent extends JPanel implements TotpConstants private final JPanel categoryPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); private final JButton buttonEdit = new JButton(Assets.getEditByBackground(getBackground())); private final JButton buttonRemove = new JButton(Assets.getDeleteByBackground(getBackground())); - // private final JLabel iconLabel = new JLabel(); - private final ComboIcon comboIcon = new ComboIcon(); - private final JButton totp = new JButton(); - private final JLabel userName = new JLabel(); - private final JLabel issuerName = new JLabel(); + private final ComboIcon comboIcon = new ComboIcon(); + private final JButton totp = new JButton(); + private final JLabel userName = new JLabel(); + private final JLabel issuerName = new JLabel(); TotpComponent(TotpEntry entry, RemoveListener removeListener, char[] password) {