Lucia-App/app/src/main/java/digital/selfdefense/lucia/MainActivity.java

402 lines
14 KiB
Java

/*
* Copyright 2021 The Lucia Developers
*
* Licensed under the EUPL, Version 1.2 only (the "Licence");
* You may not use this work except in compliance with the Licence.
* You may obtain a copy of the Licence at:
*
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the Licence for the specific language governing permissions and
* limitations under the Licence.
*/
package digital.selfdefense.lucia;
import android.graphics.Bitmap;
import android.graphics.Point;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.view.Display;
import android.view.WindowManager;
import android.widget.ImageView;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import com.google.zxing.WriterException;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import androidmads.library.qrgenearator.QRGContents;
import androidmads.library.qrgenearator.QRGEncoder;
public class MainActivity extends AppCompatActivity {
Handler handler = new Handler();
Runnable runnable;
final int delay = 5000; // 5000 -> every 5 seconds
long lastTimestamp = 0;
/**
* Random UUID that is only around as long as the app runs.
* It is used to generate pseudo-random data.
*/
UUID randomTemporaryUuid = UUID.randomUUID();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Change title
ActionBar actionBar = getSupportActionBar();
if(actionBar != null) {
actionBar.setTitle(R.string.banner_checkin);
}
// create new code on startup
this.createCode();
}
@Override
protected void onResume() {
MainActivity self = this;
handler.postDelayed(runnable = new Runnable() {
public void run() {
handler.postDelayed(runnable, delay);
self.createCode();
}
}, delay);
super.onResume();
}
@Override
protected void onPause() {
super.onPause();
handler.removeCallbacks(runnable); //stop handler when activity not visible super.onPause();
}
/**
* Creates a new QR code and shows it. Only one code will be generated for
* every minute. If this method is run multiple times within the same minute
* only the first run will create a new code. Subsequent runs will silently
* return without action to save some energy.
*/
protected void createCode() {
/*
The code contains the following data:
1) version: 1 Byte, always 3
2) device type: 1 Byte, always 1 (for Android)
3) key id: 1 Byte, random value
4) unix timestamp: 4 Bytes, rounded down to latest full minute, little
endian byte order
5) random payload: 89 Bytes, random. In the "real" app, this would
contain encrypted data and a verification tag that isn't decrypted/
checked upon check-in. Before a decryption attempt this looks like
random data, so this app will fill in random data to make it look
authentic.
6) checksum: 4 Bytes, the first 4 Bytes of the SHA256 digest of fields
1 to 5 concatenated.
During the check-in procedure only fields 1, 2, 4 and 6 are checked. The
others cannot be verified at the time of check-in and are stored for
later use. Since this app only aims to let people check-in anonymously,
this is acceptable.
*/
/**
* Field 1: Code version - always 3
*/
byte[] version = new byte[] {3};
/**
* Field 2: Device type - always 1 on Android devices
*
* 0 means iOS device
* 1 means Android device
* 2 means static QR code used on key tags
*/
byte[] deviceType = new byte[] {1};
// temporary variables in preparation for field 4
long unixTimestamp = System.currentTimeMillis() / 1000L;
long unixTimestampRounded = unixTimestamp / 60;
unixTimestampRounded *= 60;
/**
* Field 4: unix timestamp - rounded to current full minute, little
* endian byte order
*/
byte[] unixTimestampRoundedLE = Arrays.copyOfRange(longToBytesLE(unixTimestampRounded), 0, 4);
/**
* Field 3: key id - 1 random Byte
*/
byte[] keyId = generateRandomBytes(1, "lucia:code:part:keyId:uuid-" + randomTemporaryUuid.toString() + ":ts-" + unixTimestampRounded);
/**
* Field 5: random payload - 89 random Bytes
*/
byte[] randomPayload = generateRandomBytes(89, "lucia:code:part:randomPayload:uuid-" + randomTemporaryUuid.toString() + ":ts-" + unixTimestampRounded);
// abort early if timestamp hasn't changed because generating the code
// multiple times within the same minute will yield the same code
if(this.lastTimestamp == unixTimestampRounded) {
return;
}
else{
this.lastTimestamp = unixTimestampRounded;
}
// concatenate fields 1 to 5 into a byte array in preparation for the
// checksum calculation
byte[] rawCodeWithoutChecksum = new byte[version.length
+ deviceType.length
+ keyId.length
+ unixTimestampRoundedLE.length
+ randomPayload.length];
System.arraycopy(
version,
0,
rawCodeWithoutChecksum,
0,
version.length);
System.arraycopy(
deviceType,
0,
rawCodeWithoutChecksum,
version.length,
deviceType.length);
System.arraycopy(
keyId,
0,
rawCodeWithoutChecksum,
version.length + deviceType.length,
keyId.length);
System.arraycopy(
unixTimestampRoundedLE,
0,
rawCodeWithoutChecksum,
version.length + deviceType.length + keyId.length,
unixTimestampRoundedLE.length);
System.arraycopy(
randomPayload,
0,
rawCodeWithoutChecksum,
version.length + deviceType.length + keyId.length + unixTimestampRoundedLE.length,
randomPayload.length);
boolean success = true;
/**
* Field 6: checksum - first 4 Bytes of the SHA256 digest of the other
* fields
*/
byte[] checksum = new byte[4];
try {
// Calculate the checksum
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(rawCodeWithoutChecksum);
byte[] digest = md.digest();
// copy the first 4 Bytes of the checksum into the appropriate var
System.arraycopy(
digest,
0,
checksum,
0,
checksum.length);
} catch (NoSuchAlgorithmException e) {
// this catches an error that only occurs of the "SHA-256" hashing
// algorithm cannot be found. This should never happen but we will
// catch the error anyways for good measure.
e.printStackTrace();
success = false;
}
if(success) {
// append the checksum to the previously concatenated code parts
byte[] rawCode = new byte[rawCodeWithoutChecksum.length + checksum.length];
System.arraycopy(
rawCodeWithoutChecksum,
0,
rawCode,
0,
rawCodeWithoutChecksum.length);
System.arraycopy(
checksum,
0,
rawCode,
rawCodeWithoutChecksum.length,
checksum.length);
// encode the raw bytes using Z85 to make it "printable"
String code = Z85.encode(rawCode);
// generate the QR code and show it
showQrCode(code);
// log the code components and final result to logcat
Log.i(
"Lucia Code",
"{\n"
+ "version: " + bytesToHex(version) + "\n"
+ "deviceType: " + bytesToHex(deviceType) + "\n"
+ "keyId: " + bytesToHex(keyId) + "\n"
+ "timestamp: " + bytesToHex(unixTimestampRoundedLE) + "\n"
+ "randomPayload: " + bytesToHex(randomPayload) + "\n"
+ "checksum: " + bytesToHex(checksum) + "\n"
+ "code: " + code + "\n"
+ "}"
);
}
}
/**
* Returns a byte array containing pseudo random bytes of a given length.
* This function is good enough for this app but DO NOT USE THIS FUNCTION TO
* CREATE RANDOM NUMBERS FOR CRYPTOGRAPHY!
* @param len the desired length of the byte array
* @param pers a String on which the random data will be generated out of.
* If you call this function twice with the same pers value,
* the exact same sequence of pseudo random bytes will be
* generated. This is why the calling function above includes
* a random UUID and the current timestamp into the pers value.
* @return a byte array containing len pseudo random bytes
*/
protected byte[] generateRandomBytes(int len, String pers) {
final String hashingAlgo = "HmacSHA256";
final int hashingBytes = 32;
try {
int count = 0;
byte[] init = hmac(hashingAlgo, pers.getBytes(), intToBytesBE(count));
byte[] result = new byte[]{};
for (count = 1; count <= (len / 4) + 1; count++) {
byte[] newResult = hmac(hashingAlgo, init, intToBytesBE(count));
byte[] oldResult = result;
result = new byte[oldResult.length + newResult.length];
System.arraycopy(oldResult, 0, result, 0, oldResult.length);
System.arraycopy(newResult, 0, result, oldResult.length, newResult.length);
}
return Arrays.copyOfRange(result, 0, len);
}
catch (NoSuchAlgorithmException | InvalidKeyException e) {
// in case of emergency return all zeros
byte[] result = new byte[len];
for(int i = 0; i < len; i++){
result[i] = 0;
}
return result;
}
}
/**
* Wrapper function for generating HMACs
* @param algorithm
* @param key
* @param message
* @return
* @throws NoSuchAlgorithmException
* @throws InvalidKeyException
*/
public static byte[] hmac(String algorithm, byte[] key, byte[] message) throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance(algorithm);
mac.init(new SecretKeySpec(key, algorithm));
return mac.doFinal(message);
}
/**
* Converts an int to a byte array containing 4 Bytes in big endian order
* @param data
* @return
*/
private static byte[] intToBytesBE(final int data) {
return new byte[] {
(byte)((data >> 24) & 0xff),
(byte)((data >> 16) & 0xff),
(byte)((data >> 8) & 0xff),
(byte)((data >> 0) & 0xff),
};
}
/**
* Converts a long to a byte array containing 8 Bytes in little endian order
* @param data
* @return
*/
private static byte[] longToBytesLE(final long data) {
return new byte[] {
(byte)((data >> 0) & 0xff),
(byte)((data >> 8) & 0xff),
(byte)((data >> 16) & 0xff),
(byte)((data >> 24) & 0xff),
(byte)((data >> 32) & 0xff),
(byte)((data >> 40) & 0xff),
(byte)((data >> 48) & 0xff),
(byte)((data >> 56) & 0xff),
};
}
/**
* Creates a QR code of a given String and shows it in the GUI
* @param text
*/
protected void showQrCode(String text) {
WindowManager manager = (WindowManager) getSystemService(WINDOW_SERVICE);
Display display = manager.getDefaultDisplay();
Point point = new Point();
display.getSize(point);
int width = point.x;
int height = point.y;
int smallerDimension = width < height ? width : height;
smallerDimension = smallerDimension * 3 / 4;
QRGEncoder qrgEncoder = new QRGEncoder(text, null, QRGContents.Type.TEXT, smallerDimension);
ImageView qrImage = (ImageView) findViewById(R.id.imageView2);
try {
Bitmap bitmap = qrgEncoder.encodeAsBitmap();
qrImage.setImageBitmap(bitmap);
} catch (WriterException e) {
e.printStackTrace();
}
}
/**
* The hexadecimal alphabet
*/
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
/**
* Converts a byte array to a hexadecimal String
* @param bytes
* @return
*/
public static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars);
}
}