Compare commits

20 Commits

Author SHA1 Message Date
386d4f7434 merge conflicts 2025-09-25 10:08:36 +02:00
82b1407489 delete all related with DB tests 2025-09-25 09:55:20 +02:00
d86054f3a3 Preparing v1.1 2025-09-25 09:43:55 +02:00
547e143f0c Add compiled classes and script for running 2025-09-19 00:00:21 +02:00
48478c49bc Fix warnings 2025-09-09 00:51:33 +02:00
ba58c7d744 Copy native executable to ~/bin 2025-09-08 14:52:57 +02:00
32cf9cb581 Compilation options for java 2025-09-08 08:54:10 +02:00
29a1061d18 Compilation options for java 2025-09-08 07:52:49 +02:00
13be73f7e2 get multiple apps 2025-09-05 14:00:20 +02:00
38586187e9 Fix native compilation 2025-09-04 15:39:13 +02:00
af987008b0 Fix uber build 2025-09-04 14:51:33 +02:00
ba393ec55a Import from exported URL 2025-09-04 14:44:40 +02:00
a85fc61e16 otpauth-migration using protobuf 2025-09-04 09:53:32 +02:00
7b629b4b0d Import from otpauth url 2025-09-03 00:51:45 +02:00
72923a34ff working in import from URL 2025-09-02 16:22:38 +02:00
fd012eea00 new macro and create file on first add command 2025-09-02 14:08:08 +02:00
2aeef9925d multiple simple totps 2025-09-02 00:45:38 +02:00
5651cc1ab2 Merge pull request 'release/1.0' (#1) from release/1.0 into main
Reviewed-on: #1
2025-09-01 18:38:12 +02:00
96ed6ae1e9 Updated READMEç 2025-09-01 18:33:47 +02:00
d8c3f5ee67 Deleting unfinished features 2025-09-01 10:37:11 +02:00
32 changed files with 3405 additions and 240 deletions

162
README.md
View File

@@ -1,6 +1,7 @@
# clj-totp # clj-totp
TOTP (Time-based One Time Password) in clojure. It can be used in the command line, web API o simple embedded web. TOTP (Time-based One Time Password) in clojure. It supports several digest algorithms and length.
## What is TOPT ## What is TOPT
@@ -14,27 +15,174 @@ You can read more about the algorithm here:
- HOTP RFC: https://www.ietf.org/rfc/rfc4226.txt - HOTP RFC: https://www.ietf.org/rfc/rfc4226.txt
## The inside ## How to use
This project is done 100% in clojure. It uses `deps.edn` for configuring the project and `build.clj` for compiling. First, you must have installed a Java Runtime Environment. Check https://adoptium.net/es if you are
unsure how to install.
## Implementation timeline The project is distributed as a jar file with all dependencies included (a.k.a. "uberjar"), and uses
a simple script to launch the program. Script and uberjar must be in the same directory.
Execute without parameters to show main commands
```bash
clj-totp.sh
```
A quick description of each command:
- `generate`: Show a TOTP with a given secret and parameters, not stored in config.
- `config`: Manage configuration file
- `add`: Store a new TOTP configuration
- `delete`: Delete an stored configuration
- `list`: Shows a list of TOPT stored configurations
- `get`: Generate a TOTP previously added
- `import`: Import a URL with the TOTP configuration
### Quick and simple generation
If you want to quickly generate a TOPT, you only need to suministrate the secret in B32 format:
```bash
clj-totp.sh generate <B32 secret>
```
The OTP changes every 30 seconds, you can print every change with `-c` option:
```bash
clj-totp.sh generate -c <B32 secret>
```
It will update the TOTP every 30 seconds, until you press `<Enter>` or `<ctrl+c>`.
### Store your configurations
Writing the B32 secret each time can be a bit tedious, but you can store secrets for your applications.
Be carefull, this version **don't encrypt passwords**, secrets are saved in plain text in a file in
your home dir.
With the `config` command you can check your configuration file. Now, let's explorer the subcommands:
Check if the config file exists, and show the full path:
```bash
clj-totp.sh info config
```
Create a new config file, if the file exists, it will prompt you if you want to overwrite it:
``` bash
clj-totp.sh info init
```
Show all data contained in the config file, as a table:
``` bash
clj-totp.sh info show
```
### Use stored configurations
If you have a valid configuration file, it's time to configure some applications.
To add a new configured application, you can use the `add` command. The simplest way to add a new
configuration is to specify an alias and the secret in B32:
``` bash
clj-totp.sh add <alias> <b32 secret>
```
The `add` subcommand has a lot of configuration options, you can explore them wit the `-?` param.
To list all added configurations, use the `list` command:
``` bash
clj-totp.sh list
```
If you made a mistake, you can delete a configured app with the `delete` command:
``` bash
clj-totp.sh delete <alias>
```
When you have some configured apps, it's time to use them, with the `get` command. To generate a
single TOTP for some app you can simple pass a list of alias:
``` bash
clj-totp.sh get <alias1> <alias2> <aliasN>
```
It will show the TOTP value at the current time, but TOTPs changes every 30 seconds, to show
the value when it changes, add the `-c` param. It will update the TOTP for each alias until you
press enter key (or <Ctrl-C>):
``` bash
clj-totp.sh get <alias1> <alias2> <aliasN> -c
```
Finally, this program has an `import` command, that can import from a decoded QR or exported data
from Google Autenticator:
``` bash
clj-totp.sh import <alias> "<url>"
```
## Project's plan
### v1.0 ### v1.0
- [x] Functional TOTP generation - [x] Functional TOTP generation
- [x] Get TOTP from command line - [x] Get TOTP from command line
- [x] Continuous generation - [x] Continuous generation
- [ ] Store configuration in a properties file or simple DB
### v1.1 ### v1.1
- [x] Store configuration in a properties file or simple DB
- [x] Import from `otpauth` and `otpauth-migration` protocols
- [x] Show several OTPs at once
### v1.2
- [ ] REST API - [ ] REST API
- [ ] User management - [ ] User management
- [ ] Robust BD backend (H2, datomic, or similar) - [ ] Robust BD backend (H2, datomic, or similar)
### v1.2 ### v1.3
- [ ] Simple web connected to REST API - [ ] Simple web connected to REST API
## Ideas ## Ideas
- Import from google auth URL: https://github.com/dim13/otpauth
Some ideas for future versions:
- Store passwords securely: https://github.com/weavejester/crypto-password - Store passwords securely: https://github.com/weavejester/crypto-password
## Building the project
This project is done 100% in clojure. It uses `deps.edn` for configuring the project and `build.clj`
for defining compilation tasks.
The first step is to install Java JDK, version 11 or newer (version 21 recommended).
To execute manually the main function, simple use the `:run` alias:
```clojure
clojure -M:run <commands and parameters>
```
To build the uberjar:
```clojure
clojure -T:build uber
```
There is a utility script to build a native executable using Graal VM. Please, edit the script and
check the path to your Graal installation. Use it at your own risk.
```bash
native.sh
```

View File

@@ -1,9 +1,10 @@
(ns build (ns build
(:require [clojure.tools.build.api :as b])) (:require [clojure.tools.build.api :as b]))
(def lib 'es.rcorral/clj-topt) (def lib 'es.rcorral/clj-totp)
(def version (format "1.1.%s" (b/git-count-revs nil))) (def version (format "1.1.%s" (b/git-count-revs nil)))
(def class-dir "target/classes") (def target-dir "target")
(def class-dir (str target-dir "/classes"))
(def uber-file (format "target/%s-%s-standalone.jar" (name lib) version)) (def uber-file (format "target/%s-%s-standalone.jar" (name lib) version))
;; delay to defer side effects (artifact downloads) ;; delay to defer side effects (artifact downloads)
@@ -12,10 +13,21 @@
(defn clean [_] (defn clean [_]
(b/delete {:path "target"})) (b/delete {:path "target"}))
(defn compile-java [_]
(b/javac {:src-dirs ["java"]
:class-dir class-dir
:basis @basis
:javac-opts ["-source" "11" "--target" "11" "-proc:none"]}))
#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]}
(defn uber [_] (defn uber [_]
(clean nil) (clean nil)
(b/copy-dir {:src-dirs ["src" "resources"] (b/copy-dir {:src-dirs ["src"]
:target-dir class-dir}) :target-dir class-dir})
(b/copy-file {:src "resources/clj-totp.sh"
:target "target/clj-totp.sh"})
(compile-java nil)
(b/compile-clj {:basis @basis (b/compile-clj {:basis @basis
:ns-compile '[totp.app] :ns-compile '[totp.app]
:class-dir class-dir}) :class-dir class-dir})

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="BuildSystem">
<option name="buildSystemId" value="CLOJURE_DEPS" />
<option name="displayName" value="clj-totp" />
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Deps: org.clojure/clojure:1.12.1" level="project" />
<orderEntry type="library" name="Deps: lambdaisland/deep-diff2:2.11.216" level="project" />
<orderEntry type="library" name="Deps: org.clojure/core.specs.alpha:0.4.74" level="project" />
<orderEntry type="library" name="Deps: lambdaisland/kaocha:1.91.1392" level="project" />
<orderEntry type="library" name="Deps: expound:0.9.0" level="project" />
<orderEntry type="library" name="Deps: org.clojure/spec.alpha:0.5.238" level="project" />
<orderEntry type="library" name="Deps: org.clojure/tools.cli:1.1.230" level="project" />
<orderEntry type="library" name="Deps: lambdaisland/clj-diff:1.4.78" level="project" />
<orderEntry type="library" name="Deps: net.incongru.watchservice/barbary-watchservice:1.0" level="project" />
<orderEntry type="library" name="Deps: slingshot:0.12.2" level="project" />
<orderEntry type="library" name="Deps: fipp:0.6.26" level="project" />
<orderEntry type="library" name="Deps: com.nextjournal/beholder:1.0.2" level="project" />
<orderEntry type="library" name="Deps: aero:1.1.6" level="project" />
<orderEntry type="library" name="Deps: lambdaisland/tools.namespace:0.3.256" level="project" />
<orderEntry type="library" name="Deps: mvxcvi/arrangement:2.1.0" level="project" />
<orderEntry type="library" name="Deps: io.methvin/directory-watcher:0.17.3" level="project" />
<orderEntry type="library" name="Deps: progrock:0.1.2" level="project" />
<orderEntry type="library" name="Deps: org.clojure/java.classpath:1.0.0" level="project" />
<orderEntry type="library" name="Deps: clojure.java-time:1.4.3" level="project" />
<orderEntry type="library" name="Deps: org.clojure/core.rrb-vector:0.1.2" level="project" />
<orderEntry type="library" name="Deps: net.java.dev.jna/jna:5.12.1" level="project" />
<orderEntry type="library" name="Deps: org.clojure/tools.reader:1.3.6" level="project" />
<orderEntry type="library" name="Deps: org.tcrawley/dynapath:1.1.0" level="project" />
<orderEntry type="library" name="Deps: org.slf4j/slf4j-api:1.7.36" level="project" />
<orderEntry type="library" name="Deps: hawk:0.2.11" level="project" />
<orderEntry type="library" name="Deps: meta-merge:1.0.0" level="project" />
</component>
</module>

4
compile_proto.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/env sh
protoc --java_out java/protoc/ resources/proto/otpauth-migration.proto
#javac -cp resources/protobuf-java-3.25.8.jar -d target/classes/proto src/OtpauthMigration.java

View File

@@ -1,24 +1,22 @@
{:paths ["src"] {:paths ["src" "resources" "target/classes"]
:deps {org.clojure/clojure {:mvn/version "1.12.1"} :deps {org.clojure/clojure {:mvn/version "1.12.1"}
io.github.clojure/tools.build {:mvn/version "0.10.10"} io.github.clojure/tools.build {:mvn/version "0.10.10"}
mvxcvi/alphabase {:mvn/version "3.0.185"} ;; https://github.com/greglook/alphabase mvxcvi/alphabase {:mvn/version "3.0.185"} ;; https://github.com/greglook/alphabase
cli-matic/cli-matic {:mvn/version "0.5.4"} ;; https://github.com/l3nz/cli-matic cli-matic/cli-matic {:mvn/version "0.5.4"} ;; https://github.com/l3nz/cli-matic
;; For SQLite
com.github.seancorfield/next.jdbc {:mvn/version "1.3.1048"}
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
;; For Datomic local
com.datomic/local {:mvn/version "1.0.291"};; https://docs.datomic.com/datomic-local.html
;; Native image (GraalVM) ;; Native image (GraalVM)
com.github.clj-easy/graal-build-time {:mvn/version "1.0.5"}};; Tutorial: https://shagunagrawal.me/posts/setup-clojure-with-graalvm-for-native-image/ com.github.clj-easy/graal-build-time {:mvn/version "1.0.5"};; Tutorial: https://shagunagrawal.me/posts/setup-clojure-with-graalvm-for-native-image/
;; Protobuf for java
com.google.protobuf/protobuf-java {:mvn/version "3.25.8"}}
:aliases {;; Execute the app :aliases {;; Execute the app
:run {:main-opts ["-m" "totp.app"]} :run {:main-opts ["-m" "totp.app"]}
;; Kaocha runner. You can use the 'kaocha' wrapper located in ~/bin/kaocha ;; Kaocha runner. You can use the 'kaocha' wrapper located in ~/bin/kaocha
:test {:extra-paths ["test"] ;; https://cljdoc.org/d/uberdeps/uberdeps/1.4.0/doc/readme :test {:extra-paths ["test"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}} :extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}
:main-opts ["-m" "kaocha.runner"]} :main-opts ["-m" "kaocha.runner"]}
;; Run with clj -T:build function-in-build ;; Run with clj -T:build function-in-build
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.10"}} :build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.10"}}
:ns-default build}}} :ns-default build}}}

View File

@@ -1,34 +0,0 @@
@startuml
' configuration
skinparam linetype ortho
entity "user" as user {
id: number
--
login: varchar(64)
passw: varchar(512)
active: shortint
desc: varchar(512)
config: varchar(512)
}
entity "app" as app {
id: number
--
name: varchar(32)
desc: varchar(512)
secret: varchar(512)
period: int
config: varchar(512)
}
entity "user_app" as user_app {
user_id: number
app_id: number
--
}
user ||--o{ user_app
app ||--o{ user_app
@enduml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
#!/usr/bin/env sh #!/usr/bin/env sh
NATIVE=~/.sdkman/candidates/java/21.0.2-graalce/bin/native-image NATIVE=~/.sdkman/candidates/java/21.0.2-graalce/bin/native-image
UBERJAR=clj-topt-1.0.38-standalone.jar
BIN_FILE=totp BIN_FILE=totp
echo "Creating uberjar" echo "Creating uberjar"
clojure -T:build uber clojure -T:build uber
UBERJAR=$(realpath --relative-to=target target/clj-totp-*-standalone.jar)
echo "Creating native image" echo "Creating native image"
$NATIVE -jar target/$UBERJAR -o target/$BIN_FILE -H:+ReportExceptionStackTraces --features=clj_easy.graal_build_time.InitClojureClasses --report-unsupported-elements-at-runtime --verbose --no-fallback -H:ReflectionConfigurationFiles=./reflect_config.json $NATIVE -jar target/$UBERJAR -o target/$BIN_FILE -H:+ReportExceptionStackTraces --features=clj_easy.graal_build_time.InitClojureClasses --report-unsupported-elements-at-runtime --verbose --no-fallback -H:ReflectionConfigurationFiles=./reflect_config.json
echo "Executable created on target/$BIN_FILE" echo "Executable created on target/$BIN_FILE"
cp target/$BIN_FILE ~/bin
echo "Copied to ~/bin/$BIN_FILE"

8
resources/clj-totp.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env sh
JAVA_EXECUTABLE=java
UBER_JAR=$(realpath clj-totp-*-standalone.jar)
OPTS="-Xms256m -Xmx256m -client -Dclojure.spec.skip-macros=true"
$JAVA_EXECUTABLE $OPTS -jar $UBER_JAR $@

View File

@@ -0,0 +1,39 @@
syntax = "proto3";
message MigrationPayload {
enum Algorithm {
ALGORITHM_UNSPECIFIED = 0;
ALGORITHM_SHA1 = 1;
ALGORITHM_SHA256 = 2;
ALGORITHM_SHA512 = 3;
ALGORITHM_MD5 = 4;
}
enum DigitCount {
DIGIT_COUNT_UNSPECIFIED = 0;
DIGIT_COUNT_SIX = 1;
DIGIT_COUNT_EIGHT = 2;
}
enum OtpType {
OTP_TYPE_UNSPECIFIED = 0;
OTP_TYPE_HOTP = 1;
OTP_TYPE_TOTP = 2;
}
message OtpParameters {
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
DigitCount digits = 5;
OtpType type = 6;
int64 counter = 7;
}
repeated OtpParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}

View File

@@ -1,10 +1,10 @@
(ns totp.app (ns totp.app
#_{:clj-kondo/ignore [:refer-all]}
(:require [totp.core :refer :all] (:require [totp.core :refer :all]
[totp.data :refer :all] [totp.data :refer :all]
[cli-matic.core :refer [run-cmd]] [cli-matic.core :refer [run-cmd]]
[cli-matic.utils :as U]
[clojure.pprint :as pp] [clojure.pprint :as pp]
[clojure.pprint :as pprint]) [clojure.string :as str])
(:import [java.util TimerTask Timer]) (:import [java.util TimerTask Timer])
(:gen-class)) (:gen-class))
@@ -15,54 +15,73 @@
(let [step-millis (* 1000 period) (let [step-millis (* 1000 period)
now (System/currentTimeMillis) now (System/currentTimeMillis)
delay (int (- step-millis (rem now step-millis))) delay (int (- step-millis (rem now step-millis)))
fn-show (fn [s] (println (System/currentTimeMillis) "-> "(get-otp s algorithm digits period))) fn-show (fn [s] (println (format "[%d] %s" (System/currentTimeMillis) (get-otp s algorithm digits period))))
task (proxy [TimerTask] [] task (proxy [TimerTask] []
(run [] (fn-show secret)))] (run [] (fn-show secret)))]
(println "\n <Generating continuosly, press enter to stop>\n") (println "\n <Generating continuosly, press enter to stop>\n")
;; (println "Now:" now ", Delay:" delay ", Next execution: " (+ now delay)) ;; (println "Now:" now ", Delay:" delay ", Next execution: " (+ now delay))
(println "Refresing in" (int (/ delay 1000)) "seconds")
(fn-show secret) (fn-show secret)
(. (new Timer) (scheduleAtFixedRate task delay step-millis))) (. (new Timer) (scheduleAtFixedRate task delay step-millis)))
(read-line))) ;; Waits for a key press (read-line))) ;; Waits for a key press
(comment
(print get-otp "MJXW42LBORXQ====")
(print-confinuous "MJXW42LBORXQ====")
)
(defn cmd-generate (defn cmd-generate
[& {:keys [secret continuous algorithm digits period] :as opts}] [& {:keys [secret continuous algorithm digits period]}]
;;(pp/pprint opts) ;;(pp/pprint opts)
(if continuous (if continuous
(print-confinuous secret algorithm digits period) (print-confinuous secret algorithm digits period)
(println (get-otp secret algorithm digits period)))) (println (get-otp secret algorithm digits period))))
(defn cmd-get
[& {:keys [name continuous] :as opts}] (defn- print-app
;;(pp/pprint opts) [app]
(if (exists-config) (let [{:keys [name secret algorithm digits period]
(let [cfg (load-config) :or {algorithm "sha1"
app (get-app cfg name) digits 6
{:keys [secret algorithm digits period] period 30}} app]
:or {algorithm "sha1" (println (format "[%d] %12s -> %s" (System/currentTimeMillis) name (get-otp secret algorithm digits period)))))
digits 6
period 30}} app]
(if (some? secret) (defn- print-app-continuous
(if continuous ([period apps]
(print-confinuous secret algorithm digits period) (let [step-millis (* 1000 period)
(println (get-otp secret algorithm digits period))) now (System/currentTimeMillis)
(println "The app" name "is not configured"))) delay (int (- step-millis (rem now step-millis)))
(println "Config file not found."))) fn-show (fn [s]
(dorun (map print-app s))
(println "")) ;; Separate each
task (proxy [TimerTask] []
(run [] (fn-show apps)))]
(println "\n <Generating continuosly, press enter to stop>\n")
;; (println "Now:" now ", Delay:" delay ", Next execution: " (+ now delay))
(println "Refresing in" (int (/ delay 1000)) "seconds")
(fn-show apps)
(. (new Timer) (scheduleAtFixedRate task delay step-millis)))
(read-line))) ;; Waits for a key press
(defn cmd-get-multi
[& {:keys [continuous _arguments]}]
;(pp/pprint opts)
(with-config
(let [apps (filter some? #_{:clj-kondo/ignore [:unresolved-symbol]}
(map #(get-app cfg %) _arguments))]
;(println "found apps: " apps)
(if continuous
(print-app-continuous 30 apps)
(dorun (map #(print-app %) apps))))))
(defn cmd-config (defn cmd-config
[& {:keys [command] :as opts}] [& {:keys [command]}]
;;(pp/pprint opts) ;;(pp/pprint opts)
(case command (case command
"info" (println "Configuration file: " "info" (println "Configuration file:"
(if (exists-config) (if (exists-config)
cfg-file cfg-file
(str "<not found. default:" cfg-file ">"))) (str "not found. Expected location: " cfg-file)))
"init" (if (exists-config) "init" (if (exists-config)
(do (do
(println "Configuration already exists, this will delete it. Are you sure? [N/y]") (println "Configuration already exists, this will delete it. Are you sure? [N/y]")
@@ -74,32 +93,56 @@
"show" (do "show" (do
(println "Config file:\n") (println "Config file:\n")
;(println (slurp cfg-file)) ;(println (slurp cfg-file))
(pp/print-table (load-config)) (pp/print-table (load-config)))))
)))
(defn cmd-list (defn cmd-list
[& {:keys [sorted] :as opts}] [& {:keys [sorted mode]}]
;;(pp/pprint opts) (println "List mode:" mode)
(if (exists-config) (if (exists-config)
(let [apps-list (list-apps (load-config)) (case mode
s-list (if sorted (sort apps-list) apps-list)] "list" (let [apps-list (list-apps (load-config))
(dorun (map #(println %) s-list))) s-list (if sorted (sort apps-list) apps-list)]
(dorun (map #(println %) s-list)))
"table" (pp/print-table (load-config))
)
(println "Config file not found."))) (println "Config file not found.")))
(defn cmd-add (defn cmd-add
[& {:keys [name secret user issuer algorithm digits period update] :as opts}] [& {:keys [name secret user issuer algorithm digits period update] }]
;;(pp/pprint opts) ;;(pp/pprint opts)
(if (exists-config) (when (not (exists-config))
(let [cfg (load-config)] (println "Config not found. Creating new config")
(when (or update (nil? (get-app cfg name))) ;; get-app returns nil if app don't exists (create-cfg-file))
(store-config (add-app cfg name secret user issuer algorithm digits period)))) (let [cfg (load-config)]
(println "Config file not found."))) (if (or update (nil? (get-app cfg name))) ;; get-app returns nil if app don't exists
(do
(store-config (add-app cfg name secret user issuer algorithm digits period))
(println "App" name "added or updated."))
(println "App" name "already exists.\nUse --update if you want to overwrite"))))
#_{:clj-kondo/ignore [:unresolved-symbol]}
(defn cmd-import
[& {:keys [name url update] }]
(with-config
(if (or update (nil? (get-app cfg name)))
(cond
(str/starts-with? url "otpauth-migration")
(do
(store-config (import-from-url-export cfg name url))
(println "Import successful"))
(str/starts-with? url "otpauth")
(do
(store-config (import-from-url-create cfg name url))
(println "Import successful"))
:else (println "URL type not supported"))
(println "App" name "already exists.\nUse --update if you want to overwrite"))))
(defn cmd-delete (defn cmd-delete
[& {:keys [name force] :as opts}] [& {:keys [name force]}]
;;(pp/pprint opts) ;;(pp/pprint opts)
(if (exists-config) (if (exists-config)
(let [cfg (load-config)] (let [cfg (load-config)]
@@ -113,7 +156,8 @@
:version "1.1" :version "1.1"
:description ["Generate a TOTP"]} :description ["Generate a TOTP"]}
:commands [{:command "generate" :commands [;; Generate a TOTP with given params
{:command "generate"
:description "Generate one TOTP for a BASE32 secret, ignoring configured apps" :description "Generate one TOTP for a BASE32 secret, ignoring configured apps"
:examples ["Generate one TOTP for a provided BASE32 secret:" :examples ["Generate one TOTP for a provided BASE32 secret:"
" totp generate ABCD1234" " totp generate ABCD1234"
@@ -140,7 +184,7 @@
:type :int :type :int
:default 30}] :default 30}]
:runs cmd-generate} :runs cmd-generate}
;; Generate a TOTP for a configured app
{:command "get" :short "g" {:command "get" :short "g"
:description "Generate one TOTP for a configured app" :description "Generate one TOTP for a configured app"
:examples ["Generate one TOTP for a provided app:" :examples ["Generate one TOTP for a provided app:"
@@ -155,8 +199,8 @@
:as "Contiuous mode" :as "Contiuous mode"
:type :with-flag :type :with-flag
:default false}] :default false}]
:runs cmd-get} :runs cmd-get-multi}
;; Check and init your config file
{:command "config" :short "c" {:command "config" :short "c"
:description "Manage configuration" :description "Manage configuration"
:examples ["Show location for the configuration file:" :examples ["Show location for the configuration file:"
@@ -170,7 +214,7 @@
:type #{"info" "init" "show"} :type #{"info" "init" "show"}
:default :present}] :default :present}]
:runs cmd-config} :runs cmd-config}
;; List available apps
{:command "list" :short "l" {:command "list" :short "l"
:description "List existing apps" :description "List existing apps"
:examples ["List apps:" :examples ["List apps:"
@@ -178,11 +222,15 @@
"List apps sorted by name:" "List apps sorted by name:"
" totp list --sorted"] " totp list --sorted"]
:opts [{:option "sorted" :short "s" :opts [{:option "sorted" :short "s"
:as "If provided, the list willl be sorted by name" :as "If provided, the list will be sorted by name"
:type :with-flag :type :with-flag
:default false}] :default false}
{:option "mode" :short "m"
:as "How to show the list of configured apps"
:type #{"list" "table"}
:default "list"}]
:runs cmd-list} :runs cmd-list}
;; Adds a new app to the configuration
{:command "add" :short "a" {:command "add" :short "a"
:description "Add a new application with an unique name" :description "Add a new application with an unique name"
:examples ["Add a new application named 'app1' with a BASE32 secred, with defaults:" :examples ["Add a new application named 'app1' with a BASE32 secred, with defaults:"
@@ -220,7 +268,27 @@
:type :with-flag :type :with-flag
:default false}] :default false}]
:runs cmd-add} :runs cmd-add}
;; Import from URL
{:command "import" :short "i"
:description "Import a TOTP config from a URL"
:examples ["Import from a QR for creation"
" totp import app1 \"otpauth://totp/<label>?issuer=<issuer>&secret=<base32 secret>\""
"Import from a QR for exportation"
" totp import app2 \"otpauth-migration://offline?data=<exported data>\""]
:opts [{:option "name" :short 0
:as "Unique name of the application"
:type :string
:default :present}
{:option "url" :short 1
:as "Imported URL"
:type :string
:default :present}
{:option "update"
:as "Update an app with the same name if exists"
:type :with-flag
:default false}]
:runs cmd-import}
;; Deletes an existing app from configuration
{:command "delete" :short "d" {:command "delete" :short "d"
:description "Removes an existing application by it's unique name" :description "Removes an existing application by it's unique name"
:examples ["Remove the application name app1" :examples ["Remove the application name app1"

View File

@@ -2,7 +2,10 @@
(:require [clojure.edn :as e] (:require [clojure.edn :as e]
[clojure.string :as str] [clojure.string :as str]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.pprint :as pp])) [clojure.pprint :as pp]
[alphabase.base64 :as b64]
[alphabase.base32 :as b32])
(:import [protoc OtpauthMigration$MigrationPayload]))
(defn join-path (defn join-path
"Joins several subpaths using system's path separator (/ un *NIX and \\ in windows)" "Joins several subpaths using system's path separator (/ un *NIX and \\ in windows)"
@@ -17,7 +20,7 @@
(def cfg-header ";; clj-totp configuration file (def cfg-header ";; clj-totp configuration file
;; This file contents a list of maps with :name and :secret entries ;; This file contents a list of maps with :name and :secret entries
;; Secrets must be encoded in BASE32 ;; Secrets must be encoded in BASE32
") ")
@@ -70,10 +73,18 @@
(filter #(not= name (:name %)) cfg)) (filter #(not= name (:name %)) cfg))
(defn create-app
([name secret] (create-app name secret nil nil "sha1" 6 30))
([name secret user issuer] (create-app name secret user issuer "sha1" 6 30))
([name secret user issuer algorithm digits period]
{:name name :secret secret :user user :issuer issuer :algorithm algorithm :digits digits :period period}))
(defn add-app (defn add-app
([cfg app-map] (apply add-app (cons cfg (vals app-map))))
([cfg name secret] (add-app cfg name secret nil nil "sha1" 6 30)) ([cfg name secret] (add-app cfg name secret nil nil "sha1" 6 30))
([cfg name secret user issuer algorithm digits period] ([cfg name secret user issuer algorithm digits period]
(conj (delete-app cfg name) {:name name :secret secret :user user :issuer issuer :algorithm algorithm :digits digits :period period}))) (conj (delete-app cfg name) (create-app name secret user issuer algorithm digits period))))
(defn list-apps (defn list-apps
@@ -86,7 +97,10 @@
(defn get-app (defn get-app
[cfg name] [cfg name]
(first (filter #(= name (:name %)) cfg))) (let [app (first (filter #(= name (:name %)) cfg))]
(if app
app
(println "App" name "not found"))))
(comment (comment
@@ -106,4 +120,127 @@
(add-app "app1" "abc123abc123") (add-app "app1" "abc123abc123")
(add-app "app2" "abc123abc123") (add-app "app2" "abc123abc123")
(add-app "app1" "123456789012") (add-app "app1" "123456789012")
(store-config))) (store-config))
)
(defmacro with-config
"Loads config file and stores it in a cfg binding.
You can use the cfg var the inner code.
Next example will print config data:
(with-config (println cfg))
Be cafefull: dont use a binding called cfg in any module.
"
[form]
(let [cfg (symbol "cfg")] ;; This symbol will prevent error with qualified in the inner let
`(if (exists-config)
(let [~cfg (load-config)] ;; This is the problematic let binding. See: https://stackoverflow.com/a/15122414
(if (some? ~cfg)
(do ~form)))
(println "Config file not found"))))
(comment
#_{:clj-kondo/ignore [:unresolved-symbol]}
(with-config (first cfg))
(macroexpand-1 '(with-config (first cfg)))
)
(defn url-create->app
"Import data from url using the protocol otpauth://
Example: otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
https://github.com/google/google-authenticator/wiki/Key-Uri-Format"
[name url]
(when (str/starts-with? url "otpauth://")
(let [parts (str/split url #"\?")
meta-parts (str/split (first parts) #"/" -1)
data-parts (str/split (second parts) #"&" -1)
otp-type (nth meta-parts 2)]
(if (not= "totp" otp-type) ;; Only totp is supported
(println "Invalid protocol OTP type:" otp-type)
(let [user-data (str/split (nth meta-parts 3) #":" -1)
issuer (first user-data)
user (second user-data)
;data-map (apply hash-map (flatten (map #(str/split % #"=") data-parts)))
data-map (reduce (fn [acc v] ;; From array to map
(let [[k v] (str/split v #"=")]
(assoc acc (keyword k) v)))
{} data-parts)
secret (:secret data-map)
;;issuer2 (:issuer data-map)
]
(create-app name secret user issuer))))))
(comment
(url-create->app "app1" "otpauth://totp/Reddit:errepunto?issuer=Reddit&secret=3RR2")
(url-create->app "app2" "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")
(add-app [{:a 1 :b 2}] (url-create->app "app2" "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example"))
)
(defn import-from-url-create
[cfg name url]
(add-app cfg (url-create->app name url)))
(comment
#_{:clj-kondo/ignore [:unresolved-symbol]}
(with-config (import-from-url-create cfg "app1" "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example"))
)
(defn url-export->app
[name url]
(when (some? url)
(let [b64-data (second (str/split url #"=" -1))
data-b (b64/decode b64-data)
parsed (OtpauthMigration$MigrationPayload/parseFrom data-b)
payload (bean (.getOtpParameters parsed 0))
;{:keys [name secret name issuer digitsValue algorithmValue typeValue]} payload
secret-b (:secret payload)
secret (b32/encode (.toByteArray secret-b))
user (:name payload)
issuer (:issuer payload)
algorithm (case (:algorithmValuei payload) 2 "sha256" 3 "sha512" "sha1")
digits (case (:digitsValue payload) 2 8 6)
valid-type (= 2 (:typeValue payload))
]
(println "name:" name "user:" user "issuer:" issuer "digitsValue:" digits "algorithm:" algorithm "valid type?" valid-type)
(if valid-type
(create-app name secret user issuer algorithm digits 30)
(println "Invalid OTP type" (:typeValue payload)))
)))
(comment
(url-export->app "test"
"otpauth-migration://offline?data=CkkKEJ0M4MyHfITKCwCfqPIttjESFHJ1YmVuY2pAMThCMTY5RDVGRjAwGgRTTldMIAEoATACQhMzYjkxMDQxNzI3NzgzNDIzNDYyEAIYASAA"
)
)
(defn import-from-url-export
"Import data from url using the protocol otpauth-migration://
https://alexbakker.me/post/parsing-google-auth-export-qr-code.html"
[cfg name url]
(add-app cfg (url-export->app name url)))
(comment
#_{:clj-kondo/ignore [:unresolved-symbol]}
(with-config (import-from-url-export
cfg
"app2"
"otpauth-migration://offline?data=CkkKEJ0M4MyHfITKCwCfqPIttjESFHJ1YmVuY2pAMThCMTY5RDVGRjAwGgRTTldMIAEoATACQhMzYjkxMDQxNzI3NzgzNDIzNDYyEAIYASAA")
)
)

View File

@@ -1,65 +0,0 @@
(ns totp.db.datomic
(:require [datomic.client.api :as d]))
(def db-path (str cfg-path java.io.File/separator "data"))
(def cfg {:server-type :datomic-local
:system "local-data"
:storage-dir db-path})
(def client (d/client cfg))
;; Schema for our database
(def totp-schema [{:db/ident :app/name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Unique name of the application. Between 2 and 32 chars"
:db.attr/preds (fn [s] (<= 3 (count s) 15))}
{:db/ident :app/desc
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "(optional) Description of the application"}
{:db/ident :app/secret
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Secret BASE32"}
{:db/ident :app/period
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Time slice in seconds (30 by default)"}
{:db/ident :app/config
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "(Optional) Extra config"}
{:db/ident :user/login
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Identifier for the user"}
{:db/ident :user/passwd
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Password"}
{:db/ident :user/active
:db/valueType :db.type/boolean
:db/cardinality :db.cardinality/one
:db/doc "Is the user active?"}
{:db/ident :user/desc
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "(Optional) Description of the user"}
{:db/ident :user/config
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "(Optional) Extra config"}
{:db/ident :user/apps
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Applications for this user"}])
(defn init-db
[client]
(d/create-database client {:db-name "totp"}))

View File

@@ -1,23 +0,0 @@
(ns totp.db.sqlite
(:require [next.jdbc :as jdbc]))
;; DB configuration
(def db {:dbname "totp-data.sqlite"
:dbtype "sqlite"})
;; DB parsed config
(def data-source (jdbc/get-datasource db))
(defn init-db
"Create an empty DB"
[]
(jdbc/execute! data-source ["
create table apps (
id int auto_increment primary key,
name varchar(32),
desc varchar(255)
)"]))
(comment
(init-db)
)

Binary file not shown.

View File

@@ -1,4 +1,5 @@
(ns totp.core-test (ns totp.core-test
#_{:clj-kondo/ignore [:refer-all]}
(:require [clojure.test :refer :all] (:require [clojure.test :refer :all]
[totp.core :refer :all] [totp.core :refer :all]
[alphabase.base64 :as b64]) [alphabase.base64 :as b64])

Binary file not shown.