diff --git a/server/resources/migrations/100_totp_magic_code.down.sql b/server/resources/migrations/100_totp_magic_code.down.sql new file mode 100644 index 0000000000..6b8c8fd971 --- /dev/null +++ b/server/resources/migrations/100_totp_magic_code.down.sql @@ -0,0 +1,2 @@ +alter table apps drop column totp_secret_key_enc; +alter table apps drop column totp_expiry_minutes; diff --git a/server/resources/migrations/100_totp_magic_code.up.sql b/server/resources/migrations/100_totp_magic_code.up.sql new file mode 100644 index 0000000000..bd14053f99 --- /dev/null +++ b/server/resources/migrations/100_totp_magic_code.up.sql @@ -0,0 +1,2 @@ +alter table apps add column totp_secret_key_enc bytea; +alter table apps add column totp_expiry_minutes integer; diff --git a/server/src/instant/admin/routes.clj b/server/src/instant/admin/routes.clj index b323da88ab..a2a6462405 100644 --- a/server/src/instant/admin/routes.clj +++ b/server/src/instant/admin/routes.clj @@ -500,7 +500,7 @@ (defn magic-code-post [req] (let [{:keys [app-id]} (req->app-id-authed! req :data/write) email (ex/get-param! req [:body :email] email/coerce) - {:keys [code]} (app-user-magic-code-model/create! {:app-id app-id :email email})] + code (app-user-magic-code-model/create! {:app-id app-id :email email})] (response/ok {:code code}))) (defn send-magic-code-post [req] diff --git a/server/src/instant/flags.clj b/server/src/instant/flags.clj index 837ca25d45..bc7f7fa309 100644 --- a/server/src/instant/flags.clj +++ b/server/src/instant/flags.clj @@ -245,15 +245,41 @@ (defn test-rule-wheres? [] (:rule-where-testing (query-result))) +(def ^:dynamic *toggle-overrides* nil) + (defn toggled? ([key] - (get-in (query-result) [:toggles key])) + (if *toggle-overrides* + (-> (query-result) + (get :toggles) + (merge *toggle-overrides*) + (get key)) + (get-in (query-result) [:toggles key]))) ([key not-found] - (get-in (query-result) [:toggles key] not-found))) + (if *toggle-overrides* + (-> (query-result) + (get :toggles) + (merge *toggle-overrides*) + (get key not-found)) + (get-in (query-result) [:toggles key] not-found)))) + +(def ^:dynamic *flag-overrides* nil) (defn flag - ([key] (get-in (query-result) [:flags key])) - ([key not-found] (get-in (query-result) [:flags key] not-found))) + ([key] + (if *flag-overrides* + (-> (query-result) + (get :flags) + (merge *flag-overrides*) + (get key)) + (get-in (query-result) [:flags key]))) + ([key not-found] + (if *flag-overrides* + (-> (query-result) + (get :flags) + (merge *flag-overrides*) + (get key not-found)) + (get-in (query-result) [:flags key] not-found)))) (defn handle-receive-timeout [app-id] (get-in (query-result) [:handle-receive-timeout app-id])) @@ -334,3 +360,33 @@ Set to 0 to disable." [] (flag :magic-code-rate-limit-per-hour 20)) + +;; TOTP flags +;; Process for rolling out TOTP +;; 1. Deploy the code and wait for 24 hours +;; 2. Flip the validate-with-totp? toggle to true +;; 3. Wait for a while to make sure everything looks good +;; 4. Flip the dual-write-totp toggle to false +;; 5. Remove the code that writes to the magic codes table +;; 6. Later--remove the magic codes completely + +(defn generate-with-totp? + "Returns true if we should generate codes with totp. Need to run this for 24 hours before + turning on validate-with-totp" + [] + (toggled? :generate-with-totp true)) + +(defn validate-with-totp? + "Returns true if we should validate codes with totp. Need to generate-with-totp for 24 hours before + turning on validate-with-totp" + [] + (toggled? :validate-with-totp false)) + +(defn dual-write-totp? + "Returns true if we should write our totp codes to the magic codes table." + [] + (toggled? :dual-write-totp true)) + +(defn totp-default-expiry-seconds + [] + (flag :totp-default-expiry-seconds 86400)) diff --git a/server/src/instant/model/app.clj b/server/src/instant/model/app.clj index 5458e4dc90..95fa97eca7 100644 --- a/server/src/instant/model/app.clj +++ b/server/src/instant/model/app.clj @@ -346,6 +346,35 @@ WHERE a.id = ?::uuid" new-creator-id id]))))) +(defn ensure-totp-secret! + ([params] + (ensure-totp-secret! (aurora/conn-pool :write) params)) + ([conn {:keys [id]}] + (let [secret-key (crypt-util/random-bytes 32) + enc-secret-key (crypt-util/aead-encrypt {:plaintext secret-key + :associated-data (uuid-util/->bytes id)})] + (with-cache-invalidation id + (sql/execute-one! ::ensure-totp-secret! + conn + (hsql/format {:update :apps + :set {:totp-secret-key-enc enc-secret-key} + :where [:and + [:= :id id] + [:= :totp-secret-key-enc nil]]})))))) + +(defn get-totp-secret-key + ([params] + (get-totp-secret-key (aurora/conn-pool :read) + (aurora/conn-pool :write) + params)) + ([read-conn write-conn {:keys [id]}] + (let [secret-key-enc (or (:totp_secret_key_enc (get-by-id! read-conn {:id id})) + (:totp_secret_key_enc (ensure-totp-secret! write-conn {:id id})) + ;; 2nd read in case two txes tried to initialize the key at the same time + (:totp_secret_key_enc (get-by-id! read-conn {:id id})))] + (crypt-util/aead-decrypt {:ciphertext secret-key-enc + :associated-data (uuid-util/->bytes id)})))) + (defn clear-by-id! "Deletes attrs, rules, and triples for the specified app_id" ([params] (clear-by-id! (aurora/conn-pool :write) params)) diff --git a/server/src/instant/model/app_user_magic_code.clj b/server/src/instant/model/app_user_magic_code.clj index e1a1e8299b..c118824a18 100644 --- a/server/src/instant/model/app_user_magic_code.clj +++ b/server/src/instant/model/app_user_magic_code.clj @@ -1,10 +1,12 @@ (ns instant.model.app-user-magic-code (:require + [instant.flags :as flags] [instant.jdbc.aurora :as aurora] [instant.model.app :as app-model] [instant.model.app-user :as app-user-model] [instant.model.instant-user :as instant-user-model] [instant.system-catalog-ops :refer [update-op]] + [instant.totp :as totp] [instant.util.crypt :as crypt-util] [instant.util.exception :as ex] [instant.util.string :refer [rand-num-str]]) @@ -24,47 +26,81 @@ (let [created-at ^Date (:created_at magic-code)] (< (+ (.getTime created-at) ttl-ms) (System/currentTimeMillis))))) +(defn totp-secret-key [app-id ^String email] + (let [app-secret-key (app-model/get-totp-secret-key {:id app-id}) + derived-key (crypt-util/hmac-256 app-secret-key (.getBytes email))] + derived-key)) + +(defn generate-totp [app-id ^String email] + (let [secret-key (totp-secret-key app-id email)] + (totp/generate-totp secret-key))) + (defn create! ([params] (create! (aurora/conn-pool :write) params)) - ([conn {:keys [app-id email id code]}] + ([conn {:keys [app-id email id]}] (let [id (or id (random-uuid)) - code (or code (rand-code))] - (update-op - conn - {:app-id app-id - :etype etype} - (fn [{:keys [transact! get-entity]}] - (transact! [{:id id - :etype etype - :codeHash (-> code - crypt-util/str->sha256 - crypt-util/bytes->hex-string) - :email email}]) - (assoc (get-entity id) - :code code)))))) + code (if (flags/generate-with-totp?) + (generate-totp app-id email) + (rand-code))] + + (when (or (not (flags/validate-with-totp?)) + (not (flags/generate-with-totp?)) + (flags/dual-write-totp?)) + (update-op + conn + {:app-id app-id + :etype etype} + (fn [{:keys [transact!]}] + (transact! [{:id id + :etype etype + :codeHash (-> code + crypt-util/str->sha256 + crypt-util/bytes->hex-string) + :email email}])))) + code))) + + +(defn validate-totp! [app-id ^String email ^String code] + (let [secret-key (totp-secret-key app-id email) + expiry-periods (or (some-> (app-model/get-by-id! {:id app-id}) + :totp_expiry_minutes + (max 5) ;; Minimum of 5 minutes + (min 1440) ;; Maximum of 1 day + (* 60) + (/ totp/default-time-step) + (Math/ceil)) + (/ (flags/totp-default-expiry-seconds) totp/default-time-step))] + ;; Have to add 1 extra period in case the code was generated near the + ;; end of a period + (when-not (totp/valid-totp? secret-key (inc expiry-periods) code) + (ex/throw-expiration-err! :app-user-magic-code {:args [{:code code + :email email}]})))) (defn consume! ([params] (consume! (aurora/conn-pool :write) params)) ([conn {:keys [email code app-id] :as params}] - (update-op - conn - {:app-id app-id - :etype etype} - (fn [{:keys [get-entity-where delete-entity!]}] - (let [code-hash (-> code - crypt-util/str->sha256 - crypt-util/bytes->hex-string) - {code-id :id} (get-entity-where - {:codeHash code-hash - :email email})] - (ex/assert-record! code-id :app-user-magic-code {:args [params]}) - (let [code (delete-entity! code-id)] - (ex/assert-record! code :app-user-magic-code {:args [params]}) - (when (expired? code) - (ex/throw-expiration-err! :app-user-magic-code {:args [params]})) - code)))))) + (when (or (not (flags/validate-with-totp?)) + (flags/dual-write-totp?)) + (update-op + conn + {:app-id app-id + :etype etype} + (fn [{:keys [get-entity-where delete-entity!]}] + (let [code-hash (-> code + crypt-util/str->sha256 + crypt-util/bytes->hex-string) + {code-id :id} (get-entity-where + {:codeHash code-hash + :email email})] + (ex/assert-record! code-id :app-user-magic-code {:args [params]}) + (let [code (delete-entity! code-id)] + (ex/assert-record! code :app-user-magic-code {:args [params]}) + (when (expired? code) + (ex/throw-expiration-err! :app-user-magic-code {:args [params]}))))))) + (when (flags/validate-with-totp?) + (validate-totp! app-id email code)))) (comment (def instant-user (instant-user-model/get-by-email diff --git a/server/src/instant/runtime/magic_code_auth.clj b/server/src/instant/runtime/magic_code_auth.clj index ff2a940725..291a898fd0 100644 --- a/server/src/instant/runtime/magic_code_auth.clj +++ b/server/src/instant/runtime/magic_code_auth.clj @@ -83,7 +83,7 @@ (defn send! [{:keys [app-id email] :as req}] (check-rate-limit! req) (let [app (app-model/get-by-id! {:id app-id}) - {:keys [code]} (app-user-magic-code-model/create! (select-keys req [:app-id :email])) + code (app-user-magic-code-model/create! (select-keys req [:app-id :email])) template (app-email-template-model/get-by-app-id-and-email-type {:app-id app-id :email-type "magic-code"}) @@ -178,10 +178,10 @@ (def app (first (app-model/get-all-for-user {:user-id (:id instant-user)}))) (def runtime-user (app-user-model/get-by-email {:app-id (:id app) :email "stopa@instantdb.com"})) - (def m - (:magic-code (app-user-magic-code-model/create! {:app-id (:id app) :email "stopa@instantdb.com"}))) + (def code + (app-user-magic-code-model/create! {:app-id (:id app) :email "stopa@instantdb.com"})) (verify! {:app-id (:id app) :email "stopa@instantdb.com" :code "0"}) - (verify! {:app-id (:id app) :email "stopa@instantdb.com" :code (:code m)})) + (verify! {:app-id (:id app) :email "stopa@instantdb.com" :code code})) diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index 8a80cc48b0..ce8238aafb 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -117,12 +117,12 @@ :email "stopa@instantdb.com"})) (def m (app-user-magic-code-model/create! - {:id (random-uuid) :user-id (:id runtime-user) :code (app-user-magic-code-model/rand-code) + {:id (random-uuid) :user-id (:id runtime-user) :app-id (:id app)})) - (verify-magic-code-post {:body {:email "stopainstantdb.com" :code (:code m)}}) - (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m)}}) + (verify-magic-code-post {:body {:email "stopainstantdb.com" :code m}}) + (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m}}) (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code "0" :app-id (:id app)}}) - (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m) :app-id (:id app)}})) + (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m :app-id (:id app)}})) ;; ----- ;; Guest sign in diff --git a/server/src/instant/totp.clj b/server/src/instant/totp.clj new file mode 100644 index 0000000000..05c2dcd848 --- /dev/null +++ b/server/src/instant/totp.clj @@ -0,0 +1,83 @@ +(ns instant.totp + (:require + [instant.util.crypt :as crypt-util] + [clojure.string]) + (:import + (java.time Instant))) + +;; Time step is 5 minutes (in seconds), which half the default expiration. If an +;; app accepts tokens for longer, we just check all of the previous 5 minute +;; intervals until we find a matching token or exceed the max time. In the worst +;; case (24 hours), we'll have to do 288 checks. +;; We can't change this without adding extra code for backwards compatibility +(def default-time-step 300) + +(def digit-count 6) + +;; TOTP generation follows the reference impl in https://www.rfc-editor.org/rfc/rfc6238#page-9 + +(defn left-pad [s len] + (let [pad-len (- len (count s))] + (if (pos? pad-len) + (str (clojure.string/join (repeat pad-len "0")) s) + s))) + +(def digits-power + [1 + 10 + 100 + 1000 + 10000 + 100000 + 1000000 + 10000000 + 100000000]) + +;; For use in testing +(def ^:dynamic *now* nil) + +(defn generate-totp + "Generates a TOTP code. For testing, it accepts the number of + digits and a time step, but you should always use the default + values in production." + ([^bytes secret-key] + (generate-totp secret-key (or *now* (Instant/now)))) + ([^bytes secret-key ^Instant time] + (generate-totp secret-key time 6 default-time-step)) + ([^bytes secret-key ^Instant time code-digits time-step] + (let [t (/ (.getEpochSecond time) + time-step) + t-bytes (-> t + (Long/toHexString) + (.toUpperCase) + (left-pad 16) + (crypt-util/hex-string->bytes)) + hash (crypt-util/hmac-256 secret-key t-bytes) + offset (bit-and (aget hash (dec (alength hash))) + 0xf) + binary (bit-or + (bit-shift-left (bit-and (aget hash offset) 0x7f) + 24) + (bit-shift-left (bit-and (aget hash (+ offset 1)) 0xff) + 16) + (bit-shift-left (bit-and (aget hash (+ offset 2)) 0xff) + 8) + (bit-and (aget hash (+ offset 3)) 0xff)) + otp (mod binary (nth digits-power code-digits))] + (left-pad (Integer/toString otp) code-digits)))) + +(defn valid-totp? + "Returns true if the totp code is valid. Will go back up to max-10-minute-intervals." + ([^bytes secret-key max-5-minute-intervals ^String code] + (valid-totp? secret-key (or *now* (Instant/now)) max-5-minute-intervals code)) + ([^bytes secret-key + ^Instant time + max-5-minute-intervals + ^String code] + (loop [remaining-intervals max-5-minute-intervals + time time] + (when (pos? remaining-intervals) + (if (crypt-util/constant-string= code (generate-totp secret-key time)) + true + (recur (dec remaining-intervals) + (.minusSeconds time default-time-step))))))) diff --git a/server/src/instant/util/crypt.clj b/server/src/instant/util/crypt.clj index 18e2c1b856..0dda561de6 100644 --- a/server/src/instant/util/crypt.clj +++ b/server/src/instant/util/crypt.clj @@ -82,7 +82,7 @@ (defn random-hex [^Long size] (bytes->hex-string (Random/randBytes size))) -(defn hmac-256 [^bytes secret-key ^bytes b] +(defn hmac-256 ^bytes [^bytes secret-key ^bytes b] (let [mac (Mac/getInstance "HmacSHA256")] (.init mac (SecretKeySpec. secret-key "HmacSHA256")) (.doFinal mac b))) diff --git a/server/test/instant/reactive/session_test.clj b/server/test/instant/reactive/session_test.clj index fc5e2b5b91..324aa0a2da 100644 --- a/server/test/instant/reactive/session_test.clj +++ b/server/test/instant/reactive/session_test.clj @@ -1275,7 +1275,7 @@ {:keys [offset]} (blocking-send-msg :start-stream-ok socket-2 {:op :start-stream :client-id "stream-1" - :reconnect-token (str reconnect-token)}) + :reconnect-token reconnect-token}) _ (is (= offset 8)) _ (send-msg socket-2 {:op :append-stream @@ -1287,7 +1287,7 @@ (testing "if someone steals our session, we can't write to it" (blocking-send-msg :start-stream-ok socket-3 {:op :start-stream :client-id "stream-1" - :reconnect-token (str reconnect-token)}) + :reconnect-token reconnect-token}) (blocking-send-msg :error socket-2 {:op :append-stream :client-id "stream-1" :chunks ["GHI"] diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index a10aff13c0..1df5e6ce7c 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -1,9 +1,9 @@ (ns instant.runtime.routes-test (:require - [clj-http.client :as http] [clojure.test :refer [deftest is testing]] - [instant.config :as config] + [instant.core :as core] [instant.db.model.attr :as attr-model] + [instant.flags :as flags] [instant.fixtures :refer [with-empty-app]] [instant.jdbc.aurora :as aurora] [instant.jdbc.sql :as sql] @@ -18,25 +18,33 @@ [instant.system-catalog :as system-catalog] [instant.util.coll :as coll] [instant.util.crypt :as crypt-util] - [instant.util.json :refer [->json]] - [instant.flags :as flags] + [instant.util.json :refer [->json <-json]] [instant.runtime.magic-code-auth :as magic-code-auth] + [instant.totp :as totp] [instant.util.cache :as cache] [instant.util.test :as test-util] [instant.util.tracer :as tracer]) (:import - [clojure.lang ExceptionInfo])) + (clojure.lang ExceptionInfo) + (java.io ByteArrayInputStream) + (java.time Instant) + (java.time.temporal ChronoUnit))) (defn request [opts] (with-redefs [tracer/*silence-exceptions?* (atom true)] - (http/request - (merge-with - merge - {:headers {:Content-Type "application/json"} - :as :json} - (-> opts - (coll/update-when :url #(str config/server-origin %)) - (coll/update-when :body ->json)))))) + (let [req (merge-with merge + {:headers {"content-type" "application/json"} + :request-method (:method opts) + :uri (:url opts)} + (-> opts + (coll/update-when :body (fn [body] + (ByteArrayInputStream. (.getBytes ^String (->json body) "UTF-8")))))) + resp (-> ((core/handler) req) + (update :body (fn [body] + (<-json body true))))] + (if (not= 200 (:status resp)) + (throw (ex-info (str "status " (:status resp)) resp)) + resp)))) (defn send-code-runtime [app body] (let [letter (atom nil)] @@ -110,11 +118,7 @@ (with-empty-app (fn [{app-id :id :as app}] (testing "auth for new user" - (let [code (send-code app {:email "a@b.c"}) - code2 (send-code app {:email "a@b.c"})] - - (testing "can generate two codes" - (is (not= code code2))) + (let [code (send-code app {:email "a@b.c"})] (testing "can't use different code" (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code "000000"})))) @@ -128,19 +132,8 @@ (is (= "a@b.c" (:email user))) (is (some? (:refresh_token user))))) - (testing "can't reuse code" - (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code})))) - - (testing "can use second unused code" - (let [user (verify-code app {:email "a@b.c" :code code2})] - (is (= (str app-id) (:app_id user))) - (is (= "a@b.c" (:email user))) - (is (some? (:refresh_token user))))) - (testing "auth for existing user" (let [code3 (send-code app {:email "a@b.c"}) - _ (is (not= code code3)) - _ (is (not= code2 code3)) user (verify-code app {:email "a@b.c" :code code3})] (is (= (str app-id) (:app_id user))) (is (= "a@b.c" (:email user))) @@ -156,7 +149,7 @@ created_at = ?created-at WHERE app_id = ?app-id - AND entity_id = ( + AND entity_id in ( SELECT entity_id FROM @@ -182,7 +175,15 @@ (fn [{app-id :id :as app}] (let [code (send-code app {:email "a@b.c"})] (update-created-at app-id code (- (System/currentTimeMillis) (* 25 60 60 1000))) - (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code})))) + (binding [totp/*now* (.plus (Instant/now) 1 ChronoUnit/DAYS)] + (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code})))) + + ;; Test that it works without dual-write + (binding [totp/*now* (.plus (.plus (Instant/now) 1 ChronoUnit/DAYS) + 10 ChronoUnit/MINUTES) + flags/*toggle-overrides* {:dual-write-totp false + :validate-with-totp true}] + (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code}))))) (let [code (send-code app {:email "a@b.c"})] (update-created-at app-id code (- (System/currentTimeMillis) (* 23 60 60 1000))) @@ -338,25 +339,25 @@ (deftest upsert-oauth-link-disambiguates-with-email (with-empty-app (fn [app] - ;; Apple OAuth lets you provide "relay" emails: - ;; these are anonymous emails that forward to the user's real email. + ;; Apple OAuth lets you provide "relay" emails: + ;; these are anonymous emails that forward to the user's real email. - ;; This opens up a potential problem. + ;; This opens up a potential problem. - ;; Consider the following scenario: - ;; (1) User signs in with magic code: stopa@instantdb.com - ;; (2) User signs in with with Apple, private relay on: foo@privaterelay.appleid.com + ;; Consider the following scenario: + ;; (1) User signs in with magic code: stopa@instantdb.com + ;; (2) User signs in with with Apple, private relay on: foo@privaterelay.appleid.com - ;; At this point we'll have _2_ separate users. + ;; At this point we'll have _2_ separate users. - ;; Now - ;; (3) The user signs in with Apple, private relay off: stopa@instantdb.com. + ;; Now + ;; (3) The user signs in with Apple, private relay off: stopa@instantdb.com. - ;; Which user should we link this 3rd sign up too? It matches _both_ the - ;; existing email user, and the existing Apple Oauth link. + ;; Which user should we link this 3rd sign up too? It matches _both_ the + ;; existing email user, and the existing Apple Oauth link. - ;; Currently, we choose the existing email user. - ;; This means that the user with the private relay email will get stranded. + ;; Currently, we choose the existing email user. + ;; This means that the user with the private relay email will get stranded. ;; However, in the worst case scenario, they can be recovered manually. (let [provider (provider-model/create! {:app-id (:id app) :provider-name "apple"}) @@ -717,4 +718,3 @@ (permissioned-tx/transact! ctx [[:add-triple user-id (:id id-attr) user-id]])))))))) - diff --git a/server/test/instant/totp_test.clj b/server/test/instant/totp_test.clj new file mode 100644 index 0000000000..774c672905 --- /dev/null +++ b/server/test/instant/totp_test.clj @@ -0,0 +1,34 @@ +(ns instant.totp-test + (:require + [clojure.test :refer [deftest is testing]] + [instant.totp :as totp] + [instant.util.crypt :as crypt-util]) + (:import + (java.time Instant))) + +(def secret-key (crypt-util/hex-string->bytes (str "3132333435363738393031323334353637383930" + "313233343536373839303132"))) + +(deftest totp-reference-impl + ;; Tests against the reference implementation https://www.rfc-editor.org/rfc/rfc6238#page-14 + (doseq [[time expected] [[59 "46119246"] + [1111111109 "68084774"] + [1111111111 "67062674"] + [1234567890 "91819424"] + [2000000000 "90698825"] + [20000000000 "77737706"]]] + (testing (format "%s -> %s" time expected) + (is (= (totp/generate-totp secret-key (Instant/ofEpochMilli (* time 1000)) 8 30) + expected))))) + +(deftest totp-smoke-test + (let [t0 (Instant/ofEpochMilli 1774046599905) + code "508623"] + (is (= code (totp/generate-totp secret-key t0))) + (testing "valid for 5 minutes by default" + (is (totp/valid-totp? secret-key t0 1 code)) + (is (not (totp/valid-totp? secret-key (.plusSeconds t0 (* 10 60)) 1 code)))) + + (testing "can extend validity in 5 minute increments" + (is (totp/valid-totp? secret-key (.plusSeconds t0 (* 9 60)) 3 code)) + (is (not (totp/valid-totp? secret-key (.plusSeconds t0 (* 16 60)) 3 code))))))