Background:
I have a protobuf
object called RegistrationInfo
, defined as such:
message RegistrationInfo {
string serverID = 1;
string serverIP = 2;
string alias = 3;
string rootCA = 4;
}
In my Scala backend, I have a QRCodeGenerator
class, that creates a QR image from this object using the zxing
library:
def init(): Unit = {
// create our own RegistrationInfo
val regInfo = RegistrationInfo(SERVER_ID, SERVER_IP, s"${OS.getOS.toString} Server", Base64.getEncoder.encodeToString(SSLManager.loadRootCertificate().getEncoded))
encodedQRCode = generateEncodedQRCode(regInfo.toProtoString)
}
private def generateEncodedQRCode(text: String): String = {
val qrCodeWriter = new QRCodeWriter()
// Set QR code properties
val hintMap = new util.HashMap[EncodeHintType, Any]()
hintMap.put(EncodeHintType.CHARACTER_SET, "UTF-8")
// Generate the QR code as a bit matrix
val bitMatrix = qrCodeWriter.encode(text, BarcodeFormat.QR_CODE, 150, 150, hintMap)
// Write the bit matrix to a byte array (PNG format)
val outputStream = new java.io.ByteArrayOutputStream()
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream)
// Return the byte array representing the image
Base64.getEncoder.encodeToString(outputStream.toByteArray)
}
My NextJS
application requests this image (as a Base64 encoded string) and provides a download link, like so:
const [qrResponse, setQrResponse] = useState(null);
function writeBase64AsPNG(base64String, fileName = "image.png") {
// Ensure the Base64 string doesn't have the prefix `data:image/png;base64,`
const cleanBase64String = base64String.replace(/^data:image\/png;base64,/, "");
// Decode the Base64 string into a binary string
const byteCharacters = atob(cleanBase64String);
// Create an array of byte values
const byteArray = new Uint8Array(byteCharacters.length);
// Convert the binary string to a byte array
for (let i = 0; i < byteCharacters.length; i++) {
byteArray[i] = byteCharacters.charCodeAt(i);
}
// Create a Blob from the byte array (with MIME type for PNG images)
return new Blob([byteArray], { type: "image/png" });
}
useEffect(() => {
if (token && thisServerID) {
getProto("/web/qr", token, router)
.then(apiResponse => {
if (apiResponse.hasQrresponse()) {
setQrResponse(writeBase64AsPNG(apiResponse.getQrresponse().getQrdata()));
} else if (apiResponse.hasStatusresponse()) {
throw Error(apiResponse.getStatusresponse().getMessage())
}
})
}
}, [token, thisServerID]);
...
const downloadBlob = () => {
if (!qrResponse) return;
// Create an object URL for the Blob
const url = URL.createObjectURL(qrResponse);
// Create an anchor element and trigger the download
const link = document.createElement('a');
link.href = url;
link.download = thisServerID + "_fingerprint.png"; // File name for download
link.click();
// Clean up the URL after the download is triggered
URL.revokeObjectURL(url);
};
inside component:
{qrResponse && thisServerID ? (
<a href="#" onClick={downloadBlob}>
Download Digital Fingerprint
</a>
) : (
<p>Loading Digital Fingerprint...</p>
)}
This process works fine, and I can download the QR image:
I then upload the image into the UI to test the backend processing.
The backend processing (also in QRCodeGenerator
):
def extractInfo(registrationRequest: RegistrationRequest)(implicit system: ActorSystem[_]): RegistrationInfo = {
try {
system.log.info(s"Full String: ${registrationRequest.qrData}")
val cleanBase64String = registrationRequest.qrData.replaceFirst("^data:image\\/[^;]+;base64,", "")
system.log.info(s"Cleaned String: $cleanBase64String")
val decodedBytes = Base64.getDecoder.decode(cleanBase64String)
system.log.info(s"Decoded byte array size: ${decodedBytes.length} bytes")
val inputStream = new ByteArrayInputStream(decodedBytes)
val bufferedImage: BufferedImage = ImageIO.read(inputStream)
if (bufferedImage == null) {
system.log.error("Error: Failed to read image from byte array.")
null
} else {
// Create a LuminanceSource from the BufferedImage
val luminanceSource = new BufferedImageLuminanceSource(bufferedImage)
val binaryBitmap = new BinaryBitmap(new HybridBinarizer(luminanceSource))
// Create a QRCodeReader instance
val reader = new QRCodeReader()
// Decode the QR code from the luminance source
val result = reader.decode(binaryBitmap)
JsonFormat.fromJsonString[RegistrationInfo](result.getText)
}
} catch {
case e: NotFoundException =>
// If no QR code is found, return None
system.log.error("QR code not found in the image.", e)
null
case e: Exception =>
// Any other errors (e.g., Base64 decoding failure, image reading failure)
system.log.error(s"Error decoding QR code: ${e.getMessage}", e)
null
}
}
The Problem:
I am catching the NotFoundException
, meaning zxing
cannot find a valid QR code in the image.
I'm unsure why. I uploaded the image to https://zxing.org/w/decode.jspx and it was able to successfully decode the image and I could see the data correctly.
Update:
I removed NextJS
from the equation and just tried processing the results of generateEncodedQRCode
through extractInfo
. I got the same exception, suggesting there is a problem in one of the functions. I'm leaning towards the extractInfo
function, seems as the zxing
website was able to decode the QR.
Test init
function:
def init()(implicit system: ActorSystem[_]): Unit = {
// create our own RegistrationInfo
val regInfo = RegistrationInfo(SERVER_ID, SERVER_IP, s"${OS.getOS.toString} Server", Base64.getEncoder.encodeToString(SSLManager.loadRootCertificate().getEncoded))
encodedQRCode = generateEncodedQRCode(regInfo.toProtoString)
// for testing, attempt to reform regInfo
val reformedRegInfo = extractInfo(encodedQRCode) // <-- throws "NotFoundException"
system.log.info(s"Reformed RegInfo: ${reformedRegInfo.toProtoString}")
}
I'm new to this QR generation stuff and the zxing
library, so any suggestions what might be wrong?
update 2:
I added further debugging.
In generateEncodedQRCode
function:
In extractInfo
function:
I can confirm both arrays are identical. Both array sizes are identical. Both encoded strings (result of first function, passed directly to second) are identical.
I do not get to the last log, I get the NotFoundException
Update 3:
I reduced the problem even more by generating a QR code for the string "hello world" and putting it through my extract function. This worked, suggesting there is a problem with parsing toProtoString
to and from a QR code.
I opted for toProtoString
over using the proto's raw bytes because its human-readable and allows the user to scan it with their phone and verify the contents.
Update 4:
I switched from .toProtoString
to:
JsonFormat.printer.print(regInfo)
Which made no difference. I then has a suspicion of the rootCA
, as its a Base64
encoded string. I swapped it for a basic string: exampleRootCA
and it works!
So how would I get the rootCA
into the proto in a human-readable format that still processes as a QR?
So after my debugging steps outlined in the update sections of the question, I have come to the conclusion the error was caused by setting the rootCA
field of the RegistraionInfo
message to a Base64 encoded string.
The solution is to wrap the Base64 encoded string into a String raw type like so:
val rootCAString = new String(Base64.getEncoder.encodeToString(SSLManager.loadRootCertificate().getEncoded))
And set this as the RegistrationInfo
rootCA field.
This is because zxing
expects a string input that it can itself encode, not an already encoded string. Now I have the challenge of handling decoding the rootCA
back into a certificate, but that is outside the scope of this question.
I will leave this here in the hope it will help someone in the same situation in the future.
EDIT:
So wrapping it in raw string type was not the entire solution, not really sure how it worked once and never again.
Turns out the data from encoding a certificate to a string is over the max size of Version 40 of QR. I'm still very confused as to how QR decoding websites were able to decode the QR and I wasn't.
However, I now use Brotli4j
to compress the certificates bytes BEFORE encoding to a QR, reducing the size of the data so it will fit into a QR.
Example compression/decompression:
def compressCertificate(cert: X509Certificate): Array[Byte] = {
val certBytes = cert.getEncoded // Get the certificate bytes
// Create a ByteArrayOutputStream to hold the compressed data
val compressedStream = new ByteArrayOutputStream()
val brotliOutStream = new BrotliOutputStream(compressedStream)
// Write the certificate bytes to the BrotliOutputStream
brotliOutStream.write(certBytes)
brotliOutStream.close()
// Return the compressed byte array
compressedStream.toByteArray
}
def decompressCertificate(compressedBytes: Array[Byte]): Array[Byte] = {
val compressedStream = new ByteArrayInputStream(compressedBytes)
val brotliInStream = new BrotliInputStream(compressedStream)
// Read all decompressed bytes into a ByteArrayOutputStream
val decompressedStream = new ByteArrayOutputStream()
val buffer = new Array[Byte](1024)
var bytesRead = 0
while ( {
bytesRead = brotliInStream.read(buffer); bytesRead != -1
}) {
decompressedStream.write(buffer, 0, bytesRead)
}
// Return the decompressed byte array as a certificate string
decompressedStream.toByteArray
}