I've got a small issue, (My assumption is that there is one small thing holding me back but I have no clue what), as mentioned in the title.
I will start by giving an overview about what I'm doing and will provide everything I have afterwards.
I'm hashing files with SHA-256
in a mobile app and sign the hash in the backend with an ECDSA P-256
key. This is then persisted. If the user needs to he can verify the integretify of the file, by basically hashing the file again and looking up the hash and getting the hash, some metadata and the signature back.
To validate the data has been submitted my app and not a third party (the hashs are persisted in a blockchain but that doesn't really matter for this issue), the app will attempt to verify the signature with the public key. This works fine.
Now I would like to add this option to my website as well, however here is the issue. My signatures are not valid if I use the jsrsasign
or thewebcrypto
api.
3045022100f28c29042a6d766810e21f2c0a1839f93140989299cae1d37b49a454373659c802203d0967be0696686414fe2efed3a71bc1639d066ee127cfb7c0ad369521459d00
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEq6iOuQeIhlhywCjo5yoABGODOJRZ
c6/L8XzUYEsocCbc/JHiByGjuB3G9cSU2vUi1HUy5LsCtX2wlHSEObGVBw==
-----END PUBLIC KEY-----
bb5dbfcb5206282627254ab23397cda842b082696466f2563503f79a5dccf942
const validHash = document.getElementById("valid-hash");
const locationEmbedded = document.getElementById("location-embedded")
const signatureValid = document.getElementById("valid-sig")
const fileSelector = document.getElementById('file-upload');
const mcaptchaToken = document.getElementById("mcaptcha__token")
const submission = document.getElementById("submission")
let publicKey;
fileSelector.addEventListener("change", (event) => {
document.getElementsByClassName("file-upload-label")[0].innerHTML = event.target.files[0].name
})
submission.addEventListener('click', async (event) => {
let token = mcaptchaToken.value
if (token == null || token == "") {
alert("Please activate the Captcha!")
return
}
const fileList = fileSelector.files;
if (fileList[0]) {
const file = fileList[0]
const fileSize = file.size;
let fileData = await readBinaryFile(file)
let byteArray = new Uint8Array(fileData);
const bytes = await hashFile(byteArray)
try {
let resp = await callApi(toHex(bytes), token)
validHash.innerHTML = "\u2713"
const mediainfo = await MediaInfo({ format: 'object' }, async (mediaInfo) => { // Taken from docs
mediaInfo.analyzeData(() => file.size, (chunkSize, offset) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => {
if (event.target.error) {
reject(event.target.error)
}
resolve(new Uint8Array(event.target.result))
}
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize))
})
})
try {
let tags = mediaInfo.media.track[0].extra
latitude = tags.LATITUDE
longitude = tags.LONGITUDE
if (latitude && longitude) {
locationEmbedded.innerHTML = "\u2713"
} else {
locationEmbedded.innerHTML = "\u2717"
}
} catch (e) {
locationEmbedded.innerHTML = "\u2717"
}
})
if (publicKey == undefined) {
let req = await fetch("/publickey")
if (req.ok) {
publicKey = await req.text()
} else {
throw "Could not get public key"
}
}
let signature = resp.data.comment
if (signature == null || signature == "") {
throw "No signature found"
}
//const timeStamps = resp.data.timestamps
const hashString = resp.data.hash_string
console.log(hashString)
if (hashString !== toHex(bytes)) {
validHash.innerHTML = "\u2717"
} else {
validHash.innerHTML = "\u2713"
}
const result = await validateSignature(publicKey, signature, hashString)
console.log("Valid signature: " + result)
if (result) {
signatureValid.innerHTML = "\u2713"
} else {
signatureValid.innerHTML = "\u2717"
}
mcaptchaToken.value = ""
} catch (e) {
alert("Error: " + e)
window.location.reload()
}
} else {
alert("No file selected");
}
});
function toHex(buffer) {
return Array.prototype.map.call(buffer, x => ('00' + x.toString(16)).slice(-2)).join('');
}
async function callApi(hash, token) {
const url = "/verify";
let resp = await fetch(url, {
headers: { "X-MCAPTCHA-TOKEN": token },
method: "POST",
body: JSON.stringify({ hash: hash })
})
if (resp.ok) {
return await resp.json();
} else {
if (resp.status == 401) {
throw resp.status
} else {
console.log(resp)
throw "Your hash is either invalid or has not been submitted via the Decentproof App!"
}
}
}
async function hashFile(byteArray) {
let hashBytes = await window.crypto.subtle.digest('SHA-256', byteArray);
return new Uint8Array(hashBytes)
}
async function validateSignature(key, signature,hashData) {
const importedKey = importPublicKey(key)
const sig = new KJUR.crypto.Signature({"alg": "SHA256withECDSA"});
sig.init(importedKey)
sig.updateHex(hashData);
return sig.verify(signature)
}
function readBinaryFile(file) {
return new Promise((resolve, reject) => {
var fr = new FileReader();
fr.onload = () => {
resolve(fr.result)
};
fr.readAsArrayBuffer(file);
});
}
function importPublicKey(pem) {
console.log(pem)
return KEYUTIL.getKey(pem);
}
function hexToBytes(hex) {
for (var bytes = [], c = 0; c < hex.length; c += 2)
bytes.push(parseInt(hex.substr(c, 2), 16));
return new Uint8Array(bytes);
}
App Verification Code (Flutter Dart)
import 'dart:convert';
import 'package:convert/convert.dart';
import 'dart:typed_data';
import 'package:basic_utils/basic_utils.dart';
import 'package:decentproof/features/verification/interfaces/ISignatureVerifcationService.dart';
import 'package:pointycastle/asn1/asn1_parser.dart';
import 'package:pointycastle/asn1/primitives/asn1_integer.dart';
import 'package:pointycastle/signers/ecdsa_signer.dart';
class SignatureVerificationService implements ISignatureVerificationService {
late final ECPublicKey pubKey;
SignatureVerificationService() {
pubKey = loadAndPrepPubKey();
}
final String pemPubKey = """
-----BEGIN EC PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEq6iOuQeIhlhywCjo5yoABGODOJRZ
c6/L8XzUYEsocCbc/JHiByGjuB3G9cSU2vUi1HUy5LsCtX2wlHSEObGVBw==
-----END EC PUBLIC KEY-----
""";
ECSignature loadAndConvertSignature(String sig) {
//Based on: https://github.com/bcgit/pc-dart/issues/159#issuecomment-1105689978
Uint8List bytes = Uint8List.fromList(hex.decode(sig));
ASN1Parser p = ASN1Parser(bytes);
//Needs to be dynamic or otherwise throws odd errors
final seq = p.nextObject() as dynamic;
ASN1Integer ar = seq.elements?[0] as ASN1Integer;
ASN1Integer as = seq.elements?[1] as ASN1Integer;
BigInt r = ar.integer!;
BigInt s = as.integer!;
return ECSignature(r, s);
}
ECPublicKey loadAndPrepPubKey() {
return CryptoUtils.ecPublicKeyFromPem(pemPubKey);
}
@override
bool verify(String hash, String sig) {
ECSignature convertedSig = loadAndConvertSignature(sig);
final ECDSASigner signer = ECDSASigner();
signer.init(false, PublicKeyParameter<ECPublicKey>(loadAndPrepPubKey()));
Uint8List messageAsBytes = Uint8List.fromList(utf8.encode(hash));
return signer.verifySignature(messageAsBytes, convertedSig);
}
}
Key generation script (Go)
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"os"
)
func main() {
var outPutDir string
var outPutFileName string
flag.StringVar(&outPutDir, "out", "./", "Output directory")
flag.StringVar(&outPutFileName, "name", "key", "Output file name e.g key, my_project_key etc. Adding .pem is not needed")
flag.Parse()
key, err := generateKeys()
if err != nil {
fmt.Printf("Something went wrong %d", err)
return
}
err = saveKeys(key, outPutDir, outPutFileName)
if err != nil {
fmt.Printf("Something went wrong %d", err)
return
}
fmt.Printf("Keys generated and saved to %s%s.pem and %spub_%s.pem", outPutDir, outPutFileName, outPutDir, outPutFileName)
}
func generateKeys() (*ecdsa.PrivateKey, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
}
func saveKeys(key *ecdsa.PrivateKey, outPutDir string, outPutFileName string) error {
bytes, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
privBloc := pem.Block{Type: "EC PRIVATE KEY", Bytes: bytes}
privKeyFile, err := os.Create(outPutDir + outPutFileName + ".pem")
if err != nil {
return err
}
defer privKeyFile.Close()
err = pem.Encode(privKeyFile, &privBloc)
if err != nil {
return err
}
bytes, err = x509.MarshalPKIXPublicKey(&key.PublicKey)
pubBloc := pem.Block{Type: "EC Public KEY", Bytes: bytes}
pubKeyFile, err := os.Create(outPutDir + "pub_" + outPutFileName + ".pem")
if err != nil {
return err
}
defer pubKeyFile.Close()
err = pem.Encode(pubKeyFile, &pubBloc)
if err != nil {
return err
}
return nil
}
Link to signature wrapper script: Link
ECDSA
public key and use new KJUR.crypto.ECDSA({"curve":"secp256r1"}).verifyHex(hash, signature,pubKeyHex)
with the data mentioned above, it didn't work (tested only in the browser console)sig.updateString(hashData)
, it didn't workMy last attempt was the 4th attempt, since, from my understanding at least, if you are using the regular way (which I'm doing in the script above) your data get's hashed, which, in my case, is counter productive as I've already gotten a hash so if it's hashed twice it ,of course, won't match. However for reasons I don't get I still get false as return value.
One final thought, could the issue be that the go ecdsa libary truncates the message down to 32 bytes if the P-256
signature is used? And maybe in JS it doesn't?
The verification in the JavaScript code is incompatible with the Dart code for two reasons:
KJUR.crypto.Signature()
, which hashes the data implicitly. Since the data is already hashed, this results in double hashing. On the Dart side, no implicit hashing occurs (since no digest is specified in ECDSASigner()
).KJUR.crypto.ECDSA()
can be used instead of KJUR.crypto.Signature()
.updateHex()
in the JavaScript code performs a hex decoding of the hex encoded hash, while in the Dart code the hex encoded hash is UTF-8 encoded.The following JavaScript code fixes both issues:
(async () => {
var spki = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEq6iOuQeIhlhywCjo5yoABGODOJRZ
c6/L8XzUYEsocCbc/JHiByGjuB3G9cSU2vUi1HUy5LsCtX2wlHSEObGVBw==
-----END PUBLIC KEY-----`;
var pubkey = KEYUTIL.getKey(spki).getPublicKeyXYHex()
var pubkeyHex = '04' + pubkey.x + pubkey.y
var msgHashHex = ArrayBuffertohex(new TextEncoder().encode("bb5dbfcb5206282627254ab23397cda842b082696466f2563503f79a5dccf942").buffer)
// var msgHashHex = ArrayBuffertohex(new TextEncoder().encode("bb5dbfcb5206282627254ab23397cda8").buffer); // works also since only the first 32 bytes are considered for P-256
var sigHex = "3045022100f28c29042a6d766810e21f2c0a1839f93140989299cae1d37b49a454373659c802203d0967be0696686414fe2efed3a71bc1639d066ee127cfb7c0ad369521459d00"
var ec = new KJUR.crypto.ECDSA({'curve': 'secp256r1'})
var verified = ec.verifyHex(msgHashHex, sigHex, pubkeyHex)
console.log("Verification:", verified)
})();
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsrsasign/10.4.0/jsrsasign-all-min.js"></script>
Now, verification is successful.
Note regarding the UTF-8 encoding of the hex encoded hash: This produces 64 bytes, so that ECDSA with P-256 only considers the first 32 bytes of the data, i.e. the last 32 bytes are ignored (for more details see FIPS 186-5, section 6.4, last paragraph).
Actually this is either a bug or a bad design in the signing code. More correct would be a hex decoding, which would provide 32 bytes so that the entire data would be taken into account.