Self-Destructing Messages with Dark and React
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
takestext
that was encrypted on the client in aPOST
payload, puts it in the database and returns auuid
/download
takes theuuid
, 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-11'
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
andGET /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.