commit dd74209ed1f24aaf5e2236239b61e56a7597ac4a Author: sergeych Date: Wed Aug 19 14:07:16 2026 +0400 Initial Node.js library and CLI diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..745206b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.tgz +.DS_Store +coverage/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bdb330a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sergeych + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2e0ad9 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Crypstie + +Create and open end-to-end encrypted Crypstie links from Node.js or a shell. +The plaintext and the 256-bit key never reach the Crypstie server: it stores +only an authenticated ciphertext, while the key stays in the URL fragment. + +Compatible with links created by the original Kotlin/JS client at +`crypstie.com`. No Kotlin runtime is required. + +## Install + +```shell +npm install crypstie +``` + +For global CLI use: + +```shell +npm install --global crypstie +``` + +Requires Node.js 18 or newer. + +## CLI + +Create a reusable link from stdin or a file: + +```shell +printf 'secret text' | crypstie create +crypstie create ./document.txt --days 7 +``` + +Create a burn-on-read link: + +```shell +crypstie create ./secret.txt --burn +``` + +Open a link. Quote it so the shell does not interpret `#`: + +```shell +crypstie open 'https://crypstie.com/_id#key' +crypstie open "$CRYPSTIE_URL" --output ./document.bin +crypstie open "$CRYPSTIE_URL" --json +``` + +`put`/`encrypt` and `get`/`decrypt` are aliases for `create` and `open`. + +## Node.js API + +```js +const { createCrypstie, openCrypstie } = require('crypstie'); + +const created = await createCrypstie('secret text', { + burnOnShow: false, + deleteAt: new Date(Date.now() + 7 * 86400_000), +}); +console.log(created.url); + +const opened = await openCrypstie(created.url); +console.log(opened.text); // UTF-8 convenience view +console.log(opened.data); // Uint8Array with exact bytes +``` + +Both functions accept an optional `fetch` implementation and `AbortSignal`. +`createCrypstie` also accepts `rootUrl`, UI metadata fields, and binary input. +`openCrypstie` accepts optional `profileIds` for compatibility with owner-aware +legacy links. + +## Security model + +- Encryption is local AES-256-CTR with Encrypt-then-Authenticate and + SHA-256-based HMAC, implemented by `unicrypto`. +- The encrypted record uses the BOSS wire format. +- The URL fragment is removed locally and is never included in the HTTP request. +- Anyone possessing the complete URL can decrypt the content. Treat the URL as + a secret and avoid placing it in logs. +- `--burn` is enforced by the server. Concurrent first reads are subject to the + guarantees of the deployed server implementation. + +## AI agents + +The npm package includes a compact skill at `skill/crypstie/SKILL.md`. Point an +agent's skill loader at that directory, or copy the `skill/crypstie` folder into +its skills directory. The skill uses the CLI and keeps secret links out of its +written responses whenever possible. + +## Development + +```shell +npm ci +npm test +npm pack --dry-run +``` + +Live read compatibility test: + +```shell +CRYPSTIE_TEST_URL='https://crypstie.com/_id#key' npm test +``` + +Live create/read test (writes one expiring test record): + +```shell +CRYPSTIE_LIVE_WRITE=1 npm test +``` + +Russian documentation: [docs/README.ru.md](docs/README.ru.md). diff --git a/docs/README.ru.md b/docs/README.ru.md new file mode 100644 index 0000000..bd53a68 --- /dev/null +++ b/docs/README.ru.md @@ -0,0 +1,64 @@ +# Crypstie для Node.js + +Пакет создаёт и открывает зашифрованные ссылки Crypstie без браузера и Kotlin. +Он совместим со старыми ссылками `crypstie.com`. + +## Установка + +```shell +npm install --global crypstie +``` + +Требуется Node.js 18 или новее. + +## Командная строка + +Создать многоразовую ссылку: + +```shell +printf 'секретный текст' | crypstie create +crypstie create ./document.txt --days 7 +``` + +Создать ссылку, удаляемую после чтения: + +```shell +crypstie create ./secret.txt --burn +``` + +Прочитать крипстю: + +```shell +crypstie open 'https://crypstie.com/_id#key' +crypstie open "$CRYPSTIE_URL" --output ./document.bin +crypstie open "$CRYPSTIE_URL" --json +``` + +Ссылку следует заключать в кавычки: иначе shell воспримет `#` как начало +комментария. Полная ссылка является секретом — любой получивший её сможет +расшифровать содержимое. + +## Программный API + +```js +const { createCrypstie, openCrypstie } = require('crypstie'); + +const created = await createCrypstie('секрет', { + deleteAt: new Date(Date.now() + 7 * 86400_000), + burnOnShow: false, +}); + +const opened = await openCrypstie(created.url); +console.log(opened.text); +``` + +Точные исходные байты находятся в `opened.data`, а `opened.text` является их +представлением в UTF-8. + +## Модель безопасности + +- Случайный 256-битный ключ создаётся локально. +- `unicrypto` использует AES-256-CTR и Encrypt-then-Authenticate с HMAC/SHA-256. +- Сервер получает только BOSS-контейнер с зашифрованными данными. +- Ключ находится после `#` и не передаётся в HTTP-запросе. +- Не помещайте полные ссылки в публичные логи, задачи и сообщения. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f4d7451 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,485 @@ +{ + "name": "crypstie", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crypstie", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "unicrypto": "^1.14.1" + }, + "bin": { + "crypstie": "src/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fastestsmallesttextencoderdecoder": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", + "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", + "license": "CC0-1.0" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gently-copy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/gently-copy/-/gently-copy-3.2.0.tgz", + "integrity": "sha512-IBLU4rCffg0Dvq3/7KyiPionCCdEdKnyfe94c00C8+VbgzIS2J9L2jHdLchG9sn8lDqBGzbvfuYVZB/ZlffS7g==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "shelljs": "^0.8.3" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unicrypto": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/unicrypto/-/unicrypto-1.14.1.tgz", + "integrity": "sha512-5hS1hDQ6QYIysEyTYuy73C3c5h8kmPTkBp4mJ3nPrI5tv7VZOIMWb0heUTc/EQ2x0VAq7WmhKEwXNKl6pOIBsg==", + "hasInstallScript": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "dependencies": { + "buffer": "^5.4.2", + "diffie-hellman": "^5.0.3", + "fastestsmallesttextencoderdecoder": "^1.0.14", + "gently-copy": "^3.2.0", + "jsbn": "^1.1.0", + "randombytes": "^2.1.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0a15fd --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "crypstie", + "version": "0.1.0", + "description": "Create and open end-to-end encrypted Crypstie links from Node.js and the command line", + "author": "sergeych", + "type": "commonjs", + "main": "src/index.js", + "exports": "./src/index.js", + "bin": { + "crypstie": "./src/cli.js" + }, + "files": [ + "src/", + "skill/", + "docs/", + "README.md", + "LICENSE" + ], + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "unicrypto": "^1.14.1" + }, + "keywords": [ + "encryption", + "pastebin", + "cli", + "boss", + "unicrypto" + ], + "license": "MIT" +} diff --git a/scripts/create-live-fixture.js b/scripts/create-live-fixture.js new file mode 100644 index 0000000..b61faeb --- /dev/null +++ b/scripts/create-live-fixture.js @@ -0,0 +1,45 @@ +'use strict'; + +const { Boss, SymmetricKey } = require('unicrypto'); +const { initializeCrypto, openCrypstie } = require('../src'); + +async function main() { + await initializeCrypto(); + const key = new SymmetricKey(); + const text = `Crypstie Node compatibility fixture ${new Date().toISOString()}`; + const record = { + guid: '', + encryptedData: key.etaEncryptSync(new TextEncoder().encode(text)), + deleteAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + burnOnShow: false, + showWide: false, + syntaxHighlighting: null, + editable: false, + ownerHandle: null, + }; + + const form = new FormData(); + form.append('data', new Blob([Boss.dump(record)]), 'crypstie.boss'); + const response = await fetch('https://crypstie.com/api/crypstie', { + method: 'POST', + body: form, + }); + if (!response.ok) throw new Error(`create failed: HTTP ${response.status}`); + + const id = await response.text(); + const encodedKey = Buffer.from(key.pack()) + .toString('base64') + .replaceAll('+', '.') + .replaceAll('/', '_') + .replace(/=+$/, ''); + const url = `https://crypstie.com/_${id}#${encodedKey}`; + const opened = await openCrypstie(url); + if (opened.text !== text) throw new Error('live round trip returned different plaintext'); + console.log(url); + console.log(opened.text); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/skill/crypstie/SKILL.md b/skill/crypstie/SKILL.md new file mode 100644 index 0000000..4fa82c0 --- /dev/null +++ b/skill/crypstie/SKILL.md @@ -0,0 +1,51 @@ +--- +name: crypstie +description: Create and open end-to-end encrypted Crypstie links with the crypstie npm CLI. Use when an agent must securely exchange text or files through crypstie.com, decrypt an existing Crypstie URL without a browser, create reusable or burn-on-read links, or automate Crypstie from a shell. +--- + +# Crypstie + +Use the `crypstie` CLI. Install it with `npm install --global crypstie` if the +command is unavailable. + +## Open a link + +Quote URLs because they contain `#`: + +```shell +crypstie open "$CRYPSTIE_URL" +``` + +Write binary content directly to a file: + +```shell +crypstie open "$CRYPSTIE_URL" --output ./result.bin +``` + +Use `--json` only when metadata or Base64 data is required. + +## Create a link + +Pipe text without making a temporary plaintext file: + +```shell +printf '%s' "$CONTENT" | crypstie create +``` + +Create from a file and set lifetime: + +```shell +crypstie create ./input.bin --days 7 +``` + +Add `--burn` only when the user requests deletion after reading. + +## Handle secrets + +- Treat the complete URL as a secret: anyone holding it can decrypt the data. +- Never omit the fragment after `#`; it contains the local decryption key. +- Avoid repeating a full URL in explanations, logs, issues, or source files. +- Return a newly created URL only to the requesting user. +- Prefer environment variables when opening links in automation. +- Remember that Crypstie protects stored content, not endpoints where plaintext + is read or written. diff --git a/skill/crypstie/agents/openai.yaml b/skill/crypstie/agents/openai.yaml new file mode 100644 index 0000000..79a1e14 --- /dev/null +++ b/skill/crypstie/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Crypstie" + short_description: "Create and open encrypted Crypstie links" + default_prompt: "Use $crypstie to securely create or open a Crypstie link." diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..3836f41 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs/promises'); +const process = require('node:process'); +const { createCrypstie, openCrypstie } = require('./index'); +const pkg = require('../package.json'); + +const HELP = `crypstie ${pkg.version} + +Create and open end-to-end encrypted Crypstie links. + +Usage: + crypstie create [file|-] [options] + crypstie open [options] + +Aliases: + put, encrypt create + get, decrypt open + +Create options: + --burn Delete after the first non-owner read + --days Lifetime in days (default: 90) + --expires Exact expiration time + --base-url Service URL (default: https://crypstie.com) + +Open options: + -o, --output Write plaintext bytes to a file (default: stdout) + --json Print metadata and Base64 data as JSON + +General: + -h, --help Show help + -v, --version Show version + +Examples: + printf 'secret' | crypstie create + crypstie create ./document.txt --days 7 + crypstie open 'https://crypstie.com/_id#key' + crypstie open "$CRYPSTIE_URL" -o ./document.bin +`; + +async function main(argv = process.argv.slice(2)) { + if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) { + process.stdout.write(HELP); + return; + } + if (argv.includes('--version') || argv.includes('-v')) { + process.stdout.write(`${pkg.version}\n`); + return; + } + + const command = argv.shift(); + if (['create', 'put', 'encrypt'].includes(command)) return createCommand(argv); + if (['open', 'get', 'decrypt'].includes(command)) return openCommand(argv); + throw new Error(`Unknown command: ${command}. Run crypstie --help.`); +} + +async function createCommand(argv) { + const args = parseOptions(argv, new Set(['burn']), new Set(['days', 'expires', 'base-url'])); + if (args.positionals.length > 1) throw new Error('create accepts at most one input file'); + if (args.values.days && args.values.expires) throw new Error('use either --days or --expires'); + + const days = args.values.days === undefined ? 90 : Number(args.values.days); + if (!Number.isFinite(days) || days <= 0) throw new Error('--days must be a positive number'); + const deleteAt = args.values.expires + ? new Date(args.values.expires) + : new Date(Date.now() + days * 24 * 60 * 60 * 1000); + const data = await readInput(args.positionals[0]); + const result = await createCrypstie(data, { + burnOnShow: args.flags.has('burn'), + deleteAt, + rootUrl: args.values['base-url'], + }); + process.stdout.write(`${result.url}\n`); +} + +async function openCommand(argv) { + const args = parseOptions(argv, new Set(['json']), new Set(['output', 'o'])); + if (args.positionals.length !== 1) throw new Error('open requires exactly one Crypstie URL'); + const result = await openCrypstie(args.positionals[0]); + const output = args.values.output ?? args.values.o; + + if (args.flags.has('json')) { + const json = { + ...result, + data: Buffer.from(result.data).toString('base64'), + deleteAt: result.deleteAt?.toISOString() ?? null, + }; + delete json.text; + await writeOutput(`${JSON.stringify(json, null, 2)}\n`, output); + } else { + await writeOutput(Buffer.from(result.data), output); + } +} + +function parseOptions(argv, booleanNames, valueNames) { + const flags = new Set(); + const values = {}; + const positionals = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') { + positionals.push(...argv.slice(index + 1)); + break; + } + if (!argument.startsWith('-') || argument === '-') { + positionals.push(argument); + continue; + } + const name = argument.replace(/^--?/, ''); + if (booleanNames.has(name)) flags.add(name); + else if (valueNames.has(name)) { + const value = argv[++index]; + if (value === undefined) throw new Error(`${argument} requires a value`); + values[name] = value; + } else throw new Error(`Unknown option: ${argument}`); + } + return { flags, values, positionals }; +} + +async function readInput(file) { + if (file && file !== '-') return fs.readFile(file); + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function writeOutput(data, file) { + if (file && file !== '-') await fs.writeFile(file, data); + else process.stdout.write(data); +} + +main().catch((error) => { + process.stderr.write(`crypstie: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..c0ca613 --- /dev/null +++ b/src/index.js @@ -0,0 +1,209 @@ +'use strict'; + +const unicrypto = require('unicrypto'); +const { Boss, SymmetricKey } = unicrypto; + +const DEFAULT_ROOT_URL = 'https://crypstie.com'; +const DEFAULT_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000; + +class CrypstieError extends Error { + constructor(message, options = {}) { + super(message, options); + this.name = 'CrypstieError'; + this.status = options.status; + } +} + +let cryptoReady; +function initializeCrypto() { + if (cryptoReady) return cryptoReady; + + // unicrypto's Emscripten loader predates Node's global fetch. Node 18+ + // otherwise tries to fetch an absolute filesystem path as an HTTP URL. + const instantiateStreaming = WebAssembly.instantiateStreaming; + try { + WebAssembly.instantiateStreaming = undefined; + cryptoReady = unicrypto.unicryptoReady; + } finally { + WebAssembly.instantiateStreaming = instantiateStreaming; + } + return cryptoReady; +} + +function decodeCrypstieKey(encoded) { + if (typeof encoded !== 'string' || encoded.length === 0) { + throw new TypeError('Crypstie link has no decryption key'); + } + if (!/^[A-Za-z0-9._-]+={0,2}$/.test(encoded)) { + throw new TypeError('Crypstie link contains an invalid decryption key'); + } + + const base64 = encoded.replaceAll('.', '+').replaceAll('_', '/'); + const key = Buffer.from(base64, 'base64'); + if (key.length !== 32) { + throw new TypeError(`Invalid Crypstie key length: expected 32 bytes, got ${key.length}`); + } + return new Uint8Array(key); +} + +function encodeCrypstieKey(key) { + const bytes = toUint8Array(key); + if (bytes.length !== 32) { + throw new TypeError(`Invalid Crypstie key length: expected 32 bytes, got ${bytes.length}`); + } + return Buffer.from(bytes) + .toString('base64') + .replaceAll('+', '.') + .replaceAll('/', '_') + .replace(/=+$/, ''); +} + +function parseCrypstieUrl(value) { + let url; + try { + url = value instanceof URL ? new URL(value.href) : new URL(value); + } catch (error) { + throw new TypeError(`Invalid Crypstie URL: ${error.message}`); + } + + const match = /^\/_([^/]+)$/.exec(url.pathname); + if (!match) throw new TypeError('Invalid Crypstie URL path: expected /_'); + + return { url, id: match[1], key: decodeCrypstieKey(url.hash.slice(1)) }; +} + +async function createCrypstie(data, options = {}) { + await initializeCrypto(); + const source = typeof data === 'string' ? new TextEncoder().encode(data) : toUint8Array(data); + const key = options.key + ? new SymmetricKey({ keyBytes: toUint8Array(options.key) }) + : new SymmetricKey(); + const deleteAt = normalizeDate(options.deleteAt ?? new Date(Date.now() + DEFAULT_LIFETIME_MS)); + const rootUrl = normalizeRootUrl(options.rootUrl); + + const packed = Boss.dump({ + guid: '', + encryptedData: key.etaEncryptSync(source), + deleteAt, + burnOnShow: options.burnOnShow === true, + showWide: options.showWide === true, + syntaxHighlighting: options.syntaxHighlighting ?? null, + editable: options.editable === true, + ownerHandle: options.ownerHandle ?? null, + }); + + const form = new FormData(); + form.append('data', new Blob([packed]), 'crypstie.boss'); + const response = await getFetch(options.fetch)(new URL('/api/crypstie', rootUrl), { + method: 'POST', + body: form, + signal: options.signal, + }); + await requireOk(response, 'create'); + + const id = (await response.text()).trim(); + if (!id || /[\s/#?]/.test(id)) { + throw new CrypstieError('Crypstie server returned an invalid id'); + } + + const url = new URL(`/_${id}`, rootUrl); + url.hash = encodeCrypstieKey(key.pack()); + return { + id, + url: url.href, + deleteAt, + burnOnShow: options.burnOnShow === true, + }; +} + +async function decryptCrypstiePayload(packed, key) { + await initializeCrypto(); + const record = Boss.load(toUint8Array(packed)); + if (!record || !(record.encryptedData instanceof Uint8Array)) { + throw new CrypstieError('Invalid Crypstie response: encryptedData is missing'); + } + + let data; + try { + data = new SymmetricKey({ keyBytes: toUint8Array(key) }) + .etaDecryptSync(record.encryptedData); + } catch (error) { + throw new CrypstieError('Failed to authenticate or decrypt Crypstie', { cause: error }); + } + + return { + id: record.guid, + data, + text: new TextDecoder().decode(data), + deleteAt: record.deleteAt instanceof Date ? record.deleteAt : null, + burnOnShow: record.burnOnShow === true, + showWide: record.showWide === true, + syntaxHighlighting: record.syntaxHighlighting ?? null, + editable: record.editable === true, + ownerHandle: record.ownerHandle ?? null, + }; +} + +async function openCrypstie(value, options = {}) { + const { url, id, key } = parseCrypstieUrl(value); + const requestUrl = new URL(`/api/crypstie/${encodeURIComponent(id)}`, url.origin); + if (Array.isArray(options.profileIds) && options.profileIds.length > 0) { + requestUrl.searchParams.set('pids', options.profileIds.join(',')); + } + + const response = await getFetch(options.fetch)(requestUrl, { signal: options.signal }); + await requireOk(response, 'open'); + const result = await decryptCrypstiePayload(await response.arrayBuffer(), key); + return { ...result, id: result.id || id, url: url.href }; +} + +function normalizeRootUrl(value = DEFAULT_ROOT_URL) { + const url = new URL(value); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new TypeError('Crypstie root URL must use HTTP or HTTPS'); + } + return url; +} + +function normalizeDate(value) { + const date = value instanceof Date ? new Date(value) : new Date(value); + if (!Number.isFinite(date.getTime())) throw new TypeError('Invalid deleteAt date'); + if (date.getTime() <= Date.now()) throw new TypeError('deleteAt must be in the future'); + return date; +} + +function getFetch(fetchImpl = globalThis.fetch) { + if (typeof fetchImpl !== 'function') throw new TypeError('No fetch implementation is available'); + return fetchImpl; +} + +async function requireOk(response, operation) { + if (response.ok) return; + let detail = ''; + try { detail = (await response.text()).trim(); } catch {} + throw new CrypstieError( + `Failed to ${operation} Crypstie: HTTP ${response.status}${detail ? `: ${detail}` : ''}`, + { status: response.status }, + ); +} + +function toUint8Array(value) { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + throw new TypeError('Expected a string or binary data'); +} + +module.exports = { + CrypstieError, + DEFAULT_ROOT_URL, + createCrypstie, + decodeCrypstieKey, + decryptCrypstiePayload, + encodeCrypstieKey, + initializeCrypto, + openCrypstie, + parseCrypstieUrl, +}; diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..0610a98 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,26 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); +const pkg = require('../package.json'); + +const cli = path.join(__dirname, '..', 'src', 'cli.js'); + +test('CLI prints concise help', () => { + const output = execFileSync(process.execPath, [cli, '--help'], { encoding: 'utf8' }); + assert.match(output, /crypstie create \[file\|-\]/); + assert.match(output, /crypstie open /); +}); + +test('CLI prints package version', () => { + assert.equal(execFileSync(process.execPath, [cli, '--version'], { encoding: 'utf8' }), `${pkg.version}\n`); +}); + +test('CLI rejects unknown commands without a stack trace', () => { + const result = spawnSync(process.execPath, [cli, 'oops'], { encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /^crypstie: Unknown command/); + assert.equal(result.stderr.includes(' at '), false); +}); diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..5e68c8f --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,127 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { Boss, SymmetricKey } = require('unicrypto'); +const { + createCrypstie, + decryptCrypstiePayload, + encodeCrypstieKey, + initializeCrypto, + openCrypstie, + parseCrypstieUrl, +} = require('../src'); + +const KEY_BYTES = Uint8Array.from({ length: 32 }, (_, index) => index + 1); +const KEY_TEXT = Buffer.from(KEY_BYTES) + .toString('base64') + .replaceAll('+', '.') + .replaceAll('/', '_') + .replace(/=+$/, ''); + +test('parses the legacy Crypstie URL format', () => { + const parsed = parseCrypstieUrl(`https://crypstie.com/_sample#${KEY_TEXT}`); + assert.equal(parsed.id, 'sample'); + assert.deepEqual(parsed.key, KEY_BYTES); +}); + +test('encodes and decodes legacy Crypstie keys', () => { + assert.deepEqual(parseCrypstieUrl(`https://crypstie.com/_x#${encodeCrypstieKey(KEY_BYTES)}`).key, KEY_BYTES); +}); + +test('rejects a link without its fragment key', () => { + assert.throws( + () => parseCrypstieUrl('https://crypstie.com/_sample'), + /no decryption key/, + ); +}); + +test('decrypts a BOSS payload produced in the Crypstie wire format', async () => { + await initializeCrypto(); + const key = new SymmetricKey({ keyBytes: KEY_BYTES }); + const deleteAt = new Date('2030-01-02T03:04:05Z'); + const packed = Boss.dump({ + guid: 'sample', + encryptedData: key.etaEncryptSync(new TextEncoder().encode('совместимость работает')), + deleteAt, + burnOnShow: false, + showWide: true, + syntaxHighlighting: 'text', + editable: false, + ownerHandle: null, + }); + + const result = await decryptCrypstiePayload(packed, KEY_BYTES); + assert.equal(result.text, 'совместимость работает'); + assert.equal(result.id, 'sample'); + assert.equal(result.deleteAt.toISOString(), deleteAt.toISOString()); + assert.equal(result.burnOnShow, false); + assert.equal(result.showWide, true); +}); + +test('downloads the encrypted payload without sending the fragment key', async () => { + await initializeCrypto(); + const key = new SymmetricKey({ keyBytes: KEY_BYTES }); + const packed = Boss.dump({ + guid: 'sample', + encryptedData: key.etaEncryptSync(new TextEncoder().encode('secret')), + deleteAt: new Date('2030-01-02T03:04:05Z'), + burnOnShow: false, + showWide: false, + editable: false, + }); + let requested; + const fakeFetch = async (url) => { + requested = url.href; + return new Response(packed, { status: 200 }); + }; + + const result = await openCrypstie( + `https://crypstie.com/_sample#${KEY_TEXT}`, + { fetch: fakeFetch }, + ); + + assert.equal(requested, 'https://crypstie.com/api/crypstie/sample'); + assert.equal(requested.includes(KEY_TEXT), false); + assert.equal(result.text, 'secret'); +}); + +test('creates a server-compatible encrypted BOSS payload', async () => { + let request; + const fakeFetch = async (url, options) => { + request = { url: url.href, options }; + return new Response('new_id', { status: 200 }); + }; + const deleteAt = new Date(Date.now() + 60_000); + const result = await createCrypstie('created by node', { + key: KEY_BYTES, + deleteAt, + rootUrl: 'https://example.test/base', + burnOnShow: true, + fetch: fakeFetch, + }); + + assert.equal(request.url, 'https://example.test/api/crypstie'); + assert.equal(request.options.method, 'POST'); + assert.equal(result.url, `https://example.test/_new_id#${KEY_TEXT}`); + assert.equal(result.burnOnShow, true); + + const uploaded = Boss.load(new Uint8Array(await request.options.body.get('data').arrayBuffer())); + assert.equal(uploaded.burnOnShow, true); + assert.equal( + uploaded.deleteAt.toISOString(), + new Date(Math.floor(deleteAt.getTime() / 1000) * 1000).toISOString(), + ); + assert.equal( + new TextDecoder().decode(new SymmetricKey({ keyBytes: KEY_BYTES }).etaDecryptSync(uploaded.encryptedData)), + 'created by node', + ); +}); + +test('reports authentication failure for a wrong key', async () => { + await initializeCrypto(); + const key = new SymmetricKey({ keyBytes: KEY_BYTES }); + const packed = Boss.dump({ encryptedData: key.etaEncryptSync(new TextEncoder().encode('secret')) }); + const wrongKey = Uint8Array.from(KEY_BYTES, (byte) => byte ^ 0xff); + await assert.rejects(() => decryptCrypstiePayload(packed, wrongKey), /authenticate or decrypt/); +}); diff --git a/test/live.test.js b/test/live.test.js new file mode 100644 index 0000000..6b5741f --- /dev/null +++ b/test/live.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { createCrypstie, openCrypstie } = require('../src'); + +test( + 'opens a reusable Crypstie from the deployed service', + { skip: !process.env.CRYPSTIE_TEST_URL }, + async () => { + const result = await openCrypstie(process.env.CRYPSTIE_TEST_URL); + assert.match(result.text, /^Crypstie Node compatibility fixture /); + assert.equal(result.burnOnShow, false); + }, +); + +test( + 'creates and reopens a reusable Crypstie on the deployed service', + { skip: process.env.CRYPSTIE_LIVE_WRITE !== '1' }, + async () => { + const marker = `Crypstie Node compatibility fixture ${new Date().toISOString()}`; + const created = await createCrypstie(marker); + const opened = await openCrypstie(created.url); + assert.equal(opened.text, marker); + assert.equal(opened.burnOnShow, false); + }, +);