Ian Obermiller

Part time hacker, full time dad.

Self-Destructing Messages with Dark and React

Posted

Ever needed to send a password or other sensitive information to someone over email or text? Both mediums are insecure, unencrypted, and persistent, which means there are many opportunities for someone nefarious to find that information and exploit it.

There are many websites that offer to encrypt some text and allow it to be viewed only once from a given link. The problem with all of them is that I didn't write them, and have to trust their authors and the entire chain of deployment. I've used 1ty.me many times in the past and had no issues, but I figured it couldn't be that hard to build a simple clone, and it would be a good excuse to play around more with Dark.

To see what I'll be walking you through below, visit https://ianobermiller.com/secure.

Backend

Throughout this process I tried to do things the "Dark way" as much as possible, using "trace driven development" and relying on the repl. I went through a couple iterations for the API design, but ended up with this:

  • /upload takes text that was encrypted on the client in a POST payload, puts it in the database and returns a uuid
  • /download takes the uuid, returns the encrypted text, and deletes the payload

Database

First, I created a database to store the messages. It started out very simple, with only a text field of type String. Later, I added a createdAt field of type Date so that I can periodically clean up the database to remove old messages. Note that once a database has data, the schema cannot be changed, so you have to delete all the records first. That is easy enough by using a repl with the following:

DB::deleteAllv1 Messages

Upload

In the client UI, the user enters a message and clicks "Generate Link", which encrypts the data on the client and sends it to the /upload endpoint.

I wrote some very simple client-side code in TypeScript while testing in order to load a trace into Dark. It looked something like this:

const BASE_API =
  'https://ianobermiller-secure.builtwithdark.com/';

const response = await fetch(BASE_API + '/upload', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({text: 'Hello world'}),
});

Note: This works from localhost or from your own custom domain because Dark sets CORs headers by default.

You can use a snippet or the console from your browser to make the request without even standing up a site. Once you've made the request switch back to Dark and under 404s on the left side you can select the /upload endpoint and start coding:

let text = request.body.text
if String::lengthv1 text > 1024
then
  Http::badRequest "Text too long: text is limited to 256 ch
                   aracters"
else
  let key = DB::generateKey
  let _ = DB::setv1
            {
              text : text
              createdAt : Date::now
            }
            key
            Messages
  {
    id : key
  }

Before saving the message, the code rejects any requests that are too long. The client also enforces this, but those checks can be bypassed by sending a request directly. The checked length (1024) is actually greater than the reported max (256) because the client will encrypt and base64 encode the message, adding overhead.

Next, it generates a database key (a UUID) using DB::generateKey and inserts a record into the database with the current date. Finally, the last row returns a JSON object with the id, for example {id: "482e50a5-dbb5-4cc5-9fcd-0c94470f60b3"}. The client will put the ID into a url which the user will send to the message recipient.

To check that this code works and the database contains what I expect I can add a new repl with:

DB::getAllWithKeysv2 Messages

The result might look something like:

{
  5ee86e28-3ce9-4173-97b5-d68bdd8ca1e9: {
    createdAt: <Date: 2021-04-11T05:17:04Z>,
    text: "Hello world"
  }
}

Download

When the recipient visits the url, the client's JavaScript will call the /download endpoint.

Once again, to leverage trace-driven development I wrote a simple version of the client side code to generate a trace to write the backend:

const response = await fetch(BASE_API + '/download', {
  method: 'POST',
  body: JSON.stringify({
    id: '5ee86e28-3ce9-4173-97b5-d68bdd8ca1e9',
  }),
});

Once executed, I select /download in the 404s section and fill it in:

let entry = DB::getv2 request.body.id Messages
let _ = DB::deletev1 request.body.id Messages
{
  text : entry.text
}

Download simply fetches the message, deletes it, and returns the text. If any of the operations fail, like DB::get when the ID doesn't exist, the call will enter the "error rail" and return an empty response.

You might be wondering why I didn't go with a slightly more RESTful API, with routes like POST /message and GET /message/:id. First, the download request is not idempotent (since it deletes the message) and therefore shouldn't be a GET to begin with. Second, Dark works best if you have separate routes for each endpoint, allowing you to more easily switch between traces and keep the code separate.

Delete Expired Messages

For this simple application I want any messages to be automatically deleted if they are not read in 7 days. Dark supports scheduled jobs like this via a Cron component.

let deleteBeforeDate = Date::subtract Date::now 60 * 60 * 24 * 7
let keysToDelete = DB::queryWithKeyv3 Messages \entry -> entry.createdAt Date::<= deleteBeforeDate
Dict::map keysToDelete \key, value -> DB::deletev1 key Messages

I went back and added the created date and time to each database entry, so removing stale messages is a matter of computing the time 7 days ago, querying for messages created before that date, and deleting them.

Note that DB::queryWithKey is special -- it takes a lambda function and compiles it to a database query. You are limited as to what you can put inside, which is why the code to compute deleteBeforeDate is outside of the lambda. If you don't do this, you will get an error message like:

<Error: You're using our new experimental Datastore query compiler. It compiles your lambdas into optimized (and partially indexed) Datastore queries, which should be reasonably faster.

Unfortunately, we hit a snag while compiling your lambda. We only support a subset of Dark's functionality, but will be expanding it in the future.

Some Dark code is not supported in DB::query lambdas for now, and some of it won't be supported because it's an odd thing to do in a datastore query. If you think your operation should be supported, let us know in #general.

Error: We do not yet support compiling this code: (EBlank 1223731764)>

Since Dark does not yet have a bulk deletion command, the last line simply maps over all the keys and deletes them one by one.

Client

With the API feature complete, I built out the simple UI using fetch and React. The full source code can be found on GitHub. Overall, the code is pretty straightforward, with separate components for viewing a message, creating a message, and the creation confirmation screen. I've included more details of the interesting bits below.

Encryption

Since the whole point of a secure message is to send sensitive information, it is important that the server cannot read the data. So, the first step is to encrypt the data. To keep things simple, I used crypto.subtle with 128-bit AES encryption. A larger key would in theory be safer, but it results in longer urls and in practice 128 bits will be fine considering the short-lived nature of a self-destructing message.

The encryption code was copied and modified from (this gist)[https://gist.github.com/andreburgaud/6f73fd2d690b629346b8].

URL

The entire point of this project is to send someone a simple url, like https://ianobermiller.com/secure#rVCv88kDTLWAlToREDhbRQ=qwoiNo1iV6OFPpy_EcEVAw, and have them view the contents of the message. I went through a few iterations on the url before landing here.

First, I want the decryption key (the part after the = sign) to be in the hash of the url, since the hash is not sent to the server. This gives you extra confidence that the server cannot decrypt your message.

I originally tried to put the ID in the path, like /secure/rVCv88kDTLWAlToREDhbRQ, but realized that wouldn't work for a statically generated site deployment, which is how I deploy ianobermiller.com using Next.js on Vercel. Next I considered a url parameter, e.g. /secure?id=rVCv88kDTLWAlToREDhbRQ, but ended up sticking both the ID and the key in the hash to keep parsing simple. If the backend was moved to the same server, having the ID sent to the server in the original request would save a round trip, since the server could send back the encrypted message without waiting for the client code to download it.

You'll also notice that the ID in the url, rVCv88kDTLWAlToREDhbRQ doesn't look like the UUID we generated on the Dark backend. Since a UUID is just a 128 bit number, it can be re-encoded into a friendlier form. Here, I use the uuid library and url-safe base64 encoding to transform the UUID given from Dark into a shorter, more friendly looking random string:

import {parse as uuidParse} from 'uuid';
import {encode} from './base64';

function uuidToBase64(uuid: string): string {
  return encode(uuidParse(uuid));
}

For more details on the React components check out the full source code on GitHub.

Conclusion

This was a fun project to practice using Dark and stand up a simple but useful service for myself. Future improvements might include:

  • Emailing the sender when the link is viewed
  • Configurable length of time until self-destruction

Thanks for following along, and if you have any questions or comments please email or send a PR!

In the next post, we will update the app to send an email when the message is viewed.