Introduction


Encrypt.ink is a free opensource web app that provides secure file encryption in the browser with advanced wallet integration for seamless cryptographic operations.


Features


Security

The libsodium library is used for all cryptographic algorithms. Technical details here.


Privacy

  • The app runs locally in your browser.
  • No data is ever collected or sent to anyone.​

Functionality

  • Secure encryption/decryption of files with keys.
  • Wallet-based encryption using Solana blockchain wallets.
  • Asymmetric key pair generation.
  • Authenticated key exchange.
  • Wallet key derivation for cryptographic operations.
  • Web3 wallet integration with popular wallet providers.

Installation


It's easy to self host and deploy encrypt.ink, you can do that either with npm or docker

If you wish to self host the app please follow these instructions:


With npm

Before installation make sure you are running nodejs and have npm installed


  1. clone the github repository
git clone https://github.com/encrypt-ink/encrypt.ink.git encrypt.ink
  1. move to the folder
cd encrypt.ink
  1. install dependencies
npm install
  1. build app
npm run build
  1. start encrypt.ink
npm run start

the app should be running on port 3391.

if you wish to run the app in development enviroment run :


npm run dev

With docker

You can install the app with docker in multiple ways. You are free to choose which method you like.


  • install from docker hub

  1. pull image from docker hub
docker pull encryptink/encrypt.ink:latest
  1. run container
docker run -d -p 3991:80 encryptink/encrypt.ink

  • Build an image from source

  1. clone the github repository
git clone https://github.com/encrypt-ink/encrypt.ink.git encrypt.ink
  1. move to the folder
cd encrypt.ink
  1. build image using docker
docker build . -t encryptink/encrypt.ink
  1. run container
docker run -d -p 3991:80 encryptink/encrypt.ink

  • Using docker compose

  1. clone the github repository
git clone https://github.com/encrypt-ink/encrypt.ink.git encrypt.ink
  1. move to the folder
cd encrypt.ink
  1. build image using docker compose
docker compose build
  1. run container
docker compose up

The app should be running on port 3991.

encrypt.ink is also available as a Docker image. You can find it on Docker Hub.


Usage


File Encryption

  • using public and private keys

  1. Open encrypt.ink.
  2. Navigate to the Encryption panel.
  3. Drag & Drop or Select the files that you wish to encrypt.
  4. Choose public key method.
  5. Enter or load recipient's public key and your private key. if you don't have public and private keys you can generate a key pair.
  6. Download the encrypted file.
  7. Share your public key with the recipient so he will be able to decrypt the file.

Never share your private key to anyone! Only public keys should be exchanged.


File Decryption

  • using public and private keys

  1. Open encrypt.ink.
  2. Navigate to the Decryption panel.
  3. Drag & Drop or Select the files that you wish to decrypt.
  4. Enter or load sender's public key and your private key.
  5. Download the decrypted file.

Wallet-Based Encryption


Overview

Encrypt.ink revolutionizes file encryption by integrating Web3 wallets as cryptographic key sources. Instead of relying solely on traditional passwords, users can leverage their existing blockchain wallets to encrypt and decrypt files securely. This approach combines the security of blockchain cryptography with the convenience of wallet-based authentication.


How Wallet Encryption Works

Key Derivation Process

When using wallet-based encryption, Encrypt.ink employs a sophisticated key derivation mechanism:

  1. Wallet Connection: Connect your supported wallet (Phantom, MetaMask, Solflare, etc.)
  2. Signature Generation: The wallet signs a deterministic message to prove ownership
  3. Key Derivation: A cryptographic key is derived from the wallet's signature using HKDF (HMAC-based Key Derivation Function)
  4. Encryption: Files are encrypted using the derived key with XChaCha20-Poly1305

Supported Wallets

  • Solana Wallets: Phantom, Solflare, Glow, Torus, Ledger

Security Benefits

  • Hardware Security: Leverage hardware wallet security for file encryption
  • Deterministic: Same wallet always generates the same encryption key
  • Non-repudiation: Cryptographic proof of who encrypted the file
  • Quantum-resistant: Future-proof against quantum computing threats

Wallet Encryption Process

For Encryption:

  1. Connect Wallet: Click "Connect Wallet" and select your preferred wallet
  2. Upload Files: Drag & drop or select files you want to encrypt
  3. Wallet Signing: Approve the signature request in your wallet
  4. Key Generation: Encryption key is derived from your wallet signature
  5. File Encryption: Files are encrypted using XChaCha20-Poly1305 algorithm
  6. Download: Receive encrypted files with wallet-based protection

For Decryption:

  1. Connect Same Wallet: Use the exact wallet that encrypted the files
  2. Upload Encrypted Files: Select the encrypted files to decrypt
  3. Wallet Verification: Sign with your wallet to prove ownership
  4. Key Recreation: The same encryption key is derived from your signature
  5. File Decryption: Files are decrypted and ready for download

Advanced Wallet Features

Multi-Signature Encryption

For enterprise or shared file scenarios:

  • Threshold Encryption: Require multiple wallet signatures to decrypt
  • Role-Based Access: Different wallet types for different access levels
  • Time-Locked Encryption: Files that can only be decrypted after a specific time

Solana Network Features

  • SPL Token Support: Integration with Solana Program Library
  • Solana Programs: Seamless operation with Solana smart contracts
  • Network Optimization: Optimized for Solana's high-speed transactions

Technical Implementation

Key Derivation Details

// Wallet signature-based key derivation
const message = "Encrypt.ink file encryption";
const signature = await wallet.signMessage(message);
const seed = sha256(signature);
const encryptionKey = hkdf(seed, 32, "file-encryption");

Wallet Integration Architecture

  • Provider Abstraction: Universal interface for different wallet types
  • Signature Standardization: Consistent signing process across wallets
  • Error Handling: Graceful fallbacks for wallet connection issues
  • Security Validation: Multiple verification layers for wallet authenticity

Security Considerations

  • Message Consistency: Same message always produces same signature
  • Entropy Source: Wallet signatures provide high-entropy seed material
  • Replay Protection: Signatures are context-bound to prevent reuse
  • Network Independence: Works offline after initial wallet connection

Best Practices for Wallet Encryption

Wallet Security

  • Hardware Wallets: Use hardware wallets for maximum security
  • Backup Management: Ensure wallet seed phrases are securely backed up
  • Regular Updates: Keep wallet software updated
  • Multi-Wallet Strategy: Use different wallets for different security levels

File Management

  • Wallet Documentation: Record which wallet encrypted which files
  • Access Planning: Plan for wallet availability when decryption is needed
  • Backup Strategies: Consider multiple wallet access methods
  • Recovery Procedures: Have wallet recovery plans in place

Use Cases

  • Personal File Security: Protect personal documents with wallet convenience
  • Business Document Protection: Enterprise-grade security with audit trails
  • Collaborative Encryption: Team-based file sharing with wallet authentication
  • Compliance Requirements: Meet regulatory standards with cryptographic proof

Limitations


File Signature

Files encrypted with encrypt.ink are identifiable by looking at the file signature that is used by the app to verify the content of a file, Such signatures are also known as magic numbers or Magic Bytes. These Bytes are authenticated and cannot be changed.

Safari and Mobile Browsers

Safari and Mobile browsers are limited to a single file with maximum size of 1GB due to some issues related to service-workers. In addition, this limitation also applies when the app fails to register the service-worker (e.g FireFox Private Browsing).


Best Practices


Sharing Encrypted Files

If you plan on sending someone an encrypted file, it is recommended to use your private key and their public key to encrypt the file.

The file can be shared in any safe file sharing app.


Sharing the public key

Public keys are allowed to be shared, they can be sent as .public file or as text.

Never share your private key to anyone! Only public keys should be exchanged.


Storing the Public & Private keys

Make sure to store your encryption keys in a safe place and make a backup to an external storage.

Storing your private key in cloud storage is not recommended!


FAQ


Does the app log or store any of my data?

No, encrypt.ink never stores any of your data. It only runs locally in your browser.


Is encrypt.ink free?

Yes, encrypt.ink is free and always will be. However, please consider donating to support the project.


Which file types are supported? Is there a file size limit?

Encrypt.ink accepts all file types. There's no file size limit, meaning files of any size can be encrypted.

Safari browser and mobile/smartphones browsers are limited to 1GB.


Why am I seeing a notice that says "You have limited experience (single file, 1GB)"?

It means that your browser doesn't support the server-worker fetch api. Hence, you are limited to small size files. see Limitations for more info.


Is it safe to share my public key?

Yes. Public keys are allowed to be shared, they can be sent as .public file or as text.

But make sure to never share your private key with anyone!


Why the app asks for my private key in the public key encryption mode?

Encrypt.ink uses authenticated encryption. The sender must provide their private key, a new shared key will be computed from both keys to encrypt the file. Recipient has to provide their private key when decrypting also. this way can verify that the encrypted file was not tampered with, and was sent from the real sender.


I have lost my private key, is it possible to recover it?

Nope. lost private keys cannot be recovered.

Also, if you feel that your private key has been compromised (e.g accidentally shared / computer hacked) then you must decrypt all files that were encrypted with that key, generate a new keypair and re-encrypt the files.


Does the app connect to the internet?

Once you visit the site and the page loads, it runs only offline. Wallet connections are handled locally in your browser.


Can I use my hardware wallet for encryption?

Yes! Hardware wallets like Ledger provide the highest security for wallet-based encryption. The signing happens on the hardware device, ensuring your private keys never leave the secure element.


What happens if I lose access to my wallet?

If you lose access to your wallet and don't have the seed phrase, files encrypted with that wallet cannot be decrypted. Always backup your wallet seed phrase securely. Consider using multiple encryption methods for critical files.


Can I decrypt files encrypted with a different wallet?

No, files can only be decrypted with the same wallet that encrypted them. Each wallet generates a unique encryption key based on its private key signature. This ensures cryptographic security and access control.


Which wallets are supported for encryption?

We support Solana wallets including Phantom, Solflare, Glow, Torus, and Ledger. Both software and hardware wallets are supported.


How can I contribute?

Encrypt.ink is an open-source application. You can help make it better by making commits on GitHub. The project is maintained in my free time. Donations of any size are appreciated.


Why should I use encrypt.ink?

  1. The app uses fast modern secure cryptographic algorithms.
  2. It's super fast and easy to use.
  3. It runs in the browser, no need to setup or install anything.
  4. It's free opensource software and can be easily self hosted.

Technical Details


Password hashing and Key derivation

Password hashing functions derive a secret key of any size from a password and a salt.


let salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
let key = sodium.crypto_pwhash(
  sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES,
  password,
  salt,
  sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_ALG_ARGON2ID13
);

The crypto_pwhash() function derives an 256 bits long key from a password and a salt salt whose fixed length is 128 bits, which should be unpredictable.

randombytes_buf() is the easiest way to fill the 128 bits of the salt.


OPSLIMIT represents a maximum amount of computations to perform.

MEMLIMIT is the maximum amount of RAM that the function will use, in bytes.


crypto_pwhash_OPSLIMIT_INTERACTIVE and crypto_pwhash_MEMLIMIT_INTERACTIVE provide base line for these two parameters. This currently requires 64 MiB of dedicated RAM. which is suitable for in-browser operations.
crypto_pwhash_ALG_ARGON2ID13 using the Argon2id algorithm version 1.3.


File Encryption (stream)

In order to use the app to encrypt a file, the user has to provide a valid file and a password. this password gets hashed and a secure key is derived from it with Argon2id to encrypt the file.


let res = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
header = res.header;
state = res.state;

let tag = last
  ? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
  : sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;

let encryptedChunk = sodium.crypto_secretstream_xchacha20poly1305_push(
  state,
  new Uint8Array(chunk),
  null,
  tag
);

stream.enqueue(signature, salt, header, encryptedChunk);

The crypto_secretstream_xchacha20poly1305_init_push function creates an encrypted stream where it initializes a state using the key and an internal, automatically generated initialization vector. It then stores the stream header into header that has a size of 192 bits.

This is the first function to call in order to create an encrypted stream. The key will not be required any more for subsequent operations.


An encrypted stream starts with a short header, whose size is 192 bits. That header must be sent/stored before the sequence of encrypted messages, as it is required to decrypt the stream. The header content doesn't have to be secret because decryption with a different header would fail.

A tag is attached to each message accoring to the value of last, which indicates if that is the last chunk of the file or not. That tag can be any of:

  1. crypto_secretstream_xchacha20poly1305_TAG_MESSAGE: This doesn't add any information about the nature of the message.
  2. crypto_secretstream_xchacha20poly1305_TAG_FINAL: This indicates that the message marks the end of the stream, and erases the secret key used to encrypt the previous sequence.

The crypto_secretstream_xchacha20poly1305_push() function encrypts the file chunk using the state and the tag, without any additional information (null).

the XChaCha20 stream cipher Poly1305 MAC authentication are used for encryption.

stream.enqueue() function adds the encrypt.ink signature(magic bytes), salt and header followed by the encrypted chunks.

File Decryption (stream)

let state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key);

let result = sodium.crypto_secretstream_xchacha20poly1305_pull(
  state,
  new Uint8Array(chunk)
);

if (result) {
  let decryptedChunk = result.message;
  stream.enqueue(decryptedChunk);

  if (!last) {
    // continue decryption
  }
}

The crypto_secretstream_xchacha20poly1305_init_pull() function initializes a state given a secret key and a header. The key is derived from the password provided during the decryption, and the header sliced from the file. The key will not be required any more for subsequent operations.


The crypto_secretstream_xchacha20poly1305_pull() function verifies that the chunk contains a valid ciphertext and authentication tag for the given state.

This function will stay in a loop, until a message with the crypto_secretstream_xchacha20poly1305_TAG_FINAL tag is found.

If the decryption key is incorrect the function returns an error.

If the ciphertext or the authentication tag appear to be invalid it returns an error.


Random password generation

let password = sodium.to_base64(
  sodium.randombytes_buf(16),
  sodium.base64_variants.URLSAFE_NO_PADDING
);
return password;

The randombytes_buf() function fills 128 bits starting at buf with an unpredictable sequence of bytes.

The to_base64() function encodes buf as a Base64 string without padding.


Keys generation and exchange

const keyPair = sodium.crypto_kx_keypair();
let keys = {
  publicKey: sodium.to_base64(keyPair.publicKey),
  privateKey: sodium.to_base64(keyPair.privateKey),
};
return keys;

The crypto_kx_keypair() function randomly generates a secret key and a corresponding public key. The public key is put into publicKey and the secret key into privateKey. both of 256 bits.


let key = sodium.crypto_kx_client_session_keys(
  sodium.crypto_scalarmult_base(privateKey),
  privateKey,
  publicKey
);

Using the key exchange API, two parties can securely compute a set of shared keys using their peer's public key and their own secret key.

The crypto_kx_client_session_keys() function computes a pair of 256 bits long shared keys using the recipient's public key, the sender's private key.

The crypto_scalarmult_base() function used to compute the sender's public key from their private key.


Wallet-Based Key Derivation

For wallet-based encryption, Encrypt.ink implements a deterministic key derivation process:

// Wallet signature for key derivation
const message = new TextEncoder().encode("Encrypt.ink file encryption");
const signature = await wallet.signMessage(message);

// Derive encryption key from signature
const keyMaterial = await crypto.subtle.importKey(
  "raw",
  signature,
  "HKDF",
  false,
  ["deriveKey"]
);

const encryptionKey = await crypto.subtle.deriveKey(
  {
    name: "HKDF",
    salt: new Uint8Array(32), // Static salt for deterministic results
    info: new TextEncoder().encode("file-encryption"),
    hash: "SHA-256",
  },
  keyMaterial,
  { name: "AES-GCM", length: 256 },
  false,
  ["encrypt", "decrypt"]
);

The wallet signing process ensures:

  1. Deterministic Output: Same wallet always produces same signature for same message
  2. High Entropy: Wallet signatures provide cryptographically secure randomness
  3. Non-repudiation: Only the wallet owner can produce valid signatures
  4. Forward Security: Derived keys are independent of wallet's actual private key

Wallet Integration Security

// Solana wallet provider detection and connection
const detectWallet = () => {
  if (window.solana?.isPhantom) return 'phantom';
  if (window.solana?.isGlow) return 'glow';
  if (window.solana?.isSolflare) return 'solflare';
  return null;
};

// Secure message signing with validation
const signForEncryption = async (wallet, message) => {
  try {
    const signature = await wallet.signMessage(message);
    
    // Verify signature before use
    const isValid = await verifySignature(message, signature, wallet.publicKey);
    if (!isValid) throw new Error('Invalid signature');
    
    return signature;
  } catch (error) {
    throw new Error(`Wallet signing failed: ${error.message}`);
  }
};

XChaCha20-Poly1305

XChaCha20 is a variant of ChaCha20 with an extended nonce, allowing random nonces to be safe.

XChaCha20 doesn't require any lookup tables and avoids the possibility of timing attacks.

Internally, XChaCha20 works like a block cipher used in counter mode. It uses the HChaCha20 hash function to derive a subkey and a subnonce from the original key and extended nonce, and a dedicated 64-bit block counter to avoid incrementing the nonce after each block.


V2 vs V1

  • switching to xchacha20poly1305 for symmetric stream encryption and Argon2id for password-based key derivation. instead of AES-256-GCM and PBKDF2.
  • using the libsodium library for all cryptography instead of the WebCryptoApi.
  • in this version, the app doesn't read the whole file in memory. instead, it's sliced into 64MB chunks that are processed one by one.
  • since we are not using any server-side processing, the app registers a fake download URL (/file) that is going to be handled by the service-worker fetch api.
  • if all validations are passed, a new stream is initialized. then, file chunks are transferred from the main app to the service-worker file via messages.
  • each chunk is encrypted/decrypted on it's own and added to the stream.
  • after each chunk is written on disk it is going to be immediately garbage collected by the browser, this leads to never having more than a few chunks in the memory at the same time.