├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── index.ts └── utils │ └── keyConverter.ts ├── test └── near-js-encryption-box.test.ts └── tsconfig.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['16.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sandoche Adittane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # near-js-encryption-box [![NEAR](https://img.shields.io/badge/NEAR-%E2%8B%88-111111.svg)](https://near.org/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 2 | 3 | > An experimental library to encrypt and decrypt data using the NEAR account's ed25519 keypairs; you can use it to store encrypted data on-chain, off-chain, or in any decentralized storage (IPFS, Arweave). 4 | 5 | ⚠️ This is an experimental library. We do not recommend to use it to store confidential information publically. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install @nearfoundation/near-js-encryption-box 11 | ``` 12 | 13 | ## Usage 14 | 15 | You can find below an example where Alice encrypt data with her private key and Bob's public key. 16 | Then Bob can decrypt the message with his private key, Alice's public key and a nonce. 17 | 18 | ```js 19 | import { create, open } from 'near-js-encryption-box'; 20 | import { utils } from 'near-api-js'; 21 | 22 | // Randomly generating key pairs for the example 23 | const keyPairAlice = utils.key_pair.KeyPairEd25519.fromRandom(); 24 | const keyPairBob = utils.key_pair.KeyPairEd25519.fromRandom(); 25 | 26 | // Encrypting a message 27 | const message = 'Hello Bob'; 28 | const publicKeyBob = keyPairBob.getPublicKey().toString(); 29 | const privateKeyAlice = keyPairAlice.secretKey; 30 | const { secret, nonce } = create(message, publicKeyBob, privateKeyAlice); // you can also pass your own custom nonce as a 4th parameter 31 | 32 | // Decrypting the message 33 | const publicKeyAlice = keyPairAlice.getPublicKey().toString(); 34 | const privateKeyBob = keyPairBob.secretKey; 35 | const messageReceived = open(secret, publicKeyAlice, privateKeyBob, nonce); 36 | console.log(messageReceived); // will return 'Hello Bob' 37 | ``` 38 | 39 | Find more examples in the [near-js-encryption-box-test.ts](test/near-js-encryption-box.test.ts) 40 | 41 | ## Encryption 42 | 43 | - Convert NEAR Ed25519 signing key pair into Curve25519 key pair suitable for Diffie-Hellman key; using [ed2curve.js](https://github.com/dchest/ed2curve-js) 44 | - "Note that there's currently no proof that this is safe to do. It is safer to share both Ed25519 and Curve25519 public keys (their concatenation is 64 bytes long)." 45 | - Then uses Curve25519-XSalsa20-Poly1305 implemented by [TweetNaCl.js](https://tweetnacl.js.org) 46 | 47 | ## Authors 48 | 49 | - [Sandoche](https://github.com/sandoche) 50 | 51 | ## License 52 | 53 | MIT License 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.2", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=16" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build", 19 | "size": "size-limit", 20 | "analyze": "size-limit --why" 21 | }, 22 | "husky": { 23 | "hooks": { 24 | "pre-commit": "tsdx lint" 25 | } 26 | }, 27 | "prettier": { 28 | "printWidth": 80, 29 | "semi": true, 30 | "singleQuote": true, 31 | "trailingComma": "es5" 32 | }, 33 | "name": "@nearfoundation/near-js-encryption-box", 34 | "author": "Sandoche Adittane", 35 | "module": "dist/near-js-encryption-box.esm.js", 36 | "size-limit": [ 37 | { 38 | "path": "dist/near-js-encryption-box.cjs.production.min.js", 39 | "limit": "10 KB" 40 | }, 41 | { 42 | "path": "dist/near-js-encryption-box.esm.js", 43 | "limit": "10 KB" 44 | } 45 | ], 46 | "devDependencies": { 47 | "@size-limit/preset-small-lib": "^7.0.8", 48 | "@types/ed2curve": "^0.2.2", 49 | "@types/random-bytes": "^1.0.1", 50 | "husky": "^8.0.1", 51 | "near-api-js": "^0.45.1", 52 | "size-limit": "^7.0.8", 53 | "tsdx": "^0.14.1", 54 | "tslib": "^2.4.0", 55 | "typescript": "^4.8.2" 56 | }, 57 | "dependencies": { 58 | "borsh": "^0.7.0", 59 | "ed2curve": "^0.3.0", 60 | "random-bytes": "^1.0.0", 61 | "tweetnacl": "^1.0.3", 62 | "tweetnacl-util": "^0.15.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseAndConvertPublicKey, 3 | parseAndConvertPrivateKey, 4 | } from './utils/keyConverter'; 5 | import { 6 | encodeBase64, 7 | decodeBase64, 8 | decodeUTF8, 9 | encodeUTF8, 10 | } from 'tweetnacl-util'; 11 | import { box } from 'tweetnacl'; 12 | import randomBytes from 'random-bytes'; 13 | 14 | export const create = ( 15 | message: string, 16 | publicKey: string, 17 | privateKey: string, 18 | nonce?: string 19 | ): { secret: string; nonce: string } => { 20 | const convertedPublicKey = parseAndConvertPublicKey(publicKey); 21 | const convertedPrivateKey = parseAndConvertPrivateKey(privateKey); 22 | 23 | const encodedMessage = decodeUTF8(message); 24 | const encodedNonce = nonce ? decodeBase64(nonce) : randomBytes.sync(24); 25 | 26 | if (!convertedPublicKey || !convertedPrivateKey) { 27 | throw new Error('Invalid public or private key'); 28 | } 29 | 30 | const secret = box( 31 | encodedMessage, 32 | encodedNonce, 33 | convertedPublicKey, 34 | convertedPrivateKey 35 | ); 36 | 37 | return { 38 | secret: encodeBase64(secret), 39 | nonce: encodeBase64(encodedNonce), 40 | }; 41 | }; 42 | 43 | export const open = ( 44 | secret: string, 45 | publicKey: string, 46 | privateKey: string, 47 | nonce: string 48 | ): string | null => { 49 | const convertedPublicKey = parseAndConvertPublicKey(publicKey); 50 | const convertedPrivateKey = parseAndConvertPrivateKey(privateKey); 51 | 52 | const encodedSecret = decodeBase64(secret); 53 | const encodedNonce = decodeBase64(nonce); 54 | 55 | if (!convertedPublicKey || !convertedPrivateKey) { 56 | throw new Error('Invalid public or private key'); 57 | } 58 | 59 | const secretDecoded = box.open( 60 | encodedSecret, 61 | encodedNonce, 62 | convertedPublicKey, 63 | convertedPrivateKey 64 | ); 65 | 66 | return secretDecoded ? encodeUTF8(secretDecoded) : null; 67 | }; 68 | -------------------------------------------------------------------------------- /src/utils/keyConverter.ts: -------------------------------------------------------------------------------- 1 | import { convertPublicKey, convertSecretKey } from 'ed2curve'; 2 | import { decodeBase64 } from 'tweetnacl-util'; 3 | import { baseDecode } from 'borsh'; 4 | 5 | export const parseAndConvertPublicKey = ( 6 | publicKey: string 7 | ): Uint8Array | null => { 8 | const publicKeyBytes = decodeBase64( 9 | baseDecode(publicKey.replace('ed25519:', '')).toString('base64') 10 | ); 11 | const convertedPublicKey = convertPublicKey(publicKeyBytes); 12 | return convertedPublicKey; 13 | }; 14 | 15 | export const parseAndConvertPrivateKey = ( 16 | privateKey: string 17 | ): Uint8Array | null => { 18 | const privateKeyOnly = privateKey.replace('ed25519:', ''); 19 | const privateKeyBytes = 20 | privateKeyOnly.length === 64 21 | ? decodeBase64(Buffer.from(privateKeyOnly, 'hex').toString('base64')) 22 | : decodeBase64(baseDecode(privateKeyOnly).toString('base64')); 23 | const convertedPrivateKey = convertSecretKey(privateKeyBytes.slice(0, 32)); 24 | return convertedPrivateKey; 25 | }; 26 | -------------------------------------------------------------------------------- /test/near-js-encryption-box.test.ts: -------------------------------------------------------------------------------- 1 | import { create, open } from './../src'; 2 | import { utils } from 'near-api-js'; 3 | import { encodeBase64 } from 'tweetnacl-util'; 4 | import randomBytes from 'random-bytes'; 5 | 6 | describe('Creating a secret box with Alice and Bob key pairs randomly generated from NEAR JS LIBRARY', () => { 7 | const keyPairAlice = utils.key_pair.KeyPairEd25519.fromRandom(); 8 | const keyPairBob = utils.key_pair.KeyPairEd25519.fromRandom(); 9 | const keyPairCharlie = utils.key_pair.KeyPairEd25519.fromRandom(); 10 | 11 | const messageSent = 'Hello world'; 12 | 13 | const publicKeyBob = keyPairBob.getPublicKey().toString(); 14 | const privateKeyAlice = keyPairAlice.secretKey; 15 | 16 | const { secret, nonce } = create(messageSent, publicKeyBob, privateKeyAlice); 17 | 18 | it('Should contain the same message sent by Alice opened by Bob', () => { 19 | const publicKeyAlice = keyPairAlice.getPublicKey().toString(); 20 | const privateKeyBob = keyPairBob.secretKey; 21 | 22 | const messageReceived = open(secret, publicKeyAlice, privateKeyBob, nonce); 23 | 24 | expect(messageSent).toBe(messageReceived); 25 | }); 26 | 27 | it('Should not let Charlie open the box created by Alice and sent to Bob', () => { 28 | const publicKeyAlice = keyPairAlice.getPublicKey().toString(); 29 | const privateKeyCharlie = keyPairCharlie.secretKey; 30 | 31 | const messageReceived = open( 32 | secret, 33 | publicKeyAlice, 34 | privateKeyCharlie, 35 | nonce 36 | ); 37 | 38 | expect(messageReceived).toBe(null); 39 | }); 40 | }); 41 | 42 | describe('Creating a secret box with Alice and Bob key pairs generated with NEAR CLI', () => { 43 | const keyPairAlice = { 44 | publicKey: 'ed25519:d9ymaE7DT8ydtEvxgfs4UERofMF749szvniWTRQJBBh', 45 | privateKey: 46 | '09430fbb4310e17cec8c8639d6b2c6c59c28ca5c9fdd9680834737aa1aeadfec', 47 | }; 48 | 49 | const keyPairBob = { 50 | publicKey: 'ed25519:d9ymaE7DT8ydtEvxgfs4UERofMF749szvniWTRQJBBh', 51 | privateKey: 52 | '09430fbb4310e17cec8c8639d6b2c6c59c28ca5c9fdd9680834737aa1aeadfec', 53 | }; 54 | 55 | it('Should contain the same message sent by Alice opened by Bob', () => { 56 | const messageSent = 'Hello world'; 57 | 58 | const publicKeyBob = keyPairBob.publicKey; 59 | const privateKeyAlice = keyPairAlice.privateKey; 60 | 61 | const { secret, nonce } = create( 62 | messageSent, 63 | publicKeyBob, 64 | privateKeyAlice 65 | ); 66 | 67 | const publicKeyAlice = keyPairAlice.publicKey; 68 | const privateKeyBob = keyPairBob.privateKey; 69 | 70 | const messageReceived = open(secret, publicKeyAlice, privateKeyBob, nonce); 71 | 72 | expect(messageSent).toBe(messageReceived); 73 | }); 74 | }); 75 | 76 | describe('Creating a secret box with Alice and Bob key pairs having ed25519: prefix', () => { 77 | const keyPairAlice = { 78 | publicKey: 'ed25519:d9ymaE7DT8ydtEvxgfs4UERofMF749szvniWTRQJBBh', 79 | privateKey: 80 | 'ed25519:09430fbb4310e17cec8c8639d6b2c6c59c28ca5c9fdd9680834737aa1aeadfec', 81 | }; 82 | 83 | const keyPairBob = { 84 | publicKey: 'ed25519:d9ymaE7DT8ydtEvxgfs4UERofMF749szvniWTRQJBBh', 85 | privateKey: 86 | 'ed25519:09430fbb4310e17cec8c8639d6b2c6c59c28ca5c9fdd9680834737aa1aeadfec', 87 | }; 88 | 89 | it('Should contain the same message sent by Alice opened by Bob', () => { 90 | const messageSent = 'Hello world'; 91 | 92 | const publicKeyBob = keyPairBob.publicKey; 93 | const privateKeyAlice = keyPairAlice.privateKey; 94 | 95 | const { secret, nonce } = create( 96 | messageSent, 97 | publicKeyBob, 98 | privateKeyAlice 99 | ); 100 | 101 | const publicKeyAlice = keyPairAlice.publicKey; 102 | const privateKeyBob = keyPairBob.privateKey; 103 | 104 | const messageReceived = open(secret, publicKeyAlice, privateKeyBob, nonce); 105 | 106 | expect(messageSent).toBe(messageReceived); 107 | }); 108 | }); 109 | 110 | describe('Creating a secret box with Alice and Bob key pairs without ed25519: prefix', () => { 111 | const keyPairAlice = { 112 | publicKey: 'd9ymaE7DT8ydtEvxgfs4UERofMF749szvniWTRQJBBh', 113 | privateKey: 114 | '09430fbb4310e17cec8c8639d6b2c6c59c28ca5c9fdd9680834737aa1aeadfec', 115 | }; 116 | 117 | const keyPairBob = { 118 | publicKey: 'd9ymaE7DT8ydtEvxgfs4UERofMF749szvniWTRQJBBh', 119 | privateKey: 120 | '09430fbb4310e17cec8c8639d6b2c6c59c28ca5c9fdd9680834737aa1aeadfec', 121 | }; 122 | 123 | it('Should contain the same message sent by Alice opened by Bob', () => { 124 | const messageSent = 'Hello world'; 125 | 126 | const publicKeyBob = keyPairBob.publicKey; 127 | const privateKeyAlice = keyPairAlice.privateKey; 128 | 129 | const { secret, nonce } = create( 130 | messageSent, 131 | publicKeyBob, 132 | privateKeyAlice 133 | ); 134 | 135 | const publicKeyAlice = keyPairAlice.publicKey; 136 | const privateKeyBob = keyPairBob.privateKey; 137 | 138 | const messageReceived = open(secret, publicKeyAlice, privateKeyBob, nonce); 139 | 140 | expect(messageSent).toBe(messageReceived); 141 | }); 142 | }); 143 | 144 | describe('Creating a secret box for Alice only', () => { 145 | const keyPairAlice = utils.key_pair.KeyPairEd25519.fromRandom(); 146 | const keyPairBob = utils.key_pair.KeyPairEd25519.fromRandom(); 147 | 148 | const messageSent = 'Hello world'; 149 | const privateKeyAlice = keyPairAlice.secretKey; 150 | const publicKeyAlice = keyPairAlice.getPublicKey().toString(); 151 | 152 | const { secret, nonce } = create( 153 | messageSent, 154 | publicKeyAlice, 155 | privateKeyAlice 156 | ); 157 | 158 | it('Should contain the same message', () => { 159 | const messageReceived = open( 160 | secret, 161 | publicKeyAlice, 162 | privateKeyAlice, 163 | nonce 164 | ); 165 | 166 | expect(messageSent).toBe(messageReceived); 167 | }); 168 | 169 | it('Should not let Bob open the box created by Alice for herself', () => { 170 | const privateKeyBob = keyPairBob.secretKey; 171 | 172 | const messageReceived = open(secret, publicKeyAlice, privateKeyBob, nonce); 173 | 174 | expect(messageReceived).toBe(null); 175 | }); 176 | }); 177 | 178 | describe('Creating a secret box with Alice and Bob key pairs randomly generated from NEAR JS LIBRARY with a custom nonce', () => { 179 | const keyPairAlice = utils.key_pair.KeyPairEd25519.fromRandom(); 180 | const keyPairBob = utils.key_pair.KeyPairEd25519.fromRandom(); 181 | const keyPairCharlie = utils.key_pair.KeyPairEd25519.fromRandom(); 182 | 183 | const messageSent = 'Hello world'; 184 | 185 | const publicKeyBob = keyPairBob.getPublicKey().toString(); 186 | const privateKeyAlice = keyPairAlice.secretKey; 187 | 188 | const nonce = encodeBase64(randomBytes.sync(24)); 189 | const { secret } = create(messageSent, publicKeyBob, privateKeyAlice, nonce); 190 | 191 | it('Should contain the same message sent by Alice opened by Bob', () => { 192 | const publicKeyAlice = keyPairAlice.getPublicKey().toString(); 193 | const privateKeyBob = keyPairBob.secretKey; 194 | 195 | const messageReceived = open(secret, publicKeyAlice, privateKeyBob, nonce); 196 | 197 | expect(messageSent).toBe(messageReceived); 198 | }); 199 | 200 | it('Should not let Charlie open the box created by Alice and sent to Bob', () => { 201 | const publicKeyAlice = keyPairAlice.getPublicKey().toString(); 202 | const privateKeyCharlie = keyPairCharlie.secretKey; 203 | 204 | const messageReceived = open( 205 | secret, 206 | publicKeyAlice, 207 | privateKeyCharlie, 208 | nonce 209 | ); 210 | 211 | expect(messageReceived).toBe(null); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | --------------------------------------------------------------------------------