Merge pull request #1059 from matrix-org/rav/delint_megolm_export
de-lint MegolmExportEncryption + test
This commit is contained in:
commit
34e4d8088b
6 changed files with 69 additions and 51 deletions
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
src/AddThreepid.js
|
src/AddThreepid.js
|
||||||
src/async-components/views/dialogs/EncryptedEventDialog.js
|
src/async-components/views/dialogs/EncryptedEventDialog.js
|
||||||
src/async-components/views/dialogs/ExportE2eKeysDialog.js
|
|
||||||
src/async-components/views/dialogs/ImportE2eKeysDialog.js
|
|
||||||
src/autocomplete/AutocompleteProvider.js
|
src/autocomplete/AutocompleteProvider.js
|
||||||
src/autocomplete/Autocompleter.js
|
src/autocomplete/Autocompleter.js
|
||||||
src/autocomplete/Components.js
|
src/autocomplete/Components.js
|
||||||
|
@ -167,7 +165,6 @@ src/UserActivity.js
|
||||||
src/utils/DecryptFile.js
|
src/utils/DecryptFile.js
|
||||||
src/utils/DMRoomMap.js
|
src/utils/DMRoomMap.js
|
||||||
src/utils/FormattingUtils.js
|
src/utils/FormattingUtils.js
|
||||||
src/utils/MegolmExportEncryption.js
|
|
||||||
src/utils/MultiInviter.js
|
src/utils/MultiInviter.js
|
||||||
src/utils/Receipt.js
|
src/utils/Receipt.js
|
||||||
src/Velociraptor.js
|
src/Velociraptor.js
|
||||||
|
@ -188,4 +185,3 @@ test/mock-clock.js
|
||||||
test/skinned-sdk.js
|
test/skinned-sdk.js
|
||||||
test/stores/RoomViewStore-test.js
|
test/stores/RoomViewStore-test.js
|
||||||
test/test-utils.js
|
test/test-utils.js
|
||||||
test/utils/MegolmExportEncryption-test.js
|
|
||||||
|
|
|
@ -120,7 +120,7 @@ export default React.createClass({
|
||||||
'you have received in encrypted rooms to a local file. You ' +
|
'you have received in encrypted rooms to a local file. You ' +
|
||||||
'will then be able to import the file into another Matrix ' +
|
'will then be able to import the file into another Matrix ' +
|
||||||
'client in the future, so that client will also be able to ' +
|
'client in the future, so that client will also be able to ' +
|
||||||
'decrypt these messages.'
|
'decrypt these messages.',
|
||||||
) }
|
) }
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
@ -130,7 +130,7 @@ export default React.createClass({
|
||||||
'careful to keep it secure. To help with this, you should enter ' +
|
'careful to keep it secure. To help with this, you should enter ' +
|
||||||
'a passphrase below, which will be used to encrypt the exported ' +
|
'a passphrase below, which will be used to encrypt the exported ' +
|
||||||
'data. It will only be possible to import the data by using the ' +
|
'data. It will only be possible to import the data by using the ' +
|
||||||
'same passphrase.'
|
'same passphrase.',
|
||||||
) }
|
) }
|
||||||
</p>
|
</p>
|
||||||
<div className='error'>
|
<div className='error'>
|
||||||
|
|
|
@ -122,13 +122,13 @@ export default React.createClass({
|
||||||
'This process allows you to import encryption keys ' +
|
'This process allows you to import encryption keys ' +
|
||||||
'that you had previously exported from another Matrix ' +
|
'that you had previously exported from another Matrix ' +
|
||||||
'client. You will then be able to decrypt any ' +
|
'client. You will then be able to decrypt any ' +
|
||||||
'messages that the other client could decrypt.'
|
'messages that the other client could decrypt.',
|
||||||
) }
|
) }
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{ _t(
|
{ _t(
|
||||||
'The export file will be protected with a passphrase. ' +
|
'The export file will be protected with a passphrase. ' +
|
||||||
'You should enter the passphrase here, to decrypt the file.'
|
'You should enter the passphrase here, to decrypt the file.',
|
||||||
) }
|
) }
|
||||||
</p>
|
</p>
|
||||||
<div className='error'>
|
<div className='error'>
|
||||||
|
|
|
@ -32,7 +32,7 @@ const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle;
|
||||||
/**
|
/**
|
||||||
* Decrypt a megolm key file
|
* Decrypt a megolm key file
|
||||||
*
|
*
|
||||||
* @param {ArrayBuffer} file
|
* @param {ArrayBuffer} data file to decrypt
|
||||||
* @param {String} password
|
* @param {String} password
|
||||||
* @return {Promise<String>} promise for decrypted output
|
* @return {Promise<String>} promise for decrypted output
|
||||||
*/
|
*/
|
||||||
|
@ -61,12 +61,12 @@ export function decryptMegolmKeyFile(data, password) {
|
||||||
const hmac = body.subarray(-32);
|
const hmac = body.subarray(-32);
|
||||||
|
|
||||||
return deriveKeys(salt, iterations, password).then((keys) => {
|
return deriveKeys(salt, iterations, password).then((keys) => {
|
||||||
const [aes_key, hmac_key] = keys;
|
const [aesKey, hmacKey] = keys;
|
||||||
|
|
||||||
const toVerify = body.subarray(0, -32);
|
const toVerify = body.subarray(0, -32);
|
||||||
return subtleCrypto.verify(
|
return subtleCrypto.verify(
|
||||||
{name: 'HMAC'},
|
{name: 'HMAC'},
|
||||||
hmac_key,
|
hmacKey,
|
||||||
hmac,
|
hmac,
|
||||||
toVerify,
|
toVerify,
|
||||||
).then((isValid) => {
|
).then((isValid) => {
|
||||||
|
@ -80,7 +80,7 @@ export function decryptMegolmKeyFile(data, password) {
|
||||||
counter: iv,
|
counter: iv,
|
||||||
length: 64,
|
length: 64,
|
||||||
},
|
},
|
||||||
aes_key,
|
aesKey,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -102,7 +102,7 @@ export function decryptMegolmKeyFile(data, password) {
|
||||||
*/
|
*/
|
||||||
export function encryptMegolmKeyFile(data, password, options) {
|
export function encryptMegolmKeyFile(data, password, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
const kdf_rounds = options.kdf_rounds || 500000;
|
const kdfRounds = options.kdf_rounds || 500000;
|
||||||
|
|
||||||
const salt = new Uint8Array(16);
|
const salt = new Uint8Array(16);
|
||||||
window.crypto.getRandomValues(salt);
|
window.crypto.getRandomValues(salt);
|
||||||
|
@ -115,8 +115,8 @@ export function encryptMegolmKeyFile(data, password, options) {
|
||||||
// of a single bit of iv is a price we have to pay.
|
// of a single bit of iv is a price we have to pay.
|
||||||
iv[9] &= 0x7f;
|
iv[9] &= 0x7f;
|
||||||
|
|
||||||
return deriveKeys(salt, kdf_rounds, password).then((keys) => {
|
return deriveKeys(salt, kdfRounds, password).then((keys) => {
|
||||||
const [aes_key, hmac_key] = keys;
|
const [aesKey, hmacKey] = keys;
|
||||||
|
|
||||||
return subtleCrypto.encrypt(
|
return subtleCrypto.encrypt(
|
||||||
{
|
{
|
||||||
|
@ -124,7 +124,7 @@ export function encryptMegolmKeyFile(data, password, options) {
|
||||||
counter: iv,
|
counter: iv,
|
||||||
length: 64,
|
length: 64,
|
||||||
},
|
},
|
||||||
aes_key,
|
aesKey,
|
||||||
new TextEncoder().encode(data),
|
new TextEncoder().encode(data),
|
||||||
).then((ciphertext) => {
|
).then((ciphertext) => {
|
||||||
const cipherArray = new Uint8Array(ciphertext);
|
const cipherArray = new Uint8Array(ciphertext);
|
||||||
|
@ -134,17 +134,17 @@ export function encryptMegolmKeyFile(data, password, options) {
|
||||||
resultBuffer[idx++] = 1; // version
|
resultBuffer[idx++] = 1; // version
|
||||||
resultBuffer.set(salt, idx); idx += salt.length;
|
resultBuffer.set(salt, idx); idx += salt.length;
|
||||||
resultBuffer.set(iv, idx); idx += iv.length;
|
resultBuffer.set(iv, idx); idx += iv.length;
|
||||||
resultBuffer[idx++] = kdf_rounds >> 24;
|
resultBuffer[idx++] = kdfRounds >> 24;
|
||||||
resultBuffer[idx++] = (kdf_rounds >> 16) & 0xff;
|
resultBuffer[idx++] = (kdfRounds >> 16) & 0xff;
|
||||||
resultBuffer[idx++] = (kdf_rounds >> 8) & 0xff;
|
resultBuffer[idx++] = (kdfRounds >> 8) & 0xff;
|
||||||
resultBuffer[idx++] = kdf_rounds & 0xff;
|
resultBuffer[idx++] = kdfRounds & 0xff;
|
||||||
resultBuffer.set(cipherArray, idx); idx += cipherArray.length;
|
resultBuffer.set(cipherArray, idx); idx += cipherArray.length;
|
||||||
|
|
||||||
const toSign = resultBuffer.subarray(0, idx);
|
const toSign = resultBuffer.subarray(0, idx);
|
||||||
|
|
||||||
return subtleCrypto.sign(
|
return subtleCrypto.sign(
|
||||||
{name: 'HMAC'},
|
{name: 'HMAC'},
|
||||||
hmac_key,
|
hmacKey,
|
||||||
toSign,
|
toSign,
|
||||||
).then((hmac) => {
|
).then((hmac) => {
|
||||||
hmac = new Uint8Array(hmac);
|
hmac = new Uint8Array(hmac);
|
||||||
|
@ -170,7 +170,7 @@ function deriveKeys(salt, iterations, password) {
|
||||||
new TextEncoder().encode(password),
|
new TextEncoder().encode(password),
|
||||||
{name: 'PBKDF2'},
|
{name: 'PBKDF2'},
|
||||||
false,
|
false,
|
||||||
['deriveBits']
|
['deriveBits'],
|
||||||
).then((key) => {
|
).then((key) => {
|
||||||
return subtleCrypto.deriveBits(
|
return subtleCrypto.deriveBits(
|
||||||
{
|
{
|
||||||
|
@ -180,33 +180,33 @@ function deriveKeys(salt, iterations, password) {
|
||||||
hash: 'SHA-512',
|
hash: 'SHA-512',
|
||||||
},
|
},
|
||||||
key,
|
key,
|
||||||
512
|
512,
|
||||||
);
|
);
|
||||||
}).then((keybits) => {
|
}).then((keybits) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
console.log("E2e import/export: deriveKeys took " + (now - start) + "ms");
|
console.log("E2e import/export: deriveKeys took " + (now - start) + "ms");
|
||||||
|
|
||||||
const aes_key = keybits.slice(0, 32);
|
const aesKey = keybits.slice(0, 32);
|
||||||
const hmac_key = keybits.slice(32);
|
const hmacKey = keybits.slice(32);
|
||||||
|
|
||||||
const aes_prom = subtleCrypto.importKey(
|
const aesProm = subtleCrypto.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
aes_key,
|
aesKey,
|
||||||
{name: 'AES-CTR'},
|
{name: 'AES-CTR'},
|
||||||
false,
|
false,
|
||||||
['encrypt', 'decrypt']
|
['encrypt', 'decrypt'],
|
||||||
);
|
);
|
||||||
const hmac_prom = subtleCrypto.importKey(
|
const hmacProm = subtleCrypto.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
hmac_key,
|
hmacKey,
|
||||||
{
|
{
|
||||||
name: 'HMAC',
|
name: 'HMAC',
|
||||||
hash: {name: 'SHA-256'},
|
hash: {name: 'SHA-256'},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
['sign', 'verify']
|
['sign', 'verify'],
|
||||||
);
|
);
|
||||||
return Promise.all([aes_prom, hmac_prom]);
|
return Promise.all([aesProm, hmacProm]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -301,7 +301,7 @@ function packMegolmKeyFile(data) {
|
||||||
function encodeBase64(uint8Array) {
|
function encodeBase64(uint8Array) {
|
||||||
// Misinterpt the Uint8Array as Latin-1.
|
// Misinterpt the Uint8Array as Latin-1.
|
||||||
// window.btoa expects a unicode string with codepoints in the range 0-255.
|
// window.btoa expects a unicode string with codepoints in the range 0-255.
|
||||||
var latin1String = String.fromCharCode.apply(null, uint8Array);
|
const latin1String = String.fromCharCode.apply(null, uint8Array);
|
||||||
// Use the builtin base64 encoder.
|
// Use the builtin base64 encoder.
|
||||||
return window.btoa(latin1String);
|
return window.btoa(latin1String);
|
||||||
}
|
}
|
||||||
|
@ -313,10 +313,10 @@ function encodeBase64(uint8Array) {
|
||||||
*/
|
*/
|
||||||
function decodeBase64(base64) {
|
function decodeBase64(base64) {
|
||||||
// window.atob returns a unicode string with codepoints in the range 0-255.
|
// window.atob returns a unicode string with codepoints in the range 0-255.
|
||||||
var latin1String = window.atob(base64);
|
const latin1String = window.atob(base64);
|
||||||
// Encode the string as a Uint8Array
|
// Encode the string as a Uint8Array
|
||||||
var uint8Array = new Uint8Array(latin1String.length);
|
const uint8Array = new Uint8Array(latin1String.length);
|
||||||
for (var i = 0; i < latin1String.length; i++) {
|
for (let i = 0; i < latin1String.length; i++) {
|
||||||
uint8Array[i] = latin1String.charCodeAt(i);
|
uint8Array[i] = latin1String.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return uint8Array;
|
return uint8Array;
|
||||||
|
|
|
@ -2,4 +2,9 @@ module.exports = {
|
||||||
env: {
|
env: {
|
||||||
mocha: true,
|
mocha: true,
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
// mocha defines a 'this'
|
||||||
|
rules: {
|
||||||
|
"babel/no-invalid-this": "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -25,23 +25,40 @@ const TEST_VECTORS=[
|
||||||
[
|
[
|
||||||
"plain",
|
"plain",
|
||||||
"password",
|
"password",
|
||||||
"-----BEGIN MEGOLM SESSION DATA-----\nAXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx\ncissyYBxjsfsAndErh065A8=\n-----END MEGOLM SESSION DATA-----"
|
"-----BEGIN MEGOLM SESSION DATA-----\n" +
|
||||||
|
"AXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx\n" +
|
||||||
|
"cissyYBxjsfsAndErh065A8=\n" +
|
||||||
|
"-----END MEGOLM SESSION DATA-----",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Hello, World",
|
"Hello, World",
|
||||||
"betterpassword",
|
"betterpassword",
|
||||||
"-----BEGIN MEGOLM SESSION DATA-----\nAW1vcmVzYWx0bW9yZXNhbHT//////////wAAAAAAAAAAAAAD6KyBpe1Niv5M5NPm4ZATsJo5nghk\nKYu63a0YQ5DRhUWEKk7CcMkrKnAUiZny\n-----END MEGOLM SESSION DATA-----"
|
"-----BEGIN MEGOLM SESSION DATA-----\n" +
|
||||||
|
"AW1vcmVzYWx0bW9yZXNhbHT//////////wAAAAAAAAAAAAAD6KyBpe1Niv5M5NPm4ZATsJo5nghk\n" +
|
||||||
|
"KYu63a0YQ5DRhUWEKk7CcMkrKnAUiZny\n" +
|
||||||
|
"-----END MEGOLM SESSION DATA-----",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"alphanumericallyalphanumericallyalphanumericallyalphanumerically",
|
"alphanumericallyalphanumericallyalphanumericallyalphanumerically",
|
||||||
"SWORDFISH",
|
"SWORDFISH",
|
||||||
"-----BEGIN MEGOLM SESSION DATA-----\nAXllc3NhbHR5Z29vZG5lc3P//////////wAAAAAAAAAAAAAD6OIW+Je7gwvjd4kYrb+49gKCfExw\nMgJBMD4mrhLkmgAngwR1pHjbWXaoGybtiAYr0moQ93GrBQsCzPbvl82rZhaXO3iH5uHo/RCEpOqp\nPgg29363BGR+/Ripq/VCLKGNbw==\n-----END MEGOLM SESSION DATA-----"
|
"-----BEGIN MEGOLM SESSION DATA-----\n" +
|
||||||
|
"AXllc3NhbHR5Z29vZG5lc3P//////////wAAAAAAAAAAAAAD6OIW+Je7gwvjd4kYrb+49gKCfExw\n" +
|
||||||
|
"MgJBMD4mrhLkmgAngwR1pHjbWXaoGybtiAYr0moQ93GrBQsCzPbvl82rZhaXO3iH5uHo/RCEpOqp\n" +
|
||||||
|
"Pgg29363BGR+/Ripq/VCLKGNbw==\n" +
|
||||||
|
"-----END MEGOLM SESSION DATA-----",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"alphanumericallyalphanumericallyalphanumericallyalphanumerically",
|
"alphanumericallyalphanumericallyalphanumericallyalphanumerically",
|
||||||
"passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword",
|
"passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword" +
|
||||||
"-----BEGIN MEGOLM SESSION DATA-----\nAf//////////////////////////////////////////AAAD6IAZJy7IQ7Y0idqSw/bmpngEEVVh\ngsH+8ptgqxw6ZVWQnohr8JsuwH9SwGtiebZuBu5smPCO+RFVWH2cQYslZijXv/BEH/txvhUrrtCd\nbWnSXS9oymiqwUIGs08sXI33ZA==\n-----END MEGOLM SESSION DATA-----"
|
"passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword" +
|
||||||
]
|
"passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword" +
|
||||||
|
"passwordpasswordpasswordpasswordpassword",
|
||||||
|
"-----BEGIN MEGOLM SESSION DATA-----\n" +
|
||||||
|
"Af//////////////////////////////////////////AAAD6IAZJy7IQ7Y0idqSw/bmpngEEVVh\n" +
|
||||||
|
"gsH+8ptgqxw6ZVWQnohr8JsuwH9SwGtiebZuBu5smPCO+RFVWH2cQYslZijXv/BEH/txvhUrrtCd\n" +
|
||||||
|
"bWnSXS9oymiqwUIGs08sXI33ZA==\n" +
|
||||||
|
"-----END MEGOLM SESSION DATA-----",
|
||||||
|
],
|
||||||
]
|
]
|
||||||
;
|
;
|
||||||
|
|
||||||
|
@ -55,7 +72,7 @@ describe('MegolmExportEncryption', function() {
|
||||||
if (!window.crypto.subtle && !window.crypto.webkitSubtle) {
|
if (!window.crypto.subtle && !window.crypto.webkitSubtle) {
|
||||||
this.skip();
|
this.skip();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
beforeEach(function() {
|
beforeEach(function() {
|
||||||
testUtils.beforeEach(this);
|
testUtils.beforeEach(this);
|
||||||
|
@ -64,14 +81,14 @@ describe('MegolmExportEncryption', function() {
|
||||||
describe('decrypt', function() {
|
describe('decrypt', function() {
|
||||||
it('should handle missing header', function() {
|
it('should handle missing header', function() {
|
||||||
const input=stringToArray(`-----`);
|
const input=stringToArray(`-----`);
|
||||||
expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')})
|
expect(()=>MegolmExportEncryption.decryptMegolmKeyFile(input, ''))
|
||||||
.toThrow('Header line not found');
|
.toThrow('Header line not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle missing trailer', function() {
|
it('should handle missing trailer', function() {
|
||||||
const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA-----
|
const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA-----
|
||||||
-----`);
|
-----`);
|
||||||
expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')})
|
expect(()=>MegolmExportEncryption.decryptMegolmKeyFile(input, ''))
|
||||||
.toThrow('Trailer line not found');
|
.toThrow('Trailer line not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -81,7 +98,7 @@ AXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx
|
||||||
cissyYBxjsfsAn
|
cissyYBxjsfsAn
|
||||||
-----END MEGOLM SESSION DATA-----
|
-----END MEGOLM SESSION DATA-----
|
||||||
`);
|
`);
|
||||||
expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')})
|
expect(()=>MegolmExportEncryption.decryptMegolmKeyFile(input, ''))
|
||||||
.toThrow('Invalid file: too short');
|
.toThrow('Invalid file: too short');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -94,12 +111,12 @@ cissyYBxjsfsAn
|
||||||
|
|
||||||
const [plain, password, input] = TEST_VECTORS[i];
|
const [plain, password, input] = TEST_VECTORS[i];
|
||||||
return MegolmExportEncryption.decryptMegolmKeyFile(
|
return MegolmExportEncryption.decryptMegolmKeyFile(
|
||||||
stringToArray(input), password
|
stringToArray(input), password,
|
||||||
).then((decrypted) => {
|
).then((decrypted) => {
|
||||||
expect(decrypted).toEqual(plain);
|
expect(decrypted).toEqual(plain);
|
||||||
return next(i+1);
|
return next(i+1);
|
||||||
})
|
});
|
||||||
};
|
}
|
||||||
return next(0).catch(done);
|
return next(0).catch(done);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -115,7 +132,7 @@ cissyYBxjsfsAn
|
||||||
input, password, {kdf_rounds: 1000},
|
input, password, {kdf_rounds: 1000},
|
||||||
).then((ciphertext) => {
|
).then((ciphertext) => {
|
||||||
return MegolmExportEncryption.decryptMegolmKeyFile(
|
return MegolmExportEncryption.decryptMegolmKeyFile(
|
||||||
ciphertext, password
|
ciphertext, password,
|
||||||
);
|
);
|
||||||
}).then((plaintext) => {
|
}).then((plaintext) => {
|
||||||
expect(plaintext).toEqual(input);
|
expect(plaintext).toEqual(input);
|
||||||
|
|
Loading…
Reference in a new issue