In this post, we explore sending a secure message to a user that doesn’t have an account on your system and therefore cannot be authenticated. We’ll explore the use of HMAC verification tokens that you can email to a recipient as a single-use access code for a specific message or document.
Our example application is a document sharing tool. The tool gives read receipts and delivery notifications to the sender; e.g. for important letters or contracts that you need to know arrived at their destination. If both sender and receiver are already users, the application is fairly straightforward. The sender identifies the recipient, and the document is associated with that recipient’s identifier. When the recipient logs in, they can access the document and the sender gets a read receipt.
But what happens when the sender wants to send a document to someone who is not yet a user? We can’t send the document via email because we will not be able to establish a read receipt. Instead, we send the user a link to the document via email, allowing us to track reads. But this adds another complication; how do we guarantee that only the recipient is allowed to access that particular document?
Although we can’t guarantee that the recipient is the one accessing the document; we can take some steps to reduce the likelihood that a bad actor is accessing the document using HMAC verification tokens as described in the architecture below:
A basic outline is as follows:
- Store the document in the database and get a unique ID for it.
- Create an expiration date sometime in the future and a random nonce.
- Create a byte array that is the concatenation of the ID, expiration, and nonce.
- Generate a secret key and create an HMAC signature on the concatenation with it.
- Store the signature and concatenation in your database, send the secret key and message ID to the recipient via email.
- When the recipient is ready to access the document, they come to your site and present the secret key and message id.
- Look up the concatenation and signature associated with the ID, then verify that the document has not expired adn that the secret key creates the same HMAC signature – if so, you can allow access to the recipient.
A discussion of how and why this works is below, along with some implementation details in Go.
HMAC Signatures
An HMAC (hash-based authentication code) signature is a hash of some data generated by a cryptographic function that requires the exact original data and a secret key. It is used in TLS and other cryptographic mechanisms to guarantee that a message has not changed by a man-in-the-middle when sent between two parties.
Let’s say that Alice generates a message, m
and sends it to Bob; how does Bob know that m
is the original message that Alice sent and that a bad actor didn’t modify the message to m'
while it was on route? If Alice and Bob can share a secret key before Alice sends the message, then Alice can generate an HMAC signature, s
of message m
with that secret key and send it with the message. Bob then generates another HMAC signature t
on the message he receives with his secret. If s==t
then m
must be the original message and has not been modified.
If an attacker modifies even one bit and creates a message m'
then t
cannot match s
. Even if an attacker attempts to modify the message and the signature sent with the message, it will not work because the attacker will not have the secret key.
Discussion
What about simply using a random string and sending that to the user? In this case, there is the possibility that an attacker might guess the string and get access to a document they shouldn’t have. The more documents in your system, the more likely a guess will be associated with a document. This can be prevented with longer random strings, etc. But we can do better than relying on randomness for security.
It might also seem a bit odd that we’re sending the secret key to the recipient.The HMAC method described above requires the key to be shared ahead of time and the signature sent along with the message. However, by storing the signature and sending the key, we’re actually reducing the surface area of the attack in the admittedly rare case where the attacker might gain access only to our token generating database. If so, the attacker has enough information (the secret + the concatenated data) to generate the signature to access the document.
Finally, rate-limiting and expiration timestamps are also important to prevent access. Rate limiting reduces the number of guesses from a specific IP address and helps prevent brute force attacks. Expiration timestamps essentially limit the number of possible documents that can be accessed at any given time. Finally, if the document expires, a valid token can be used to generate a new token and sent to the original email, preventing unauthorized access in a two step process.
Implementation
Some example code is below to clarify the components to the reader. First create a message that needs to be sent to the user:
type Message struct {
ID uuid.UUID
Expiration time.Time
nonce []byte
}
func NewMessage(id uuid.UUID, expiration time.Time) *Message {
message := &Message{
ID: id
Expiration: expiration
nonce: make([]byte, 64)
}
// Make sure to use crypto/rand and to deal with errors!
rand.Read(message.nonce)
return message
}
func (m *Message) MarshalBinary() ([]byte, error) {
// Use your favorite data serialization method
}
func (m *Message) UnmarshalBinary([]byte) error {
// Use your favorite data serialization method
}
The message keeps the nonce
internal to ensure that it is randomly generated. The random nonce helps ensure that for the same ID and expiration a different HMAC verification token is generated and further increases the cryptographic complexity. However, make sure that your database storage mechanism also stores the nonce. The way I like to do this is to store the message binary as a BLOB in the database then use UnmarshalBinary
to recreate the data without exporting the nonce
.
To create the HMAC signature:
type SignedMessage struct {
Message
signature []byte // The HMAC signature computed from the Token data (read-only)
}
type Token []byte
func (m *Message) Sign() (token Token, signature *SignedMessage, err error) {
// Create a random secret key for signing
secret := make([]byte, 64)
if _, err := rand.Read(secret); err != nil {
panic(fmt.Errorf("no crypto random generator available: %w", err))
}
// Marshal the token for signing
var data []byte
if data, err = t.MarshalBinary(); err != nil {
return nil, nil, err
}
// Create HMAC signature for the token
mac := hmac.New(sha256.New, secret)
if _, err = mac.Write(data); err != nil {
return nil, nil, err
}
// Get the HMAC signature and append it to the verification data
// NOTE: this must happen after HMAC signing!
signature = &SignedMessage{
Token: *t,
signature: mac.Sum(nil),
}
// Create the verification token
token = make(Token, 80)
copy(token[0:16], t.SunriseID[:])
copy(token[16:], secret)
return token, signature, nil
We create two new types: a SignedMessage
that includes the signature
along with the original message data and a Token
, which is just an array of bytes – the UUID concatenated with the secret key. I like to send the token in the email as a base64 encoded string as shown below.
func (t Token) String() string {
return base64.RawURLEncoding.EncodeToString(t)
}
The message is marshalled into a binary form, then a rand secret key is generated and used to create the HMAC signature along with a SHA256 hash. This signature is stored alongside the message and the ID and the secret are concatened together to send to the recipient.
You should store the SignedMessage
in your database for retrieval later. I use the Scanner
and Valuer
interface for this, storing a binary representation of the signed message as a BLOB. This allows me to include the nonce
and the signature
in the binary data without worrying about exporting those fields. Just remember, you need to index on the ID
field, so store that separately!
func (t *SignedMessage) Scan(value interface{}) error {
data, ok := value.([]byte)
if !ok {
return ErrUnexpectedType
}
return t.UnmarshalBinary(data)
}
func (t *SignedMessage) Value() (_ driver.Value, err error) {
var data []byte
if data, err = t.MarshalBinary(); err != nil {
return nil, err
}
return data, nil
}
Now send the Token
to the user and store the SignedMessage
in your database. When the user presents you with the Token
look up the SignedMessage
in the database using the first 16 bytes which is the UUID of the message. The last 64 bytes is the secret key which can be verified as follows:
func (m *SignedMessage) Verify(token Token) (secure bool, err error) {
if len(token) != 80 {
return false, ErrSize
}
// Compute the hash of the current token for verification
var data []byte
if data, err = m.Message.MarshalBinary(); err != nil {
return false, err
}
// Generate the HMAC signature of the current token
mac := hmac.New(sha256.New, token[16:])
if _, err := mac.Write(data); err != nil {
return false, err
}
return bytes.Equal(m.signature, mac.Sum(nil)), nil
}
Conclusion
HMAC verification tokens are an excellent way to perform a one-time or one-access authentication for a user that is not a member of your system. We use them for inviting users to join a group, sending external messages via Whisper, confirming email addresses or phone numbers, and even for password resets!
AI Image Generated with Leonardo.ai