Last active
March 9, 2024 14:32
-
-
Save Alceatraz/3ca46097110bc451bd141bb453abd4a2 to your computer and use it in GitHub Desktop.
RFC6238 TOTP in java lib-less only std lib
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package org.example; | |
import javax.crypto.Mac; | |
import javax.crypto.spec.SecretKeySpec; | |
import java.net.URLEncoder; | |
import java.nio.charset.StandardCharsets; | |
import java.security.InvalidKeyException; | |
import java.security.NoSuchAlgorithmException; | |
import java.security.SecureRandom; | |
@SuppressWarnings("unused") | |
public sealed class TOTP permits TOTP.TOTPServer, TOTP.TOTPClient { | |
//= ================================================================================================================ | |
//= Remove main method for production usage | |
//= ================================================================================================================ | |
public static void main(String[] args) { | |
// 1. Create new 2FA secret | |
// Save bytes into database better then Base32 secret key to Avoid decode overhead | |
byte[] secretBytes = TOTP.generateSecret(); | |
{ | |
// 2. Convert key into Base32 string for manual setup | |
TOTPServer server = TOTP.getServer(secretBytes); | |
String secretKey = server.getSecretKey(); | |
System.out.println(secretKey); | |
// 3. Convert key into OTP Auth URL for QRCode generation | |
String secretURL = server.getOTPAuthURL("BTS-TOTP", "test-001", "BTS-Account"); | |
System.out.println(secretURL); | |
// Do not use it to generate security tokens for logging into your account. You should never store, | |
// process or otherwise access the secret key on the same machine that you use for accessing your account. | |
// Doing so completely defeats the security of two-step verification. | |
// DO NOT USE THIS IN PRODUCTION DO NOT USE THIS IN PRODUCTION DO NOT USE THIS IN PRODUCTION DO NOT USE THIS IN PRODUCTION | |
String qrcodeURL = "https://api.qrserver.com/v1/create-qr-code/?size=300x300&ecc=M&margin=50&data=" + URLEncoder.encode(secretURL, StandardCharsets.UTF_8); | |
System.out.println(qrcodeURL); | |
// DO NOT USE THIS IN PRODUCTION DO NOT USE THIS IN PRODUCTION DO NOT USE THIS IN PRODUCTION DO NOT USE THIS IN PRODUCTION | |
// 4. Client scan QRCode and get first passcode | |
TOTPClient client = TOTP.getClient(secretBytes); | |
int setupPasscode = client.generatePasscode(); | |
// 5. Server verify the setup passcode | |
boolean verifyPasscode = server.verifyPasscode(setupPasscode); | |
System.out.println(setupPasscode + " " + verifyPasscode); | |
} | |
//------------------------------------------------------------------------------------------------------------- | |
{ | |
// some day | |
// 1. Client generate passcode | |
TOTPClient client = TOTP.getClient(secretBytes); | |
int loginPasscode = client.generatePasscode(); | |
// 2. Server verify passcode | |
TOTPServer server = TOTP.getServer(secretBytes); | |
boolean verifyPasscode = server.verifyPasscode(loginPasscode); | |
System.out.println(loginPasscode + " " + verifyPasscode); | |
} | |
} | |
//= ================================================================================================================ | |
//= Remove main method for production usage | |
//= ================================================================================================================ | |
//= ================================================================================================================ | |
public static byte[] generateSecret() { | |
return generateSecret(Digest.SHA1); | |
} | |
public static byte[] generateSecret(Digest hashMode) { | |
byte[] secret = new byte[hashMode.length]; | |
SecureRandom secureRandom; | |
try { | |
secureRandom = SecureRandom.getInstance("SHA1PRNG"); | |
} catch (NoSuchAlgorithmException exception) { | |
throw new RuntimeException(exception); | |
} | |
secureRandom.nextBytes(secret); | |
return secret; | |
} | |
//= ================================================================================================================ | |
public static TOTPServer getServer(byte[] secret) { | |
return new TOTPServer(Digest.SHA1, 30, 6, secret, null); | |
} | |
public static TOTPServer getServer(String secret) { | |
return new TOTPServer(Digest.SHA1, 30, 6, Base32L.decode(secret), secret); | |
} | |
public static TOTPServer getServer(Digest digest, int period, int digits, byte[] secret) { | |
return new TOTPServer(digest, period, digits, secret, null); | |
} | |
public static TOTPServer getServer(Digest digest, int period, int digits, String secret) { | |
return new TOTPServer(digest, period, digits, Base32L.decode(secret), secret); | |
} | |
//= ================================================================================================================ | |
public static TOTPClient getClient(byte[] secret) { | |
return new TOTPClient(Digest.SHA1, 30, 6, secret, null); | |
} | |
public static TOTPClient getClient(String secret) { | |
return new TOTPClient(Digest.SHA1, 30, 6, Base32L.decode(secret), secret); | |
} | |
public static TOTPClient getClient(Digest digest, int period, int digits, byte[] secret) { | |
return new TOTPClient(digest, period, digits, secret, null); | |
} | |
public static TOTPClient getClient(Digest digest, int period, int digits, String secret) { | |
return new TOTPClient(digest, period, digits, Base32L.decode(secret), secret); | |
} | |
//= ================================================================================================================ | |
protected final Digest digest; | |
protected final int period; | |
protected final int digits; | |
protected final byte[] secret; | |
protected final String encodedSecret; | |
protected final int divider; | |
protected TOTP(Digest digest, int period, int digits, byte[] secret, String encodedSecret) { | |
this.digest = digest; | |
this.period = period; | |
this.digits = digits; | |
this.secret = secret; | |
this.encodedSecret = encodedSecret; | |
// divider = Integer.parseInt("1" + "0".repeat(digits)); | |
divider = (int) Math.pow(10, digits); | |
} | |
public enum Digest { | |
SHA1(20, "SHA1", "HmacSHA1"), | |
SHA256(32, "SHA256", "HmacSHA256"), | |
SHA512(64, "SHA512", "HmacSHA512"); | |
public final int length; | |
public final String algorithm; | |
public final String cipherName; | |
Digest(int length, String algorithm, String cipherName) { | |
this.length = length; | |
this.algorithm = algorithm; | |
this.cipherName = cipherName; | |
} | |
} | |
protected int passcode(long epoch) { | |
long timestamp = epoch / period; | |
SecretKeySpec key = new SecretKeySpec(secret, digest.cipherName); | |
Mac mac; | |
try { | |
mac = Mac.getInstance(digest.cipherName); | |
mac.init(key); | |
} catch (NoSuchAlgorithmException | InvalidKeyException exception) { | |
throw new RuntimeException(exception); | |
} | |
byte[] data = new byte[8]; | |
for (int i = 8; i-- > 0; timestamp = timestamp >>> 8) { | |
data[i] = (byte) timestamp; | |
} | |
byte[] hash = mac.doFinal(data); | |
int offset = hash[hash.length - 1] & 0xf; | |
int b1 = (hash[offset++] & 0x7f) << 24; | |
int b2 = (hash[offset++] & 0xff) << 16; | |
int b3 = (hash[offset++] & 0xff) << 8; | |
int b4 = (hash[offset] & 0xff); | |
return (b1 | b2 | b3 | b4) % divider; | |
} | |
//= ================================================================================================================ | |
public static final class TOTPServer extends TOTP { | |
private String secretKey = null; | |
private TOTPServer(Digest digest, int period, int digits, byte[] secret, String encodedSecret) { | |
super(digest, period, digits, secret, encodedSecret); | |
} | |
/** | |
* WARNING: This is not thread safe | |
* You will only use this once, Thread safe is meaningless | |
* | |
* @return Base32 encoded key | |
*/ | |
public String getSecretKey() { | |
if (encodedSecret == null) { | |
if (secretKey == null) { | |
secretKey = Base32L.encode(secret); | |
} | |
return secretKey; | |
} else { | |
return encodedSecret; | |
} | |
} | |
/** | |
* WARNING: This is not thread safe | |
* You will only use this once, Thread safe is meaningless | |
* | |
* @param provider provider | |
* @param username username | |
* @param issuer issuer | |
* @return OTP Auth URL | |
*/ | |
public String getOTPAuthURL(String provider, String username, String issuer) { | |
provider = URLEncoder.encode(provider, StandardCharsets.UTF_8); | |
username = URLEncoder.encode(username, StandardCharsets.UTF_8); | |
issuer = URLEncoder.encode(issuer, StandardCharsets.UTF_8); | |
return "otpauth://totp/" + provider + ":" + username + | |
"?secret=" + getSecretKey() + | |
"&issuer=" + issuer + | |
"&digits=" + digits + | |
"&period=" + period + | |
"&algorithm=" + digest.algorithm; | |
} | |
public boolean verifyPasscode(int passcode) { | |
return verifyPasscode(passcode, System.currentTimeMillis(), 1); | |
} | |
public boolean verifyPasscode(int passcode, long epochMilli) { | |
return verifyPasscode(passcode, epochMilli, 1); | |
} | |
public boolean verifyPasscode(int passcode, long epochMilli, int window) { | |
long epoch = epochMilli / 1000; | |
if (passcode == super.passcode(epoch)) { | |
return true; | |
} else { | |
for (long i = epoch; i < epoch + window; i++) { | |
if (passcode == super.passcode(i)) { | |
return true; | |
} | |
} | |
for (long i = epoch; i > epoch - window; i--) { | |
if (passcode == super.passcode(i)) { | |
return true; | |
} | |
} | |
} | |
return false; | |
} | |
} | |
public static final class TOTPClient extends TOTP { | |
private final String FORMAT = "%0" + digits + "d"; | |
private TOTPClient(Digest digest, int period, int digits, byte[] secret, String encodedSecret) { | |
super(digest, period, digits, secret, encodedSecret); | |
} | |
public String generateFormattedPasscode() { | |
return String.format(FORMAT, generatePasscode()); | |
} | |
public String generateFormattedPasscode(long epochMilli) { | |
return String.format(FORMAT, generatePasscode(epochMilli)); | |
} | |
public int generatePasscode() { | |
return generatePasscode(System.currentTimeMillis()); | |
} | |
public int generatePasscode(long epochMilli) { | |
long epoch = epochMilli / 1000; | |
return super.passcode(epoch); | |
} | |
} | |
//= ================================================================================================================ | |
private static class Base32L { | |
private static final char[] MAPPER = { | |
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', | |
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '2', '3', '4', '5', '6', '7', | |
}; | |
private static final int[] LOOKUP = { | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, | |
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
}; | |
private static String encode(byte[] input) { | |
StringBuilder result = new StringBuilder(); | |
int temp = 0; | |
int size = 0; | |
for (byte it : input) { | |
temp = temp << 8; | |
temp = temp | it & 0xFF; | |
size = size + 8; | |
while (size >= 5) { | |
int move = size - 5; | |
int code = temp >> move & 0x1F; | |
result.append(MAPPER[code]); | |
size = move; | |
} | |
} | |
int remain = input.length * 8 % 5; | |
if (remain > 0) { | |
int width = 8 - remain; | |
temp = temp << width; | |
size = size + width; | |
while (size >= 5) { | |
int move = size - 5; | |
int code = temp >> move & 0x1F; | |
result.append(MAPPER[code]); | |
size = move; | |
} | |
int repeat = 8 - result.length() % 8; | |
result.append("=".repeat(repeat)); | |
} | |
return result.toString(); | |
} | |
private static byte[] decode(String input) { | |
char[] charArray = input.toCharArray(); | |
byte[] buffer = new byte[charArray.length * 5 / 8]; | |
int length = 0; | |
int temp = 0; | |
int size = 0; | |
for (char it : charArray) { | |
if (it == '=') break; | |
temp = temp << 5; | |
temp = temp | LOOKUP[it]; | |
size = size + 5; | |
while (size >= 8) { | |
int move = size - 8; | |
buffer[length++] = (byte) (temp >> move); | |
size = move; | |
} | |
} | |
byte[] result = new byte[length]; | |
System.arraycopy(buffer, 0, result, 0, length); | |
return result; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment