A guide to build an encrypted email service on Solana with Anchor
Gabriel
Posted on April 2, 2022
Project overview
We will be building an end-to-end encrypted email service on the Solana blockchain. Let me give you a summary of how this program works:
The user registers himself on our dapp, he gets a Diffie-Hellmann key pair (a private and public key). Only the public key is stored on his newly created account on the blockchain.
And now, he can send encrypted emails to someone (who is also registered). Once the email is sent, both parties can decrypt the email locally with their private keys, this is secure because the encryption and decryption process is done locally, on the client, without any network request.
The blockchain is only used to store public information about the encryption, like the iv
and salt
, for example, and to register users.
We will use AES 256 bits with counter mode as our encryption algorithm. And elliptic curve Diffie-Hellmann for the key exchange.
Prerequisites
At least a basic understanding of Rust, and the following installed:
Anchor is a framework for Solana that makes our life much easier. It handles a lot of dirty work for us. Without it, we would have to do a lot of tedious things, like manual serialization and deserialization.
Configuration
Make sure that you generated a development keypair on the Solana CLI. And that it has enough SOL to pay for fees. Change the current network to the devnet solana config set --url devnet
and airdrop some SOL with solana airdrop 2
.
Now, let's create our project, open up your terminal and paste this command anchor init encrypted-mail
. "encrypted-mail" is the name of the project, you can change this to any other name.
We need to install a few dependencies on the program, open the programs/encrypted-mail/Cargo.toml
file, and append the following to the file:
[dependencies]
uuid = { version = "0.8.*", features = ["serde", "v5"] }
anchor-lang = "0.22.1"
Open your program folder programs/encrypted-mail/src
and add a few files to make your structure be exactly the same as this architecture:
...
├─ src
│ ├─ context.rs -> contexts of instructions
│ ├─ error.rs -> error structs
│ ├─ lib.rs -> contains all the instructions
│ ├─ state.rs -> state structs
│ ├─ utils.rs -> helpers functions
...
Coding
Now that we have our foundation, let's start to do some code.
State.rs
Open your state.rs
and paste the following:
use anchor_lang::prelude::*;
#[account]
pub struct Mail {
pub from: Pubkey,
pub to: Pubkey,
pub id: String,
pub subject: String,
/* encrypted text */
pub body: String,
pub authority: Pubkey,
pub created_at: u32,
/* public information about encryption and decryption */
pub iv: String,
pub salt: String,
}
#[account]
pub struct UserAccount {
/* pubkey from diffie helman exchange */
pub diffie_pubkey: String,
pub authority: Pubkey,
pub bump: u8,
}
/* this event allows us to notify clients */
/* when a new email is created */
#[event]
pub struct NewEmailEvent {
pub from: Pubkey,
pub to: Pubkey,
pub id: String,
}
Solana stores data in accounts, and those structs are basically the types of our accounts, except for NewEmailEvent
.
The iv
and salt
are generated when the client encrypt data. And it is also used to decrypt it, they are not sensitive data so we can store them on the blockchain without fear.
Context.rs
Open your context.rs
file and paste the following:
use crate::state::{Mail, UserAccount};
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct SendMail<'info> {
#[account(
init,
payer = authority,
space =
8 + // discriminator
32 + // from
32 + // to
34 + // id
40 + // subject
512 + // body
32 + // authority
4 + // created_at
20 + // salt
36 // iv
)]
pub mail: Account<'info, Mail>,
pub system_program: Program<'info, System>,
#[account(mut)]
pub authority: Signer<'info>,
}
#[derive(Accounts)]
pub struct Register<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(
init,
payer = authority,
space =
8 + // discriminator
4 + 64 + // public key
32 + // authority
1, // bump
seeds = [b"user-account", authority.key().as_ref()],
bump
)]
pub user_account: Account<'info, UserAccount>,
pub system_program: Program<'info, System>,
}
/* helper table for calculating accounts spaces */
/*
bool 1 byte 1 bit rounded up to 1 byte.
u8 or i8 1 byte
u16 or i16 2 bytes
u32 or i32 4 bytes
u64 or i64 8 bytes
u128 or i128 16 bytes
[u16; 32] 64 bytes 32 items x 2 bytes. [itemSize; arrayLength]
PubKey 32 bytes Same as [u8; 32]
vec<u16> Any multiple of 2 bytes + 4 bytes for the prefix Need to allocate the maximum amount of item that could be required.
String Any multiple of 1 byte + 4 bytes for the prefix Same as vec<u8>
*/
Those structs are the context of the instructions. They hold and manage all the accounts that the instruction will interact.
An account will be managed by the #[account()]
macro, you declare if the account is mutable or not, if it is a new account to be initialized with x amount of space, the constraints that it must obey etc. Please read the official documentation about this here.
An instruction is just a normal function that will be called on the client to interact with the program.
We have 2 instructions: register
and send_email
.
Error.rs
Open your error.rs
file and paste the following:
use anchor_lang::prelude::*;
#[error_code]
pub enum ErrorCode {
#[msg("Invalid instruction")]
InvalidInstruction,
#[msg("The body of your email is too long. The max is 512 chars")]
InvalidBody,
#[msg("The subject of your email is too long. The max is 40 chars")]
InvalidSubject,
#[msg("The salt should be exactly 16 chars")]
InvalidSalt,
#[msg("The IV should be exactly 32 chars")]
InvalidIv,
#[msg("The diffie publickey should be exactly 64 chars")]
InvalidDiffie,
}
This simply maps an error to a message.
Utils.rs
Open your utils.rs
and paste the following:
use anchor_lang::prelude::Pubkey;
use uuid::Uuid;
/* creates a unique ID for a mail using now, body, and sender as arguments */
pub fn get_uuid(now: &u32, body: &String, sender: &Pubkey) -> String {
const V5NAMESPACE: &Uuid = &Uuid::from_bytes([
16, 92, 30, 120, 224, 152, 10, 207, 140, 56, 246, 228, 206, 99, 196, 138,
]);
let now = now.to_be_bytes();
let body = body.as_bytes();
let sender = sender.to_bytes();
let mut vec = vec![];
vec.extend_from_slice(&now);
vec.extend_from_slice(&body);
vec.extend_from_slice(&sender);
Uuid::new_v5(V5NAMESPACE, &vec).to_string()
}
We have only one helper function, the get_uuid
, this will take a few arguments and generate a unique id for each email.
Lib.rs
Open your lib.rs
file and paste the following:
use {crate::error::ErrorCode, anchor_lang::prelude::*, context::*, utils::*};
pub mod context;
pub mod error;
pub mod state;
pub mod utils;
declare_id!("9KVS65SWuX5jnmJkzpyXMCdeKpad9G5sSoKopUUgDiA");
#[program]
pub mod encrypted-mail {
use super::*;
use anchor_lang::Key;
pub fn send_email(
ctx: Context<SendMail>,
subject: String,
body: String,
from: Pubkey,
to: Pubkey,
salt: String,
iv: String
) -> Result<()> {
require!(subject.chars().count() < 50, ErrorCode::InvalidSubject);
require!(body.chars().count() < 280, ErrorCode::InvalidBody);
require!(salt.chars().count() == 16, ErrorCode::InvalidSalt);
require!(iv.chars().count() == 32, ErrorCode::InvalidIv);
let now = Clock::get().unwrap().unix_timestamp as u32;
let mail = &mut ctx.accounts.mail;
let id = get_uuid(&now, &body, &mail.key());
mail.from = from;
mail.to = to;
mail.id = id.clone();
mail.subject = subject;
mail.body = body; // encrypted body, a ciphertext
mail.created_at = now;
mail.salt = salt;
mail.iv = iv;
mail.authority = *ctx.accounts.authority.key;
emit!(state::NewEmailEvent {
from,
to,
id
});
Ok(())
}
pub fn register(ctx: Context<Register>, diffie_pubkey: String) -> Result<()> {
require!(diffie_pubkey.chars().count() == 64, ErrorCode::InvalidDiffie);
let user_account = &mut ctx.accounts.user_account;
user_account.diffie_pubkey = diffie_pubkey;
user_account.authority = *ctx.accounts.authority.key;
user_account.bump = *ctx.bumps.get("user_account").unwrap();
Ok(())
}
}
You need to change declare_id!
with your account id, Anchor will output this information when you run anchor build
.
The require!
ensures that the user passes the right data to the instructions. If the condition is false, it will return an error.
The *ctx.bump.get("user_account")
is an abstraction for generating a bump seed for this PDA, please read about PDA's here and here.
Now, that the program itself is done, we just need to make some tests on the client.
Testing
Add a few dependencies with this command: yarn add crypto-js elliptic text-encoding
Let's create a file /utils.ts
at the root of the project, this file will contain abstractions to make readability easier on our tests. Paste the following into the file:
import { PublicKey } from "@solana/web3.js";
import { TextEncoder } from "text-encoding";
import idl from "./target/idl/minerva.json";
import { getProvider } from "@project-serum/anchor";
import { ec } from 'elliptic'
export const DEVNET_WALLET = getProvider().wallet.publicKey;
export const getUserPDA = async (
seed: string,
authority: PublicKey = DEVNET_WALLET
) => {
const [PDA] = await PublicKey.findProgramAddress(
[new TextEncoder().encode(seed), authority.toBuffer()],
new PublicKey(idl.metadata.address)
);
return PDA;
};
export const elliptic = new ec('curve25519')
The getUserPDA
will generate a PDA for the user when he registers. We need to pass an array of seeds to generate the address.
The elliptic
is just an instance of the curve25519 class that we imported from the elliptic
library.
Open /tests/encrypted-mail.ts
and let's start testing. You can erase everything and paste the following code:
import {
Program,
workspace,
Provider,
setProvider,
} from "@project-serum/anchor";
import AES from 'crypto-js/aes'
import { enc, mode, lib } from 'crypto-js'
import { Keypair, SystemProgram } from "@solana/web3.js";
import { EncryptedMail } from "../target/types/encrypted-mail";
import { DEVNET_WALLET, getUserPDA, elliptic } from "../utils";
import { expect } from "chai";
describe("beggining encrypted-mail tests", () => {
setProvider(Provider.env());
/* generating diffie helmann keys */
const aliceKeypair = elliptic.genKeyPair()
const bobKeypair = elliptic.genKeyPair()
const aliceDiffiePublic = aliceKeypair.getPublic().encode("hex", true)
const bobDiffiePublic = bobKeypair.getPublic().encode("hex", true)
const sharedSecret = aliceKeypair.derive(bobKeypair.getPublic()).toString("hex")
/* generating blockchain wallets */
const alice = DEVNET_WALLET;
const bob = Keypair.generate();
const program = workspace.EncryptedMail as Program<EncryptedMail>;
}
We generate 2 key pairs, one for Alice, and one for Bob. We can generate a shared private key from the private of one of them, and the public of the other. It is the shared private that will be used to encrypt and decrypt the emails.
Let's add our first test, append the following code inside your describe
function:
it("can register alice and bob", async () => {
const aliceAccountPDA = await getUserPDA("user-account");
const bobAccountPDA = await getUserPDA("user-account", bob.publicKey);
const airdropTx = await program.provider.connection.requestAirdrop(
bob.publicKey,
1000000000
);
await program.provider.connection.confirmTransaction(airdropTx);
await program.rpc.register(aliceDiffiePublic, {
accounts: {
authority: alice,
userAccount: aliceAccountPDA,
systemProgram: SystemProgram.programId,
},
});
await program.rpc.register(bobDiffiePublic, {
accounts: {
authority: bob.publicKey,
userAccount: bobAccountPDA,
systemProgram: SystemProgram.programId,
},
signers: [bob]
});
const users = await program.account.userAccount.all();
console.log("users: ", users);
expect(users.length).to.equal(2);
});
This is very straightforward, the function start by generating the PDA for Alice and Bob and then calls the register
instruction from the program, and then we get all the userAccounts and check to see if they equal 2.
Finally, the last test is to encrypt the email, send the email, and decrypt it back.
it("can encrypt emails, send the emails, and decrypt it", async () => {
const mailA = Keypair.generate();
let cipher = AES.encrypt("simplesmente intankavel o bostil", sharedSecret, { mode: mode.CTR })
await program.rpc.sendEmail(
"very important subject", // subject
cipher.ciphertext.toString(), // body of email
alice, // from
bob.publicKey, // to
cipher.salt.toString(), // salt
cipher.iv.toString(), // iv
{
accounts: {
authority: alice,
mail: mailA.publicKey,
systemProgram: SystemProgram.programId,
},
signers: [mailA],
}
);
const emails = await program.account.mail.all();
const email = emails[0].account
const plaintext = AES.decrypt(
{
ciphertext: enc.Hex.parse(email.body),
iv: enc.Hex.parse(email.iv),
salt: enc.Hex.parse(email.salt)
} as lib.CipherParams,
sharedSecret,
{ mode: mode.CTR }
)
console.log("\n");
console.log("emails: ", emails);
console.log("\n");
console.log('plaintext: ', plaintext.toString(enc.Utf8))
console.log("shared_secret: ", sharedSecret);
console.log("cyphertext: ", emails[0].account.body);
console.log("\n");
expect(plaintext.toString(enc.Utf8)).to.equal('simplesmente intankavel o bostil');
});
First, we generate an email account, then encrypt the body of the email, and call the sendEmail
instruction.
After that, we get the email back and decrypt the body of the email. And check to see if the decrypted message is the same message that was encrypted.
To run the tests, first, you need to build the program with anchor build
. At the end of the build, Anchor will print your program id on your terminal, you need to copy this and replace on declare_id!
macro on lib.rs
and also on /Anchor.toml
. This file should look like this:
[programs.localnet]
encrypted-mail = "9KVS65SWuX5jnmJkzpyXMCdeKpad9G5sSoKopUUgDiA"
[programs.devnet]
encrypted-mail = "9KVS65SWuX5jnmJkzpyXMCdeKpad9G5sSoKopUUgDiA"
[registry]
url = "https://anchor.projectserum.com"
[provider]
cluster = "localnet"
wallet = "~/.config/solana/devnet.json"
[scripts]
test = "npx ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
Now you can run anchor deploy
and anchor test
. And that's it!
The end
I could not dive into every technical aspect of Solana in detail, because there would be just too much to be explained here, I prefer to focus on a more practical approach at first, you should read the official documentation of Anchor and Solana to understand better the theory behind all of this.
If you made it this far, congratulations! If you got lost at some point, you can check the source code here. This is my finished frontend/dapp of this program, make sure you use your wallet on the devnet. Also, feel free to send me a message if you have any questions.
Posted on April 2, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.