Secrets Secrets are NoFun
Introduction
In an ideal developer world, everyone has access to those secrets (and only those secrets) they need. But that ain't the kind of world we live in. Sometimes you just need to share a secret. At my company, we use a simple, purpose-built web service for this: you input a secret, click "Create Secret," and receive a one-time-use link. Once accessed, the secret is permanently deleted from the server. While this approach is far better than sharing secrets outright in Slack, it has limitations—primarily, it introduces an external system when nearly all of our work happens in Slack, and it doesn't prevent secrets from being shared outside the organization.
To address these shortcomings, I built NoFun, a Slack app designed for secure, ephemeral secret-sharing within an organization. NoFun provides two key security benefits:
- Ensuring secrets are only shared within the org by leveraging Slack’s built-in permissions.
- Keeping secrets off the server, ensuring they are accessible only to the intended recipient.
As an added advantage, NoFun prioritizes ease of use, which goes a long way in determining whether security practices are actually followed.
Project setup
Creating a Slack app is fairly straightforward once you've made an account; you can create your app from scratch or from a manifest, here's NoFun's:
{
"display_information": {
"name": "NoFun"
},
"features": {
"bot_user": {
"display_name": "NoFun",
"always_online": false
},
"slash_commands": [
{
"command": "/nofun",
"url": "https://5a62-96-246-210-200.ngrok-free.app/commands/share-secret",
"description": "Does something with nofun",
"usage_hint": "[secret]",
"should_escape": false
}
]
},
"oauth_config": {
"scopes": {
"bot": [
"chat:write",
"chat:write.customize",
"commands",
"incoming-webhook"
]
}
},
"settings": {
"interactivity": {
"is_enabled": true,
"request_url": "https://5a62-96-246-210-200.ngrok-free.app/action"
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}
Backend Setup
For simplicity, NoFun is built as a lightweight Express application. After initializing the project with npm init
, installing dependencies, and setting up environment variables, the basic server setup (index.js
) looks like this:
const express = require('express');
const bodyParser = require('body-parser');
const crypto = require('crypto');
const { WebClient } = require('@slack/web-api');
const app = express();
const slackToken = process.env.SLACK_TOKEN;
const slackClient = new WebClient(slackToken);
const secretKey = Buffer.from(process.env.SECRET_KEY, 'base64');
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
// fun stuff goes here
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
Encryption Logic
Before setting up endpoints, we define helper functions for AES-CTR encryption to ensure that secrets are securely stored in transit:
const encrypt = (text, secretKey) => {
const algorithm = 'aes-256-ctr';
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
return {
iv: iv.toString('hex'),
content: encrypted.toString('hex')
};
};
const decrypt = (hash, secretKey) => {
const algorithm = 'aes-256-ctr';
const iv = Buffer.from(hash.iv, 'hex');
const encryptedText = Buffer.from(hash.content, 'hex');
const decipher = crypto.createDecipheriv(algorithm, secretKey, iv);
const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]);
return decrypted.toString();
};
Sending a secret
How do you send a message on Slack without making it searchable? With NoFun, secrets are shared via a slash command pointing to the app's /commands/nofun endpoint. This endpoint listens for the command and returns a modal view for entering the secret and selecting the recipient:
app.post('/commands/share-secret', async (req, res) => {
try {
const triggerId = req.body.trigger_id;
if (!triggerId) {
return res.status(400).send('Missing trigger_id');
}
const preFilledText = req.body.text || '';
const view = {
type: 'modal',
callback_id: 'submit-secret',
title: {
type: 'plain_text',
text: 'Share a Secret'
},
blocks: [
{
type: 'input',
block_id: 'secret_input',
label: {
type: 'plain_text',
text: 'Secret'
},
element: {
type: 'plain_text_input',
action_id: 'secret_text',
multiline: true,
initial_value: preFilledText
}
},
{
type: 'input',
block_id: 'user_select',
label: {
type: 'plain_text',
text: 'Select a user to share with'
},
element: {
type: 'users_select',
action_id: 'selected_user'
}
}
],
submit: {
type: 'plain_text',
text: 'Submit'
}
};
const result = await slackClient.views.open({
trigger_id: triggerId,
view: view
});
res.send('');
} catch (error) {
console.error('Error in share-secret command:', error);
res.status(500).send('An unexpected error occurred');
}
});
It may not look like much but Slack handles the modal UI, which ends up looking like this:
Submitting the modal sends a POST call to /action (defined in my app manifest as my app's request_url
. Calling this endpoint does one of two things depending on payload type: in this case, it's view_submission
, and NoFun first encrypts the secret before sending the recipient a new message from NoFun specifying the sender and rendering a 'Reveal Sender' button, with an associated value of the encrypted secret, IV for decryption, and the intended user ID:
app.post('/action', async (req, res) => {
const payload = JSON.parse(req.body.payload);
if (payload.type === 'view_submission' && payload.view.callback_id === 'submit-secret') {
const secretText = payload.view.state.values.secret_input.secret_text.value;
const senderId = payload.user.id;
const senderName = payload.user.name;
const selectedUserId = payload.view.state.values.user_select.selected_user.selected_user;
const encryptedSecret = encrypt(secretText, secretKey);
try {
await slackClient.chat.postMessage({
channel: selectedUserId,
text: `You have received a secret from <@${senderId}>.`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `You have received a secret from <@${senderId}>.`
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: 'Reveal Secret'
},
action_id: 'reveal_secret',
value: JSON.stringify({ iv: encryptedSecret.iv, content: encryptedSecret.content, intended_user: selectedUserId })
}
}
]
});
res.send('');
} catch (error) {
console.error('Error sending message:', error);
res.status(500).send('Failed to send message');
}
}
// logic for block_action payload type
}
Revealing a secret
Once the user clicks the 'Reveal Secret' button, another call is made to the same /action endpoint, this time with a block_action
payload type, triggering the secret's decryption and returning the decrypted secret to the intended recipient in a modal outside of Slack's message history:
app.post('/action', async (req, res) => {
const payload = JSON.parse(req.body.payload);
// logic for view_submission payload type
else if (payload.type === 'block_actions' && payload.actions[0].action_id === 'reveal_secret') {
const { iv, content, intended_user } = JSON.parse(payload.actions[0].value);
const triggerId = payload.trigger_id;
const userId = payload.user.id;
if (userId !== intended_user) {
await slackClient.chat.postMessage({
channel: userId,
text: "You are not authorized to view this secret."
});
return;
}
try {
const decryptedSecret = decrypt({ iv, content }, secretKey);
await slackClient.views.open({
trigger_id: triggerId,
view: {
type: 'modal',
callback_id: 'reveal-secret-modal',
title: {
type: 'plain_text',
text: 'Revealed Secret'
},
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${decryptedSecret}`
}
}
]
}
});
} catch (error) {
console.error('Error revealing secret:', error);
}
}
});
Again, Slack Handles the UX for us:
Conclusion
The code provided here gets the basic concept working in a controlled environment. In production, NoFun would benefit from some additional security/organization:
- Implementing rate limiting would mitigate the risk of brute force attacks.
- Introducing message expiry would prevent long-term exposure of sensitive data.
- having conditional blocks for one endpoint that do very different things is unintuitive and sloppy.
- End-to-end encryption would be very cool if Slack apps could easily access some form of local storage, for which there was no good/easy solution when I built this.
That said, you don't need to be a security engineer to build security features. You don't need to be an integration engineer to build awesome integrations, whether for personal use or a 1000+ person company. Slack itself was built on a pivot from a game company; we all make do with the tools at our disposal.
Surprise yourself.