Compare commits

36 Commits

Author SHA1 Message Date
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
9eeb3571d5 bye bye, intellij config 2025-08-28 21:06:41 +02:00
58a17dc5bd Update README.md 2025-08-28 13:59:54 +02:00
4f29260f9b Native compilation 2025-08-28 13:47:51 +02:00
df82cf3f44 Small typo 2025-08-28 12:27:14 +02:00
f32986f2db Update README.md 2025-08-28 10:17:41 +02:00
1205a79f19 Continuous mode is working 2025-08-28 10:13:10 +02:00
38126f987a continuous generation 2025-08-27 23:57:34 +02:00
fa0de1f624 using a file as config backend (for now)ç 2025-08-27 23:53:18 +02:00
efec0ca07c diagram 2025-08-26 16:14:22 +02:00
4361782861 database diagram 2025-08-26 16:01:49 +02:00
8d0fb81a9d Testing datomic 2025-08-26 15:00:34 +02:00
4a1abd7fd7 Build uberjar using build.clj 2025-08-26 12:19:28 +02:00
b7d3c6ce86 Ignore 'target' directory 2025-08-26 11:31:00 +02:00
0a4e531b2f Adding an alias to run easily 2025-08-25 09:32:52 +02:00
a22f4a5670 Implementing SQLite database for config 2025-08-24 17:52:30 +02:00
f408834726 Using subcommands for CLI 2025-08-24 12:26:19 +02:00
09d8cd0e10 Check for byte array wasn't consistent enough 2025-08-24 11:41:16 +02:00
d448ebe001 Simple CLI usage 2025-08-24 02:27:02 +02:00
e165dc107f future cli program 2025-08-24 00:50:05 +02:00
e18953b287 Corrects left padding with zeroes 2025-08-24 00:33:08 +02:00
290c52a71f Version with 1 parameter that gets current time 2025-08-24 00:25:10 +02:00
e32e054c8c Functional OTP generation 2025-08-24 00:21:02 +02:00
5eec2f2e25 Implement hmac-sha1 as a multimethod 2025-08-23 20:06:16 +02:00
65aa97d6e9 Renamed 'topt' to 'totp' (typo) 2025-08-19 09:48:35 +02:00
ec6d948645 Calculate hmac and converts long to byte array 2025-08-19 01:11:22 +02:00
a7e79e0727 First function and test 2025-08-19 00:01:00 +02:00
67f60f2b5d ignoring nrepl temporary file 2025-08-18 11:58:45 +02:00
5dc70b70e8 exclude .lsp 2025-08-18 11:41:09 +02:00
4390afbaca Reimporting IntelliJ project 2025-08-18 11:36:22 +02:00
9eac0fc3a2 Copied from intellij project 2025-08-18 11:03:17 +02:00
da9f2d28d1 clean repository 2025-08-18 10:49:30 +02:00
ca88c0b86d Update README.md 2025-08-18 08:50:19 +02:00
d7c7bddb67 IntelliJ configuration files 2025-08-17 01:32:40 +02:00
15 changed files with 513 additions and 60 deletions

33
.gitignore vendored
View File

@@ -1,32 +1,7 @@
# ---> Clojure
pom.xml
pom.xml.asc
*.jar
*.class
/lib/
/classes/
/.clj-kondo/
/.cpcache/
/.lsp/
/target/
/checkouts/
.lein-deps-sum
.lein-repl-history
.lein-plugins/
.lein-failures
.nrepl-port
.cpcache/
# ---> Leiningen
pom.xml
pom.xml.asc
*.jar
*.class
/lib/
/classes/
/target/
/checkouts/
.lein-deps-sum
.lein-repl-history
.lein-plugins/
.lein-failures
.nrepl-port
.cpcache/
.calva

18
LICENSE
View File

@@ -1,18 +0,0 @@
MIT License
Copyright (c) 2025 ruben
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -13,22 +13,43 @@ You can red more about the algorith here:
- TOTP RFC: https://web.archive.org/web/20110711124823/http://tools.ietf.org/html/rfc6238
- HOTP RFC: https://www.ietf.org/rfc/rfc4226.txt
## The inside
This project is done 100% in clojure. It uses `deps.edn` for configuring the project.
## Implementation timeline
## Features
### v1.0
[ ] Functional TOTP generation
[ ] Get TOTP from command line
[ ] Store configuration in a simple BD (sqlite, for example)
- Functional TOTP generation
- Get TOTP from command line
- Continuous update every 30 seconds
### v1.1
[ ] REST API
[ ] User management
## Usage
You can use the `clojure` command to run the program:
```
clojure -M:run <params>
```
### v1.2
[ ] Simple web connected to REST API
If you prefer using the distributed jar:
```
java -jar clj-topt-1.0.35-standalone.jar <params>
```
You can use the binary (compiled with GraalVM) in linux environments:
```
totp <params>
```
All three methods are equivalent.
### Generate a single TOTP
You can simple run:
```
totp generate <secret in BASE32>
```
If want to update coninously the generated TOTP, you cand add the `-s` param:
```
totp generate <secret in BASE32> -s
```

25
build.clj Normal file
View File

@@ -0,0 +1,25 @@
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'es.rcorral/clj-topt)
(def version (format "1.0.%s" (b/git-count-revs nil)))
(def class-dir "target/classes")
(def uber-file (format "target/%s-%s-standalone.jar" (name lib) version))
;; delay to defer side effects (artifact downloads)
(def basis (delay (b/create-basis {:project "deps.edn"})))
(defn clean [_]
(b/delete {:path "target"}))
(defn uber [_]
(clean nil)
(b/copy-dir {:src-dirs ["src" "resources"]
:target-dir class-dir})
(b/compile-clj {:basis @basis
:ns-compile '[totp.app]
:class-dir class-dir})
(b/uber {:class-dir class-dir
:uber-file uber-file
:basis @basis
:main 'totp.app}))

1
collect-deps.sh Executable file
View File

@@ -0,0 +1 @@
~/.sdkman/candidates/java/21.0.2-graalce/bin/java -agentlib:native-image-agent=config-output-dir=META-INF/native-image -jar target/clj-topt-1.0.32-standalone.jar g TUGOBTEHPSCMUCYAT6UPELNWGE -c

19
deps.edn Executable file
View File

@@ -0,0 +1,19 @@
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.1"}
io.github.clojure/tools.build {:mvn/version "0.10.10"}
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
;; 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/
:aliases {;; Execute the app
:run {:main-opts ["-m" "totp.app"]}
;; 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
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}
:main-opts ["-m" "kaocha.runner"]}
;; Run with clj -T:build function-in-build
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.10"}}
:ns-default build}}}

34
doc/db.plantuml Normal file
View File

@@ -0,0 +1,34 @@
@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

BIN
doc/db.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

14
native.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env sh
NATIVE=~/.sdkman/candidates/java/21.0.2-graalce/bin/native-image
UBERJAR=clj-topt-1.0.32-standalone.jar
BIN_FILE=totp
echo "Creating uberjar"
clojure -T:build uber
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
echo "Executable created on target/$BIN_FILE"

52
reflect_config.json Normal file
View File

@@ -0,0 +1,52 @@
[
{
"name": "com.sun.crypto.provider.HmacSHA1",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"name": "java.lang.reflect.Method",
"methods": [
{
"name": "canAccess",
"parameterTypes": [
"java.lang.Object"
]
}
]
},
{
"name": "java.util.Arrays",
"allDeclaredClasses": true,
"allPublicClasses": true,
"queryAllPublicMethods": true,
"methods": [
{
"name": "copyOfRange",
"parameterTypes": [
"byte[]",
"int",
"int"
]
}
]
},
{
"name": "java.util.Timer",
"queryAllPublicMethods": true,
"methods": [
{
"name": "scheduleAtFixedRate",
"parameterTypes": [
"java.util.TimerTask",
"long",
"long"
]
}
]
}
]

64
src/totp/app.clj Normal file
View File

@@ -0,0 +1,64 @@
(ns totp.app
(:require [totp.core :refer :all]
[cli-matic.core :refer [run-cmd]]
[cli-matic.utils :as U]
[clojure.pprint :as pp])
(:import [java.util TimerTask Timer])
(:gen-class))
(defn- print-confinuous
([secret] (print-confinuous secret 30))
([secret step]
(let [step-millis (* 1000 step)
now (System/currentTimeMillis)
delay (int (- step-millis (rem now step-millis)))
fn-show (fn [s] (println (System/currentTimeMillis) "-> "(get-otp s)))
task (proxy [TimerTask] []
(run [] (fn-show secret)))]
(println "\n <Generating continuosly, press enter to stop>\n")
;; (println "Now:" now ", Delay:" delay ", Next execution: " (+ now delay))
(fn-show secret)
(. (new Timer) (scheduleAtFixedRate task delay step-millis)))
(read-line))) ;; Waits for a key press
(comment
(print get-otp "MJXW42LBORXQ====")
(print-confinuous "MJXW42LBORXQ====")
)
(defn cmd-generate
[& {:keys [secret continuous] :as otps}]
;(pp/pprint otps)
(if continuous
(print-confinuous secret)
(println (get-otp secret))
))
(def cli-options
{:app {:command "totp"
:version "1.0"
:description ["Generate a TOTP"]}
:commands [{:command "generate" :short "g"
:description "Generate one TOTP with a given secret in BASE32"
:examples ["Generate one TOTP and exit:"
" totp generate \"MJXW42LBORXQ====\""
"Generate one TOTP, update each 30 seconds:"
" totp g -c \"MJXW42LBORXQ====\""]
:opts [{:option "secret"
:short 0
:as "Secret codified in BASE32"
:type :string
:default :present}
{:option "continuous"
:short "c"
:type :with-flag
:as "Contiuous mode"
:default false}]
:runs cmd-generate}]})
(defn -main [& args]
(run-cmd args cli-options))

88
src/totp/core.clj Normal file
View File

@@ -0,0 +1,88 @@
(ns totp.core
(:require [alphabase.base32 :as b32])
(:import (javax.crypto Mac)
(javax.crypto.spec SecretKeySpec)
(java.util Base64 Arrays)
(java.nio ByteBuffer)))
(def ^:private byte-array-type (type (.getBytes "")))
(defn timestamp->steps
"Converts from UNIX timestamp in milliseconds to a number os steps of 's' seconds of duration"
[time, step-size]
(if (or (nil? time) (nil? step-size) (zero? step-size))
0
(int (quot time (* 1000 step-size)))))
(defn bytes-array?
"Return true if x is a byte[]"
[x]
(= byte-array-type (type x)))
(defmulti hmac-sha1
"Generates an HMAC-SHA1. The key and the message can be (both) string or array of bytes, nil otherwise"
(fn [key message]
(cond
(and (string? key) (string? message)) :string
(and (bytes-array? key) (bytes-array? message)) :byte
:else :nil)))
;; By default
(defmethod hmac-sha1 :nil [_ _]
nil)
;; When key and message are strings
(defmethod hmac-sha1 :string [key message]
(if (or (empty? key) (empty? message))
""
(let [mac (doto (Mac/getInstance "HmacSHA1") (.init (SecretKeySpec. (.getBytes key) "HmacSHA1")))
hmac-bytes (.doFinal mac (.getBytes message))]
;; Return the Base64 encoded HMAC
(.encodeToString (Base64/getEncoder) hmac-bytes))))
;; When key and message are arrays of bytes
(defmethod hmac-sha1 :byte [key message]
(if (nil? message)
(bytes (byte-array 0))
(let [mac (doto (Mac/getInstance "HmacSHA1") (.init (SecretKeySpec. key "HmacSHA1")))
hmac-bytes (.doFinal mac message)]
;; Return the Base64 encoded HMAC
(Base64/getEncoder) hmac-bytes)))
(defn long->bytes
"Converts a long to an array of 8 bytes"
[l]
;;Java equivalent: ByteBuffer.allocate(Long.SIZE / Byte.SIZE).putLong(someLong).array();
(when (integer? l)
(-> (ByteBuffer/allocate (/ Long/SIZE Byte/SIZE))
(.putLong l)
(.array))))
(defn bytes->int
"Converts an array of 4 bytes to an integer"
[bytes]
;;Java equivalent: ByteBuffer.wrap(data).getInt()
(when (some? bytes)
(.getInt (ByteBuffer/wrap bytes))))
(defn get-otp
"Generate an OTP with the given secret (in base32) for the specified timestep"
([secret step]
(when (and secret step)
(let [k (b32/decode secret)
c (long->bytes step)
hs (hmac-sha1 k c)
offset (bit-and (get hs (dec (count hs))) 0x0f) ;; int offset = hs[hs.length-1] & 0xf;
chunk (Arrays/copyOfRange hs offset (+ offset 4)) ;(take 4 (drop offset hs)) ;; byte[] chunk = Arrays.copyOfRange(hs, offset, offset+4)
]
(format "%06d" (-> chunk
(bytes->int)
(bit-and 0x7fffffff)
(rem 1000000))))))
([secret]
(get-otp secret (timestamp->steps (System/currentTimeMillis) 30))))

106
src/totp/data.clj Normal file
View File

@@ -0,0 +1,106 @@
(ns totp.data
(:require [clojure.edn :as e]
[clojure.string :as str]
[clojure.java.io :as io]
[clojure.pprint :as pp]))
(defn join-path
"Joins several subpaths using system's path separator (/ un *NIX and \\ in windows)"
[& col]
(str/join java.io.File/separator col))
(def cfg-path (join-path (System/getProperty "user.home") ".config" "totp"))
(def cfg-file (join-path cfg-path "apps.edn"))
(def cfg-header ";; clj-totp configuration file
;; This file contents a list of maps with :name and :secret entries
;; Secrets must be encoded in BASE32
")
(defn exists-config
"Checks if the config file exists"
[]
(.exists (io/file cfg-file)))
(defn create-cfg-file
"Creates the config file"
[]
(println "Creating " cfg-file)
(io/delete-file cfg-file true)
(io/make-parents cfg-file)
(spit cfg-file cfg-header)
true)
(defn create-cfg?
"Create configuration file if not exists. Overridable with allways = true"
([] (create-cfg? false))
([allways]
(if (or allways (not (exists-config)))
(create-cfg-file)
false)))
(comment
(exists-config)
(create-cfg?)
)
(defn load-config
"Loads configuration from file"
[]
(e/read-string (slurp cfg-file)))
(defn store-config
"Store configuration to file"
[cfg]
(when cfg
(spit cfg-file (str cfg-header (with-out-str
(binding [pp/*print-right-margin* 50]
(pp/pprint cfg)))))))
(defn delete-app
[cfg name]
(filter #(not= name (:name %)) cfg))
(defn add-app
[cfg name secret]
(conj (delete-app cfg name) {:name name :secret secret}))
(defn list-apps
[cfg]
(map :name cfg))
(defn get-app
[cfg name]
(first (filter #(= name (:name %)) cfg)))
(comment
(exists-config)
(create-cfg?)
(load-config)
(with-out-str
(binding [pp/*print-right-margin* 50]
(pp/pprint [{:name "abc" :secret "def"} {:name "my-app" :secret "abc123"}])))
(store-config [{:name "abc" :secret "def"} {:name "my-app" :secret "abc123"}])
(-> nil
(add-app "app1" "abc123abc123")
(add-app "app2" "abc123abc123")
(add-app "app1" "123456789012")
(store-config))
)

71
test/totp/core_test.clj Normal file
View File

@@ -0,0 +1,71 @@
(ns totp.core-test
(:require [clojure.test :refer :all]
[totp.core :refer :all]
[alphabase.base64 :as b64])
(:import (java.util Arrays)))
(deftest timestamp->steps-test
(testing "Border cases"
(is (zero? (timestamp->steps nil nil)))
(is (zero? (timestamp->steps 0 nil)))
(is (zero? (timestamp->steps nil 0)))
(is (zero? (timestamp->steps 0 0))))
(testing "Common usage"
(is (= 10 (timestamp->steps 100000 10)))
(is (= 10 (timestamp->steps 100001 10)))
(is (= 10 (timestamp->steps 100999 10)))
(is (= 11 (timestamp->steps 110000 10)))
(is (= 2 (timestamp->steps 63000 30)))))
(deftest bytes-array?-test
(testing "All cases"
(is (= false (bytes-array? nil)))
(is (= false (bytes-array? "")))
(is (= false (bytes-array? [0x0])))
(is (= true (bytes-array? (.getBytes ""))))
(is (= true (bytes-array? (bytes (byte-array [0 0 0 0 0 0 0 0])))))))
(deftest hmac-sha1-test
(testing "border cases"
(is (= nil (hmac-sha1 nil nil)))
(is (= nil (hmac-sha1 "" nil)))
(is (= nil (hmac-sha1 nil "")))
(is (= nil (hmac-sha1 (.getBytes "") nil)))
(is (= nil (hmac-sha1 nil (.getBytes ""))))
(is (= "" (hmac-sha1 "" ""))))
(testing "String params"
(is (= "63h3K4sN+c3NDEl3EGeA23jq/EY=" (hmac-sha1 "12345" "this is a message")))
(is (= "MA+ieo7t7MeQfyZR/X52dB1aXDI=" (hmac-sha1 "12345" "this is a longer message
with some lines"))))
(testing "byte[] params"
(is (Arrays/equals (b64/decode "63h3K4sN+c3NDEl3EGeA23jq/EY=") (hmac-sha1 (.getBytes "12345") (.getBytes "this is a message"))))
(is (Arrays/equals (b64/decode "MA+ieo7t7MeQfyZR/X52dB1aXDI=") (hmac-sha1 (.getBytes "12345") (.getBytes "this is a longer message
with some lines"))))))
(deftest long->bytes-test
(testing "Border cases"
(is (nil? (long->bytes nil))))
(testing "Common usage"
(is (Arrays/equals (bytes (byte-array [0 0 0 0 0 0 0 0])) (long->bytes 0)))
(is (Arrays/equals (bytes (byte-array [0 0 0 0 0 0 0x01 0x01])) (long->bytes 257)))))
(deftest bytes->int-test
(testing "Border cases"
(is (nil? (bytes->int nil))))
(testing "Common usage"
(is (= 0 (bytes->int (bytes (byte-array [0 0 0 0])))))
(is (= 1 (bytes->int (bytes (byte-array [0 0 0 0x01])))))
(is (= 257 (bytes->int (bytes (byte-array [0 0 0x01 0x01])))))))
(deftest get-otp-test
(testing "Border cases"
(is (nil? (get-otp nil nil)))
(is (nil? (get-otp "" nil)))
(is (nil? (get-otp nil "")))
(is (nil? (get-otp nil 1000))))
(testing "Common usage"
(is (= "837552" (get-otp "MJXW42LBORXQ====" 10000)))
(is (= 6 (count (get-otp "MJXW42LBORXQ===="))))))

1
tests.edn Normal file
View File

@@ -0,0 +1 @@
#kaocha/v1 {}