diff --git a/README.md b/README.md index 6b1b2f4..e889000 100644 --- a/README.md +++ b/README.md @@ -22,39 +22,41 @@ You can then run it with `npx staticrypt ...`. You can also install globally wit ### Examples -> These will create a `.staticrypt.json` file in the current directory, see the FAQ as to why. You can prevent it by setting the `--config` flag to "false". +> If you're viewing your file over HTTPS or localhost, you should use the `--engine webcrypto` flag to use the WebCrypto engine, which is more secure here. Otherwise the CryptoJS engine will be used. +> +> These examples will create a `.staticrypt.json` file in the current directory, see the FAQ as to why. You can prevent it by setting the `--config` flag to "false". **Encrypt a file:** Encrypt `test.html` and create a `test_encrypted.html` file (add `-o my_encrypted_file.html` to change the name of the output file): ```bash -staticrypt test.html MY_LONG_PASSWORD +staticrypt test.html MY_LONG_PASSWORD --engine webcrypto ``` **Encrypt a file with the password in an environment variable:** set your long password in the `STATICRYPT_PASSWORD` environment variable ([`.env` files](https://www.npmjs.com/package/dotenv#usage) are supported): ```bash # the password is in the STATICRYPT_PASSWORD env variable -staticrypt test.html +staticrypt test.html --engine webcrypto ``` **Encrypt a file and get a shareable link containing the hashed password** - you can include your file URL or leave blank: ```bash # you can also pass '--share' without specifying the URL to get the `?staticrypt_pwd=...` -staticrypt test.html MY_LONG_PASSWORD --share https://example.com/test_encrypted.html +staticrypt test.html MY_LONG_PASSWORD --share https://example.com/test_encrypted.html --engine webcrypto # => https://example.com/test_encrypted.html?staticrypt_pwd=5bfbf1343c7257cd7be23ecd74bb37fa2c76d041042654f358b6255baeab898f ``` **Encrypt all html files in a directory** and replace them with encrypted versions (`{}` will be replaced with each file name by the `find` command - if you wanted to move the encrypted files to an `encrypted/` directory, you could use `-o encrypted/{}`): ```bash -find . -type f -name "*.html" -exec staticrypt {} MY_LONG_PASSWORD -o {} \; +find . -type f -name "*.html" -exec staticrypt {} MY_LONG_PASSWORD -o {} --engine webcrypto \; ``` **Encrypt all html files in a directory except** the ones ending in `_encrypted.html`: ```bash -find . -type f -name "*.html" -not -name "*_encrypted.html" -exec staticrypt {} MY_LONG_PASSWORD \; +find . -type f -name "*.html" -not -name "*_encrypted.html" -exec staticrypt {} MY_LONG_PASSWORD --engine webcrypto \; ``` ### CLI Reference @@ -73,6 +75,10 @@ The password argument is optional if `STATICRYPT_PASSWORD` is set in the environ -e, --embed Whether or not to embed crypto-js in the page (or use an external CDN). [boolean] [default: true] + --engine The crypto engine to use. WebCrypto uses 600k + iterations and is more secure, CryptoJS 15k. + Possible values: 'cryptojs', 'webcrypto'. + [string] [default: "cryptojs"] -f, --file-template Path to custom HTML template with password prompt. [string] [default: "/code/staticrypt/lib/password_template.html"] @@ -113,7 +119,7 @@ The password argument is optional if `STATICRYPT_PASSWORD` is set in the environ So, how can you password protect html without a back-end? -StatiCrypt uses the [crypto-js](https://github.com/brix/crypto-js) library to generate a static, password protected page that can be decrypted in-browser. You can then just send or upload the generated page to a place serving static content (github pages, for example) and you're done: the page will prompt users for a password, and the javascript will decrypt and load your HTML, all done in the browser. +StatiCrypt uses the [crypto-js](https://github.com/brix/crypto-js) library or WebCrypto to generate a static, password protected page that can be decrypted in-browser. You can then just send or upload the generated page to a place serving static content (github pages, for example) and you're done: the page will prompt users for a password, and the javascript will decrypt and load your HTML, all done in the browser. So it basically encrypts your page and puts everything in a user-friendly way to use a password in the new file. @@ -121,11 +127,11 @@ So it basically encrypts your page and puts everything in a user-friendly way to ### Is it secure? -Simple answer: your file content has been encrypted with AES-256 (CBC), a popular and strong encryption algorithm, you can now upload it in any public place and no one will be able to read it without the password. So if you used a long, strong password, then yes it should be pretty secure. +Simple answer: your file content has been encrypted with AES-256, a popular and strong encryption algorithm. You can now upload it in any public place and no one will be able to read it without the password. So if you used a long, strong password, then yes it should be pretty secure. That being said, actual security always depends on a number of factors and on the threat model you want to protect against. Because your full encrypted file is accessible client side, brute-force/dictionary attacks would be easy to do at a really fast pace: **use a long, unusual password**. We recommend 16+ alphanum characters, [Bitwarden](https://bitwarden.com/) is a great open-source password manager if you don't have one already. -On the technical aspects: we use AES in CBC mode (see a discussion on why it's appropriate for StatiCrypt in [#19](https://github.com/robinmoisson/staticrypt/issues/19)) and 15k PBKDF2 iterations (it will be 600k when we'll switch to WebCrypto, read a detailed report on why these numbers in [#159](https://github.com/robinmoisson/staticrypt/issues/159)). +On the technical aspects: we use AES in CBC mode (see a discussion on why it's appropriate for StatiCrypt in [#19](https://github.com/robinmoisson/staticrypt/issues/19)) and 600k PBKDF2 iterations when using the WebCrypto engine (it's 15k when using CryptoJS, read a detailed report on why these numbers in [#159](https://github.com/robinmoisson/staticrypt/issues/159)). **Also, disclaimer:** I am not a cryptographer - the concept is simple and I try my best to implement it correctly but please adjust accordingly: if you are an at-risk activist or have sensitive crypto data to protect, you might want to use something else. @@ -137,7 +143,17 @@ Yes! Just copy `lib/password_template.html`, modify it to suit your style and po If you don't want the checkbox to be included, you can add the `--noremember` flag to disable it. -### Why do we embed the whole crypto-js library in each encrypted file by default? +### Should I use the WebCrypto or CryptoJS engine? + +CryptoJS is the JS library that StatiCrypt used at first to do its crypto operations. WebCrypto is a browser API which exposes crypto methods, without having to rely on an external library. + +WebCrypto is faster, which allows us to do more hashing rounds and make StatiCrypt more robust against brute-force attacks - if you can, **you should use WebCrypto**. The only limitation is it's only available in HTTPS context (which [is annoying people](https://github.com/w3c/webcrypto/issues/28)) or on localhost and on non-ancient browsers, so if you need that you can use `--engine cryptojs` which works everywhere. WebCrypto will be the only available option in our next major version. + +> **Will switching break share links/remember-me?** If you encrypted a file with the CryptoJS engine and shared auto-decrypt links, or activated the remember-me flag, then switch to WebCrypto, the change is backward compatible and the file should still autodecrypt. The reverse isn't true - don't create an auto-decrypt link with WebCrypto then encrypt your file with CryptoJS. +> +> This is because we use more hashing rounds with the faster WebCrypto, making it more secure, but we can't remove hashing rounds to convert back (which is the whole point of a hash). + +### Why do we embed the whole crypto-js library in each encrypted file when using the CryptoJS engine by default? Some adblockers used to see the `crypto-js.min.js` served by CDN, think that's a crypto miner and block it. If you don't want to include it and serve from a CDN instead, you can add `--embed false`. @@ -218,6 +234,4 @@ Here are some other projects and community resources you might find interesting ### Based on StatiCrypt -**WebCrypto:** https://github.com/tarpdalton/staticrypt/tree/webcrypto is a fork that uses the WebCrypto browser api to encrypt and decrypt the page, which removes the need for `crypto-js`. There's a PR open towards here which I haven't checked in detail yet. WebCrypto is only available in HTTPS context (which [is annoying people](https://github.com/w3c/webcrypto/issues/28)) so it won't work if you're on HTTP. - **Template to host an encrypted single page website with Github Pages:** [a-nau/password-protected-website-template](https://github.com/a-nau/password-protected-website-template) is a demonstration of how to build a protected page on Github Pages, integrating with Github Actions diff --git a/cli/helpers.js b/cli/helpers.js index 76e32aa..2ffeb91 100644 --- a/cli/helpers.js +++ b/cli/helpers.js @@ -1,10 +1,9 @@ const fs = require("fs"); -const cryptoEngine = require("../lib/cryptoEngine/cryptojsEngine"); +const { generateRandomSalt } = require("../lib/cryptoEngine/webcryptoEngine.js"); const path = require("path"); const {renderTemplate} = require("../lib/formater.js"); const Yargs = require("yargs"); -const { generateRandomSalt } = cryptoEngine; const PASSWORD_TEMPLATE_DEFAULT_PATH = path.join(__dirname, "..", "lib", "password_template.html"); @@ -12,11 +11,11 @@ const PASSWORD_TEMPLATE_DEFAULT_PATH = path.join(__dirname, "..", "lib", "passwo /** * @param {string} message */ -function exitEarly(message) { - console.log(message); +function exitWithError(message) { + console.log("ERROR: " + message); process.exit(1); } -exports.exitEarly = exitEarly; +exports.exitWithError = exitWithError; /** * Check if a particular option has been set by the user. Useful for distinguishing default value with flag without @@ -66,7 +65,7 @@ function getPassword(positionalArguments) { } if (positionalArguments.length < 2) { - exitEarly("Missing password: please provide an argument or set the STATICRYPT_PASSWORD environment variable in the environment or .env file"); + exitWithError("missing password, please provide an argument or set the STATICRYPT_PASSWORD environment variable in the environment or .env file"); } return positionalArguments[1].toString(); @@ -81,7 +80,7 @@ function getFileContent(filepath) { try { return fs.readFileSync(filepath, "utf8"); } catch (e) { - exitEarly("Failure: input file does not exist!"); + exitWithError("input file does not exist!"); } } exports.getFileContent = getFileContent; @@ -119,7 +118,7 @@ function convertCommonJSToBrowserJS(modulePath) { const resolvedPath = path.join(rootDirectory, ...modulePath.split("/")) + ".js"; if (!fs.existsSync(resolvedPath)) { - exitEarly(`Failure: could not find module to convert at path "${resolvedPath}"`); + exitWithError(`could not find module to convert at path "${resolvedPath}"`); } const moduleText = fs @@ -145,7 +144,7 @@ function readFile(filePath, errorName = file) { try { return fs.readFileSync(filePath, "utf8"); } catch (e) { - exitEarly(`Failure: could not read ${errorName}!`); + exitWithError(`could not read ${errorName}!`); } } @@ -164,7 +163,7 @@ function genFile(data, outputFilePath, templateFilePath) { try { fs.writeFileSync(outputFilePath, renderedTemplate); } catch (e) { - exitEarly("Failure: could not generate output file!"); + exitWithError("could not generate output file!"); } } exports.genFile = genFile; @@ -179,22 +178,39 @@ exports.genFile = genFile; * @returns {boolean} */ function isCustomPasswordTemplateLegacy(templatePathParameter) { - // if the user uses the default template, it's up to date - if (templatePathParameter === PASSWORD_TEMPLATE_DEFAULT_PATH) { - return false; - } - const customTemplateContent = readFile(templatePathParameter, "template"); // if the template injects the crypto engine, it's up to date - if (customTemplateContent.includes("js_crypto_engine")) { - return false; - } - - return true; + return !customTemplateContent.includes("js_crypto_engine"); } exports.isCustomPasswordTemplateLegacy = isCustomPasswordTemplateLegacy; +/** + * TODO: remove in next major version + * + * This method checks whether the password template support the async logic. + * + * @param {string} templatePathParameter + * @returns {boolean} + */ +function isPasswordTemplateUsingAsync(templatePathParameter) { + const customTemplateContent = readFile(templatePathParameter, "template"); + + // if the template includes this comment, it's up to date + return customTemplateContent.includes("// STATICRYPT_VERSION: async"); +} +exports.isPasswordTemplateUsingAsync = isPasswordTemplateUsingAsync; + +/** + * @param {string} templatePathParameter + * @returns {boolean} + */ +function isCustomPasswordTemplateDefault(templatePathParameter) { + // if the user uses the default template, it's up to date + return templatePathParameter === PASSWORD_TEMPLATE_DEFAULT_PATH; +} +exports.isCustomPasswordTemplateDefault = isCustomPasswordTemplateDefault; + function parseCommandLineArguments() { return Yargs.usage("Usage: staticrypt [] [options]") .option("c", { @@ -211,10 +227,15 @@ function parseCommandLineArguments() { .option("e", { alias: "embed", type: "boolean", - describe: - "Whether or not to embed crypto-js in the page (or use an external CDN).", + describe: "Whether or not to embed crypto-js in the page (or use an external CDN).", default: true, }) + .option("engine", { + type: "string", + describe: "The crypto engine to use. WebCrypto uses 600k iterations and is more secure, CryptoJS 15k.\n" + + "Possible values: 'cryptojs', 'webcrypto'.", + default: "cryptojs", + }) .option("f", { alias: "file-template", type: "string", diff --git a/cli/index.js b/cli/index.js index 485c789..a11a7fb 100755 --- a/cli/index.js +++ b/cli/index.js @@ -4,146 +4,181 @@ const fs = require("fs"); const path = require("path"); -const Yargs = require("yargs"); // parse .env file into process.env require('dotenv').config(); -const cryptoEngine = require("../lib/cryptoEngine/cryptojsEngine"); -const codec = require("../lib/codec"); -const { convertCommonJSToBrowserJS, exitEarly, isOptionSetByUser, genFile, getPassword, getFileContent, getSalt} = require("./helpers"); -const { isCustomPasswordTemplateLegacy, parseCommandLineArguments} = require("./helpers.js"); -const { generateRandomSalt, generateRandomString } = cryptoEngine; -const { encode } = codec.init(cryptoEngine); +const cryptojsEngine = require("../lib/cryptoEngine/cryptojsEngine"); +const webcryptoEngine = require("../lib/cryptoEngine/webcryptoEngine"); +const codec = require("../lib/codec.js"); +const { convertCommonJSToBrowserJS, exitWithError, isOptionSetByUser, genFile, getPassword, getFileContent, getSalt} = require("./helpers"); +const { isCustomPasswordTemplateLegacy, parseCommandLineArguments, isPasswordTemplateUsingAsync} = require("./helpers.js"); -const SCRIPT_URL = - "https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.min.js"; -const SCRIPT_TAG = - ''; +const CRYPTOJS_SCRIPT_TAG = + ''; // parse arguments const yargs = parseCommandLineArguments(); const namedArgs = yargs.argv; -// if the 's' flag is passed without parameter, generate a salt, display & exit -if (isOptionSetByUser("s", yargs) && !namedArgs.salt) { - console.log(generateRandomSalt()); - process.exit(0); -} - -// validate the number of arguments -const positionalArguments = namedArgs._; -if (positionalArguments.length > 2 || positionalArguments.length === 0) { - yargs.showHelp(); - process.exit(1); -} - -// parse input -const inputFilepath = positionalArguments[0].toString(), - password = getPassword(positionalArguments); - -if (password.length < 16 && !namedArgs.short) { - console.log( - `WARNING: Your password is less than 16 characters (length: ${password.length}). Brute-force attacks are easy to ` - + `try on public files, and you are most safe when using a long password.\n\n` - + `👉️ Here's a strong generated password you could use: ` - + generateRandomString(21) - + "\n\nThe file was encrypted with your password. You can hide this warning by increasing your password length or" - + " adding the '--short' flag." - ) -} - -// get config file -const isUsingconfigFile = namedArgs.config.toLowerCase() !== "false"; -const configPath = "./" + namedArgs.config; -let config = {}; -if (isUsingconfigFile && fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, "utf8")); -} - -// get the salt -const salt = getSalt(namedArgs, config); - -// validate the salt -if (salt.length !== 32 || /[^a-f0-9]/.test(salt)) { - exitEarly( - "The salt should be a 32 character long hexadecimal string (only [0-9a-f] characters allowed)" - + "\nDetected salt: " + salt - ); -} - -// write salt to config file -if (isUsingconfigFile && config.salt !== salt) { - config.salt = salt; - fs.writeFileSync(configPath, JSON.stringify(config, null, 4)); -} - -// display the share link with the hashed password if the --share flag is set -if (isOptionSetByUser("share", yargs)) { - const url = namedArgs.share || ""; - const hashedPassphrase = cryptoEngine.hashPassphrase(password, salt); - - console.log(url + "?staticrypt_pwd=" + hashedPassphrase); -} - -// get the file content -const contents = getFileContent(inputFilepath); - -// TODO: remove in the next major version bump. This is to allow a security update to some versions without breaking -// older ones. If the password template is custom AND created before 2.2.0 we need to use the old hashing algorithm. -const isLegacy = isCustomPasswordTemplateLegacy(namedArgs.f); - -if (isLegacy) { - console.log( - "#################################\n\n" + - "SECURITY WARNING [StatiCrypt]: You are using an old version of the password template, which has been found to " + - "be less secure. Please update your custom password_template logic to match version 2.2.0 or higher." + - "\nYou can find instructions here: https://github.com/robinmoisson/staticrypt/issues/161" + - "\n\n#################################" - ); -} +// set the crypto engine +const isWebcrypto = namedArgs.engine === "webcrypto"; +const cryptoEngine = isWebcrypto ? webcryptoEngine : cryptojsEngine; +const { generateRandomSalt, generateRandomString } = cryptoEngine; +const { encode } = codec.init(cryptoEngine); -// encrypt input -const encryptedMessage = encode(contents, password, salt, isLegacy); - -// create crypto-js tag (embedded or not) -let cryptoTag = SCRIPT_TAG; -if (namedArgs.embed) { - try { - const embedContents = fs.readFileSync( - path.join(__dirname, "..", "lib", "kryptojs-3.1.9-1.min.js"), - "utf8" - ); - - cryptoTag = ""; - } catch (e) { - exitEarly("Failure: embed file does not exist!"); - } +async function runStatiCrypt() { + // if the 's' flag is passed without parameter, generate a salt, display & exit + if (isOptionSetByUser("s", yargs) && !namedArgs.salt) { + console.log(generateRandomSalt()); + process.exit(0); + } + + // validate the number of arguments + const positionalArguments = namedArgs._; + if (positionalArguments.length > 2 || positionalArguments.length === 0) { + yargs.showHelp(); + process.exit(1); + } + + // parse input + const inputFilepath = positionalArguments[0].toString(), + password = getPassword(positionalArguments); + + if (password.length < 16 && !namedArgs.short) { + console.log( + `WARNING: Your password is less than 16 characters (length: ${password.length}). Brute-force attacks are easy to ` + + `try on public files, and you are most safe when using a long password.\n\n` + + `👉️ Here's a strong generated password you could use: ` + + generateRandomString(21) + + "\n\nThe file was encrypted with your password. You can hide this warning by increasing your password length or" + + " adding the '--short' flag." + ) + } + + // get config file + const isUsingconfigFile = namedArgs.config.toLowerCase() !== "false"; + const configPath = "./" + namedArgs.config; + let config = {}; + if (isUsingconfigFile && fs.existsSync(configPath)) { + config = JSON.parse(fs.readFileSync(configPath, "utf8")); + } + + // get the salt + const salt = getSalt(namedArgs, config); + + // validate the salt + if (salt.length !== 32 || /[^a-f0-9]/.test(salt)) { + exitWithError( + "the salt should be a 32 character long hexadecimal string (only [0-9a-f] characters allowed)" + + "\nDetected salt: " + salt + ); + } + + // write salt to config file + if (isUsingconfigFile && config.salt !== salt) { + config.salt = salt; + fs.writeFileSync(configPath, JSON.stringify(config, null, 4)); + } + + // display the share link with the hashed password if the --share flag is set + if (isOptionSetByUser("share", yargs)) { + const url = namedArgs.share || ""; + + const hashedPassword = await cryptoEngine.hashPassphrase(password, salt); + + console.log(url + "?staticrypt_pwd=" + hashedPassword); + } + + // TODO: remove in the next major version bump. This is to allow a security update to some versions without breaking + // older ones. If the password template is custom AND created before 2.2.0 we need to use the old hashing algorithm. + const isLegacy = isCustomPasswordTemplateLegacy(namedArgs.f); + + if (isLegacy) { + console.log( + "#################################\n\n" + + "SECURITY WARNING [StatiCrypt]: You are using an old version of the password template, which has been found to " + + "be less secure. Please update your custom password_template logic to match the latest version." + + "\nYou can find instructions here: https://github.com/robinmoisson/staticrypt/issues/161" + + "\n\n#################################" + ); + } + + if (!isWebcrypto) { + console.log( + "WARNING: If you are viewing the file over HTTPS or locally, we recommend " + + (isPasswordTemplateUsingAsync(namedArgs.f) ? "" : "updating your password template to the latest version and ") + + "using the '--engine webcrypto' more secure engine. It will become the default in StatiCrypt next major version." + ); + } else if (!isPasswordTemplateUsingAsync(namedArgs.f) && isWebcrypto) { + exitWithError( + "The '--engine webcrypto' engine is only available for password templates that use async/await. Please " + + "update your password template to the latest version or use the '--engine cryptojs' engine." + ) + } + + // create crypto-js tag (embedded or not) + let cryptoTag = CRYPTOJS_SCRIPT_TAG; + if (isWebcrypto) { + cryptoTag = ""; + } else if (namedArgs.embed) { + try { + const embedContents = fs.readFileSync( + path.join(__dirname, "..", "lib", "kryptojs-3.1.9-1.min.js"), + "utf8" + ); + + cryptoTag = ""; + } catch (e) { + exitWithError("Embed file does not exist."); + } + } + + const cryptoEngineString = isWebcrypto + ? convertCommonJSToBrowserJS("lib/cryptoEngine/webcryptoEngine") + : convertCommonJSToBrowserJS("lib/cryptoEngine/cryptojsEngine"); + + // get the file content + const contents = getFileContent(inputFilepath); + + // encrypt input + encode(contents, password, salt, isLegacy).then((encryptedMessage) => { + let codecString; + if (isWebcrypto) { + codecString = convertCommonJSToBrowserJS("lib/codec"); + } else { + // TODO: remove on next major version bump. The replace is a hack to pass the salt to the injected js_codec in + // a backward compatible way (not requiring to update the password_template). Same for using a "sync" version + // of the codec. + codecString = convertCommonJSToBrowserJS("lib/codec-sync").replace('##SALT##', salt); + } + + const data = { + crypto_tag: cryptoTag, + decrypt_button: namedArgs.decryptButton, + // TODO: deprecated option here for backward compat, remove on next major version bump + embed: isWebcrypto ? false : namedArgs.embed, + encrypted: encryptedMessage, + instructions: namedArgs.instructions, + is_remember_enabled: namedArgs.noremember ? "false" : "true", + js_codec: codecString, + js_crypto_engine: cryptoEngineString, + label_error: namedArgs.labelError, + passphrase_placeholder: namedArgs.passphrasePlaceholder, + remember_duration_in_days: namedArgs.remember, + remember_me: namedArgs.rememberLabel, + salt: salt, + title: namedArgs.title, + }; + + const outputFilepath = namedArgs.output !== null + ? namedArgs.output + : inputFilepath.replace(/\.html$/, "") + "_encrypted.html"; + + genFile(data, outputFilepath, namedArgs.f); + + }); } -const data = { - crypto_tag: cryptoTag, - decrypt_button: namedArgs.decryptButton, - embed: namedArgs.embed, - encrypted: encryptedMessage, - instructions: namedArgs.instructions, - is_remember_enabled: namedArgs.noremember ? "false" : "true", - // TODO: remove on next major version bump. This is a hack to pass the salt to the injected js_codec in a backward - // compatible way (not requiring to update the password_template). - js_codec: convertCommonJSToBrowserJS("lib/codec").replace('##SALT##', salt), - js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine/cryptojsEngine"), - label_error: namedArgs.labelError, - passphrase_placeholder: namedArgs.passphrasePlaceholder, - remember_duration_in_days: namedArgs.remember, - remember_me: namedArgs.rememberLabel, - salt: salt, - title: namedArgs.title, -}; - -const outputFilepath = namedArgs.output !== null - ? namedArgs.output - : inputFilepath.replace(/\.html$/, "") + "_encrypted.html"; - -genFile(data, outputFilepath, namedArgs.f); +runStatiCrypt(); diff --git a/example/example_encrypted.html b/example/example_encrypted.html index cff47ee..fc76a9d 100644 --- a/example/example_encrypted.html +++ b/example/example_encrypted.html @@ -184,48 +184,138 @@ - +