kopia lustrzana https://github.com/robinmoisson/staticrypt
bump patch version
rodzic
0cdb80a419
commit
69a101e68f
|
@ -96,27 +96,6 @@
|
|||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.staticrypt-footer {
|
||||
position: fixed;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
padding: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.staticrypt-footer p {
|
||||
margin: 2px;
|
||||
text-align: center;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.staticrypt-footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
label.staticrypt-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -202,10 +181,6 @@
|
|||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="staticrypt-footer">
|
||||
<p class="pull-right">Created with <a href="https://robinmoisson.github.io/staticrypt">StatiCrypt</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
|
@ -262,20 +237,90 @@ exports.decrypt = decrypt;
|
|||
* @returns string
|
||||
*/
|
||||
function hashPassphrase(passphrase, salt) {
|
||||
var hashedPassphrase = CryptoJS.PBKDF2(passphrase, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1000,
|
||||
});
|
||||
// we hash the passphrase in two steps: first 1k iterations, then we add iterations. This is because we used to use 1k,
|
||||
// so for backwards compatibility with remember-me/autodecrypt links, we need to support going from that to more
|
||||
// iterations
|
||||
var hashedPassphrase = hashLegacyRound(passphrase, salt);
|
||||
|
||||
return hashedPassphrase.toString();
|
||||
return hashSecondRound(hashedPassphrase, salt);
|
||||
}
|
||||
exports.hashPassphrase = hashPassphrase;
|
||||
|
||||
/**
|
||||
* This hashes the passphrase with 1k iterations. This is a low number, we need this function to support backwards
|
||||
* compatibility.
|
||||
*
|
||||
* @param {string} passphrase
|
||||
* @param {string} salt
|
||||
* @returns {string}
|
||||
*/
|
||||
function hashLegacyRound(passphrase, salt) {
|
||||
return CryptoJS.PBKDF2(passphrase, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1000,
|
||||
}).toString();
|
||||
}
|
||||
exports.hashLegacyRound = hashLegacyRound;
|
||||
|
||||
/**
|
||||
* Add a second round of iterations. This is because we used to use 1k, so for backwards compatibility with
|
||||
* remember-me/autodecrypt links, we need to support going from that to more iterations.
|
||||
*
|
||||
* @param hashedPassphrase
|
||||
* @param salt
|
||||
* @returns {string}
|
||||
*/
|
||||
function hashSecondRound(hashedPassphrase, salt) {
|
||||
return CryptoJS.PBKDF2(hashedPassphrase, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 14000,
|
||||
hasher: CryptoJS.algo.SHA256,
|
||||
}).toString();
|
||||
}
|
||||
exports.hashSecondRound = hashSecondRound;
|
||||
|
||||
function generateRandomSalt() {
|
||||
return CryptoJS.lib.WordArray.random(128 / 8).toString();
|
||||
}
|
||||
exports.generateRandomSalt = generateRandomSalt;
|
||||
|
||||
function getRandomAlphanum() {
|
||||
var possibleCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
var byteArray;
|
||||
var parsedInt;
|
||||
|
||||
// Keep generating new random bytes until we get a value that falls
|
||||
// within a range that can be evenly divided by possibleCharacters.length
|
||||
do {
|
||||
byteArray = CryptoJS.lib.WordArray.random(1);
|
||||
// extract the lowest byte to get an int from 0 to 255 (probably unnecessary, since we're only generating 1 byte)
|
||||
parsedInt = byteArray.words[0] & 0xff;
|
||||
} while (parsedInt >= 256 - (256 % possibleCharacters.length));
|
||||
|
||||
// Take the modulo of the parsed integer to get a random number between 0 and totalLength - 1
|
||||
var randomIndex = parsedInt % possibleCharacters.length;
|
||||
|
||||
return possibleCharacters[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string of a given length.
|
||||
*
|
||||
* @param {int} length
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateRandomString(length) {
|
||||
var randomString = '';
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
randomString += getRandomAlphanum();
|
||||
}
|
||||
|
||||
return randomString;
|
||||
}
|
||||
exports.generateRandomString = generateRandomString;
|
||||
|
||||
function signMessage(hashedPassphrase, message) {
|
||||
return CryptoJS.HmacSHA256(
|
||||
message,
|
||||
|
@ -294,22 +339,31 @@ exports.signMessage = signMessage;
|
|||
* @param cryptoEngine - the engine to use for encryption / decryption
|
||||
*/
|
||||
function init(cryptoEngine) {
|
||||
// TODO: remove on next major version bump. This is a hack to make the salt available in all functions here in a
|
||||
// backward compatible way (not requiring to change the password_template).
|
||||
const backwardCompatibleSalt = 'b93bbaf35459951c47721d1f3eaeb5b9';
|
||||
|
||||
const exports = {};
|
||||
|
||||
/**
|
||||
* Top-level function for encoding a message.
|
||||
* Includes passphrase hashing, encryption, and signing.
|
||||
* Includes password hashing, encryption, and signing.
|
||||
*
|
||||
* @param {string} msg
|
||||
* @param {string} passphrase
|
||||
* @param {string} password
|
||||
* @param {string} salt
|
||||
* @param {boolean} isLegacy - whether to use the legacy hashing algorithm (1k iterations) or not
|
||||
*
|
||||
* @returns {string} The encoded text
|
||||
*/
|
||||
function encode(msg, passphrase, salt) {
|
||||
const hashedPassphrase = cryptoEngine.hashPassphrase(passphrase, salt);
|
||||
function encode(msg, password, salt, isLegacy = false) {
|
||||
// TODO: remove in the next major version bump. This is to not break backwards compatibility with the old way of hashing
|
||||
const hashedPassphrase = isLegacy
|
||||
? cryptoEngine.hashLegacyRound(password, salt)
|
||||
: cryptoEngine.hashPassphrase(password, salt);
|
||||
const encrypted = cryptoEngine.encrypt(msg, hashedPassphrase);
|
||||
// we use the hashed passphrase in the HMAC because this is effectively what will be used a passphrase (so we can store
|
||||
// it in localStorage safely, we don't use the clear text passphrase)
|
||||
// we use the hashed password in the HMAC because this is effectively what will be used a password (so we can store
|
||||
// it in localStorage safely, we don't use the clear text password)
|
||||
const hmac = cryptoEngine.signMessage(hashedPassphrase, encrypted);
|
||||
|
||||
return hmac + encrypted;
|
||||
|
@ -318,21 +372,49 @@ function init(cryptoEngine) {
|
|||
|
||||
/**
|
||||
* Top-level function for decoding a message.
|
||||
* Includes signature check, an decryption.
|
||||
* Includes signature check and decryption.
|
||||
*
|
||||
* @param {string} signedMsg
|
||||
* @param {string} hashedPassphrase
|
||||
* @param {string} backwardCompatibleHashedPassword
|
||||
*
|
||||
* @returns {Object} {success: true, decoded: string} | {success: false, message: string}
|
||||
*/
|
||||
function decode(signedMsg, hashedPassphrase) {
|
||||
function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') {
|
||||
const encryptedHMAC = signedMsg.substring(0, 64);
|
||||
const encryptedMsg = signedMsg.substring(64);
|
||||
const decryptedHMAC = cryptoEngine.signMessage(hashedPassphrase, encryptedMsg);
|
||||
|
||||
if (decryptedHMAC !== encryptedHMAC) {
|
||||
// TODO: remove in next major version bump. This is to not break backwards compatibility with the old 1k
|
||||
// iterations in PBKDF2 - if the key we try isn't working, it might be because it's a remember-me/autodecrypt
|
||||
// link key, generated with 1k iterations. Try again with the updated iteration count.
|
||||
if (!backwardCompatibleHashedPassword) {
|
||||
return decode(
|
||||
signedMsg,
|
||||
cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt),
|
||||
hashedPassphrase
|
||||
);
|
||||
}
|
||||
|
||||
return { success: false, message: "Signature mismatch" };
|
||||
}
|
||||
|
||||
// TODO: remove in next major version bump. If we're trying to double hash for backward compatibility reasons,
|
||||
// and the attempt is successful, we check if we should update the stored password in localStorage. This avoids
|
||||
// having to compute the upgrade each time.
|
||||
if (backwardCompatibleHashedPassword) {
|
||||
if (window && window.localStorage) {
|
||||
const storedPassword = window.localStorage.getItem('staticrypt_passphrase');
|
||||
|
||||
// check the stored password is actually the backward compatible one, so we don't save the new one and trigger
|
||||
// the "remember-me" by mistake, leaking the password
|
||||
if (storedPassword === backwardCompatibleHashedPassword) {
|
||||
window.localStorage.setItem('staticrypt_passphrase', hashedPassphrase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
decoded: cryptoEngine.decrypt(encryptedMsg, hashedPassphrase),
|
||||
|
|
152
index.html
152
index.html
|
@ -43,7 +43,7 @@
|
|||
</h1>
|
||||
<p>
|
||||
Based on the <a href="https://github.com/brix/crypto-js">crypto-js library</a>, StatiCrypt uses AES-256
|
||||
to encrypt your string with your passphrase in your browser (client side).
|
||||
to encrypt your string with your long password in your browser (client side).
|
||||
</p>
|
||||
<p>
|
||||
Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see <a
|
||||
|
@ -62,8 +62,8 @@
|
|||
</h4>
|
||||
<div id="concept" class="hidden">
|
||||
<p>
|
||||
<b class="text-danger">Disclaimer</b> if you have extra sensitive banking data, you should probably
|
||||
use something else!
|
||||
<b class="text-danger">Disclaimer</b> if you are an at-risk activist, or have extra sensitive
|
||||
banking data, you should probably use something else!
|
||||
</p>
|
||||
<p>
|
||||
StatiCrypt generates a static, password protected page that can be decrypted in-browser:
|
||||
|
@ -72,13 +72,14 @@
|
|||
</p>
|
||||
<p>
|
||||
It basically encrypts your page and puts everything with a user-friendly way to use a password
|
||||
in the new file.
|
||||
<br>AES-256 is state of the art but <b>brute-force/dictionary attacks would be trivial to
|
||||
do at a really fast pace: use a long, unusual passphrase!</b>
|
||||
in the new file. AES-256 is state of the art but <b>brute-force/dictionary attacks would be easy to
|
||||
do at a really fast pace: use a long, unusual password!</b>
|
||||
<br/> => To be safe, we recommend 16+ alphanum characters, and using a password manager like the
|
||||
open-source <a href="http://bitwarden.com">Bitwarden</a>.
|
||||
</p>
|
||||
<p>
|
||||
Feel free to contribute or report any thought to the
|
||||
<a href="https://github.com/robinmoisson/staticrypt">GitHub project</a>!
|
||||
<a href="https://github.com/robinmoisson/staticrypt">GitHub project</a>.
|
||||
</p>
|
||||
</div>
|
||||
<br>
|
||||
|
@ -88,9 +89,9 @@
|
|||
<div class="col-xs-12">
|
||||
<form id="encrypt_form">
|
||||
<div class="form-group">
|
||||
<label for="passphrase">Passphrase</label>
|
||||
<label for="passphrase">Password</label>
|
||||
<input type="password" class="form-control" id="passphrase"
|
||||
placeholder="Passphrase (choose a long one!)">
|
||||
placeholder="Password (choose a long one!)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
@ -247,20 +248,90 @@ exports.decrypt = decrypt;
|
|||
* @returns string
|
||||
*/
|
||||
function hashPassphrase(passphrase, salt) {
|
||||
var hashedPassphrase = CryptoJS.PBKDF2(passphrase, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1000,
|
||||
});
|
||||
// we hash the passphrase in two steps: first 1k iterations, then we add iterations. This is because we used to use 1k,
|
||||
// so for backwards compatibility with remember-me/autodecrypt links, we need to support going from that to more
|
||||
// iterations
|
||||
var hashedPassphrase = hashLegacyRound(passphrase, salt);
|
||||
|
||||
return hashedPassphrase.toString();
|
||||
return hashSecondRound(hashedPassphrase, salt);
|
||||
}
|
||||
exports.hashPassphrase = hashPassphrase;
|
||||
|
||||
/**
|
||||
* This hashes the passphrase with 1k iterations. This is a low number, we need this function to support backwards
|
||||
* compatibility.
|
||||
*
|
||||
* @param {string} passphrase
|
||||
* @param {string} salt
|
||||
* @returns {string}
|
||||
*/
|
||||
function hashLegacyRound(passphrase, salt) {
|
||||
return CryptoJS.PBKDF2(passphrase, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1000,
|
||||
}).toString();
|
||||
}
|
||||
exports.hashLegacyRound = hashLegacyRound;
|
||||
|
||||
/**
|
||||
* Add a second round of iterations. This is because we used to use 1k, so for backwards compatibility with
|
||||
* remember-me/autodecrypt links, we need to support going from that to more iterations.
|
||||
*
|
||||
* @param hashedPassphrase
|
||||
* @param salt
|
||||
* @returns {string}
|
||||
*/
|
||||
function hashSecondRound(hashedPassphrase, salt) {
|
||||
return CryptoJS.PBKDF2(hashedPassphrase, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 14000,
|
||||
hasher: CryptoJS.algo.SHA256,
|
||||
}).toString();
|
||||
}
|
||||
exports.hashSecondRound = hashSecondRound;
|
||||
|
||||
function generateRandomSalt() {
|
||||
return CryptoJS.lib.WordArray.random(128 / 8).toString();
|
||||
}
|
||||
exports.generateRandomSalt = generateRandomSalt;
|
||||
|
||||
function getRandomAlphanum() {
|
||||
var possibleCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
var byteArray;
|
||||
var parsedInt;
|
||||
|
||||
// Keep generating new random bytes until we get a value that falls
|
||||
// within a range that can be evenly divided by possibleCharacters.length
|
||||
do {
|
||||
byteArray = CryptoJS.lib.WordArray.random(1);
|
||||
// extract the lowest byte to get an int from 0 to 255 (probably unnecessary, since we're only generating 1 byte)
|
||||
parsedInt = byteArray.words[0] & 0xff;
|
||||
} while (parsedInt >= 256 - (256 % possibleCharacters.length));
|
||||
|
||||
// Take the modulo of the parsed integer to get a random number between 0 and totalLength - 1
|
||||
var randomIndex = parsedInt % possibleCharacters.length;
|
||||
|
||||
return possibleCharacters[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string of a given length.
|
||||
*
|
||||
* @param {int} length
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateRandomString(length) {
|
||||
var randomString = '';
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
randomString += getRandomAlphanum();
|
||||
}
|
||||
|
||||
return randomString;
|
||||
}
|
||||
exports.generateRandomString = generateRandomString;
|
||||
|
||||
function signMessage(hashedPassphrase, message) {
|
||||
return CryptoJS.HmacSHA256(
|
||||
message,
|
||||
|
@ -282,22 +353,31 @@ exports.signMessage = signMessage;
|
|||
* @param cryptoEngine - the engine to use for encryption / decryption
|
||||
*/
|
||||
function init(cryptoEngine) {
|
||||
// TODO: remove on next major version bump. This is a hack to make the salt available in all functions here in a
|
||||
// backward compatible way (not requiring to change the password_template).
|
||||
const backwardCompatibleSalt = '##SALT##';
|
||||
|
||||
const exports = {};
|
||||
|
||||
/**
|
||||
* Top-level function for encoding a message.
|
||||
* Includes passphrase hashing, encryption, and signing.
|
||||
* Includes password hashing, encryption, and signing.
|
||||
*
|
||||
* @param {string} msg
|
||||
* @param {string} passphrase
|
||||
* @param {string} password
|
||||
* @param {string} salt
|
||||
* @param {boolean} isLegacy - whether to use the legacy hashing algorithm (1k iterations) or not
|
||||
*
|
||||
* @returns {string} The encoded text
|
||||
*/
|
||||
function encode(msg, passphrase, salt) {
|
||||
const hashedPassphrase = cryptoEngine.hashPassphrase(passphrase, salt);
|
||||
function encode(msg, password, salt, isLegacy = false) {
|
||||
// TODO: remove in the next major version bump. This is to not break backwards compatibility with the old way of hashing
|
||||
const hashedPassphrase = isLegacy
|
||||
? cryptoEngine.hashLegacyRound(password, salt)
|
||||
: cryptoEngine.hashPassphrase(password, salt);
|
||||
const encrypted = cryptoEngine.encrypt(msg, hashedPassphrase);
|
||||
// we use the hashed passphrase in the HMAC because this is effectively what will be used a passphrase (so we can store
|
||||
// it in localStorage safely, we don't use the clear text passphrase)
|
||||
// we use the hashed password in the HMAC because this is effectively what will be used a password (so we can store
|
||||
// it in localStorage safely, we don't use the clear text password)
|
||||
const hmac = cryptoEngine.signMessage(hashedPassphrase, encrypted);
|
||||
|
||||
return hmac + encrypted;
|
||||
|
@ -306,21 +386,49 @@ function init(cryptoEngine) {
|
|||
|
||||
/**
|
||||
* Top-level function for decoding a message.
|
||||
* Includes signature check, an decryption.
|
||||
* Includes signature check and decryption.
|
||||
*
|
||||
* @param {string} signedMsg
|
||||
* @param {string} hashedPassphrase
|
||||
* @param {string} backwardCompatibleHashedPassword
|
||||
*
|
||||
* @returns {Object} {success: true, decoded: string} | {success: false, message: string}
|
||||
*/
|
||||
function decode(signedMsg, hashedPassphrase) {
|
||||
function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') {
|
||||
const encryptedHMAC = signedMsg.substring(0, 64);
|
||||
const encryptedMsg = signedMsg.substring(64);
|
||||
const decryptedHMAC = cryptoEngine.signMessage(hashedPassphrase, encryptedMsg);
|
||||
|
||||
if (decryptedHMAC !== encryptedHMAC) {
|
||||
// TODO: remove in next major version bump. This is to not break backwards compatibility with the old 1k
|
||||
// iterations in PBKDF2 - if the key we try isn't working, it might be because it's a remember-me/autodecrypt
|
||||
// link key, generated with 1k iterations. Try again with the updated iteration count.
|
||||
if (!backwardCompatibleHashedPassword) {
|
||||
return decode(
|
||||
signedMsg,
|
||||
cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt),
|
||||
hashedPassphrase
|
||||
);
|
||||
}
|
||||
|
||||
return { success: false, message: "Signature mismatch" };
|
||||
}
|
||||
|
||||
// TODO: remove in next major version bump. If we're trying to double hash for backward compatibility reasons,
|
||||
// and the attempt is successful, we check if we should update the stored password in localStorage. This avoids
|
||||
// having to compute the upgrade each time.
|
||||
if (backwardCompatibleHashedPassword) {
|
||||
if (window && window.localStorage) {
|
||||
const storedPassword = window.localStorage.getItem('staticrypt_passphrase');
|
||||
|
||||
// check the stored password is actually the backward compatible one, so we don't save the new one and trigger
|
||||
// the "remember-me" by mistake, leaking the password
|
||||
if (storedPassword === backwardCompatibleHashedPassword) {
|
||||
window.localStorage.setItem('staticrypt_passphrase', hashedPassphrase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
decoded: cryptoEngine.decrypt(encryptedMsg, hashedPassphrase),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "staticrypt",
|
||||
"version": "2.3.5",
|
||||
"version": "2.3.6",
|
||||
"description": "Based on the [crypto-js](https://github.com/brix/crypto-js) library, StatiCrypt uses AES-256 to encrypt your input with your passphrase and put it in a HTML file with a password prompt that can decrypted in-browser (client side).",
|
||||
"main": "index.js",
|
||||
"files": [
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
</h1>
|
||||
<p>
|
||||
Based on the <a href="https://github.com/brix/crypto-js">crypto-js library</a>, StatiCrypt uses AES-256
|
||||
to encrypt your string with your passphrase in your browser (client side).
|
||||
to encrypt your string with your long password in your browser (client side).
|
||||
</p>
|
||||
<p>
|
||||
Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see <a
|
||||
|
@ -62,8 +62,8 @@
|
|||
</h4>
|
||||
<div id="concept" class="hidden">
|
||||
<p>
|
||||
<b class="text-danger">Disclaimer</b> if you have extra sensitive banking data, you should probably
|
||||
use something else!
|
||||
<b class="text-danger">Disclaimer</b> if you are an at-risk activist, or have extra sensitive
|
||||
banking data, you should probably use something else!
|
||||
</p>
|
||||
<p>
|
||||
StatiCrypt generates a static, password protected page that can be decrypted in-browser:
|
||||
|
@ -72,13 +72,14 @@
|
|||
</p>
|
||||
<p>
|
||||
It basically encrypts your page and puts everything with a user-friendly way to use a password
|
||||
in the new file.
|
||||
<br>AES-256 is state of the art but <b>brute-force/dictionary attacks would be trivial to
|
||||
do at a really fast pace: use a long, unusual passphrase!</b>
|
||||
in the new file. AES-256 is state of the art but <b>brute-force/dictionary attacks would be easy to
|
||||
do at a really fast pace: use a long, unusual password!</b>
|
||||
<br/> => To be safe, we recommend 16+ alphanum characters, and using a password manager like the
|
||||
open-source <a href="http://bitwarden.com">Bitwarden</a>.
|
||||
</p>
|
||||
<p>
|
||||
Feel free to contribute or report any thought to the
|
||||
<a href="https://github.com/robinmoisson/staticrypt">GitHub project</a>!
|
||||
<a href="https://github.com/robinmoisson/staticrypt">GitHub project</a>.
|
||||
</p>
|
||||
</div>
|
||||
<br>
|
||||
|
@ -88,9 +89,9 @@
|
|||
<div class="col-xs-12">
|
||||
<form id="encrypt_form">
|
||||
<div class="form-group">
|
||||
<label for="passphrase">Passphrase</label>
|
||||
<label for="passphrase">Password</label>
|
||||
<input type="password" class="form-control" id="passphrase"
|
||||
placeholder="Passphrase (choose a long one!)">
|
||||
placeholder="Password (choose a long one!)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
Ładowanie…
Reference in New Issue