Index āŗ 20. End-to-end encrypted peer-to-peer Small Web apps
Start by creating a basic initial ephemeral, unauthenticated chat application and then build upon it to create an authenticated, peer-to-peer, end-to-end encrypted Small Web chat application using Kittenās unique first-class support for route authentication, ed25519 identities, and higher-level cryptogaphic primitives.
Topics covered
- Public-key cryptography, how to carry out Diffie-Hellman key exchange, and encrypt and decrypt data in Kitten using the Kitten Cryptography API.
- Testing peer-to-peer web applications (Small Web apps) locally during development using Kittenās localhost aliases.
- Detecting and presenting online/offline status.
- Debugging htmx using the htmx logger.
- Using authenticated routes.
š§ This tutorial has not been updated to use Kittenās new Streaming HTML workflow yet but it will be. In the meanwhile, you can compare code here with the Streaming HTML versions of the Kitten Chat examples.
Kitten chat
You can do a lot by using a WebSocket server to communicate with your application. You can use it, for example, in place of POST requests to send remote commands to the server and get asynchronous results back. Thatās really powerful in creating responsive applications. But youāre not limited to sending messages to just one page. You can also broadcast messages to all connected pages.
To see how that works, letās create a simple chat example, starting with the index page:
index.page.js
import styles from './index.styles.js'
// The page template is static so we render it outside
// the route handler for efficiency (so it only gets rendered
// once when this file first loads instead of on every request).
export default () => kitten.html`
<page htmx htmx-websocket>
<main>
<h1>š± <a href='https://codeberg.org/kitten/app'>Kitten</a> Chat</h1>
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data
>
<ul id='messages'>
<!-- Received messages will go here. -->
</ul>
<form id='message-form' ws-send>
<label for='nickname'>Nickname:</label>
<input
id='nickname' name='nickname' type='text' required
@htmx:ws-after-send.window='$el.value = ""'
>
<label for='text'>Message:</label>
<input id='text' name='text' type='text' required />
<button id='sendButton' type='submit'>Send</button>
</form>
</div>
</main>
${[styles]}
`
Also, create an index.styles.js file and add the following CSS rules to it so our interface fills up the whole browser window, with the majority of the space reserved for the chat messages.
š Weāve wrapped our entire interface a
<main>
tag. This is because we are going to add styles that wouldnāt work when applied to the<body>
tag that Kitten renders your page onto. You might have seen mainstream web frameworks render pages into a special div. Kitten tries to keep special cases to a minimum and doesnāt do that.
š” We could have just inlined the styles into the HTML block but since theyāre quite verbose, we decided to put them into their own file. Note that the syntax weāre using to include (interpolate) the styles into the HTML. By wrapping them in an array, we are telling HTMX to bypass any sanitisation it may otherwise perform on interpolated values. Needless to say, only do this with trusted content and never with data you obtain from an API call, etc. In those instances, use the global
kitten.safelyAddHtml()
function.
index.styles.js
export default kitten.css`
* { box-sizing: border-box; }
/* Make interface fill full browser canvas. */
main {
display: flex;
font-family: sans-serif;
height: calc(var(--vh, 1vh) * 100 - 1em);
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-content: stretch;
align-items: flex-start;
padding: 1em;
}
h1 {
margin-top: 0;
margin-bottom: 0;
}
p {
margin-top: 0;
margin-bottom: 0;
}
a {
color: #334b4c;
}
form {
background: #eee;
display: grid;
grid-template-columns: [labels] auto [controls] 1fr;
align-items: center;
grid-row-gap: 0.5em;
grid-column-gap: 0.5em;
padding: 0.75em;
width: 100%;
}
form > label { grid-column: labels; }
form > input, form > button {
grid-column: controls;
min-width: 6em;
max-width: 300px;
padding: 0.5em;
font-size: 1em;
}
button {
text-align: center;
cursor: pointer;
font-size:16px;
color: white;
border-radius: 4px;
background-color:#466B6A;
border: none;
padding: 0.75em;
padding-top: 0.25em;
padding-bottom: 0.25em;
transition: color 0.5s;
transition: background-color 0.5s;
}
button:hover {
color: black;
background-color: #92AAA4;
}
button:disabled {
color: #999;
background-color: #ccc;
}
/* The chat div should not affect the layout. This is actually a smell.
It is purely being used to declaratively create a WebSocket connection.
Need to think about this.
*/
#chat {
display: contents;
}
#messages {
list-style: none;
width: 100%;
flex: 100 1 auto;
align-self: stretch;
overflow-y: scroll;
background-color: #eee;
padding: 0.75em;
}
`
OK, and now, finally, letās create our socket route to handle the passing of messages between people.
chat.socket route
export default function ({ socket, request }) {
socket.addEventListener('message', event => {
// A new message has been received: broadcast it to all clients
// in the same room after performing basic validation.
const message = JSON.parse(event.data)
if (!isValidMessage(message)) {
console.warn(`Message is invalid; not broadcasting.`)
return
}
const numberOfRecipients = socket.all(
kitten.html`
<div hx-swap-oob="beforeend:#messages">
<li><strong>${message.nickname}</strong> ${message.text}</li>
</div>
`
)
// Log the number of recipients message was sent to
// and make sure we pluralise the log message properly.
console.info(` š«§ Kitten ${request.originalUrl} message from ${message.nickname} broadcast to `
+ `${numberOfRecipients} recipient`
+ `${numberOfRecipients === 1 ? '' : 's'}.`)
})
}
// Some basic validation.
// Is the passed object a valid string?
function isValidString(s) {
return Boolean(s) // Isnāt null, undefined, '', or 0
&& typeof s === 'string' // and is the correct type
&& s.replace(/s/g, '') !== '' // and is not just whitespace.
}
// Is the passed message object valid?
function isValidMessage(m) {
return isValidString(m.nickname) && isValidString(m.text)
}
Now, if you run kitten
and visit https://localhost you should see the chat interface and be able to send messages. Open another browser window to see the messages appear.
š” When you send a message, we are not optimistically copying it to the messages list on the client. In fact, our app currently has no custom client-side functionality at all. Weāve declared all dynamic functionally as htmx attributes in the HTML. So thatās why we use the socketās all() method to send received messages to all clients, including the one that originally sent the message. (Or else the person sending the message would not see it in the message list.) This also means that you can know that the socket is working even if you donāt open another browser tab or window to see the sent messages appear as long as theyāre appearing for you in your own window after being sent.
If we were optimistically updating the messages list with client-side logic, we would use the
broadcast()
method on the socket instead. This method ensures that a message is sent to all clients apart from the one that originally sent it.Also, due to the way htmxās WebSocket extension functions, the outer
<div>
(or any other outer element you specify in your response) is stripped by htmx before updating the document. So if you view source on your page, youāll see that only the list items are present in the list.
So weāve just written a very basic chat app without writing any custom client-side logic at all. Thatās pretty cool. But our chat app does have a number of usability issues that we could improve by sprinkling some more custom logic on the client using Alpine.js.
Letās start with the first-launch experienceā¦
When the page initially loads, the message list is empty. If youāve used the app before, then youāll know that thatās where the messages go but itās not overly friendly. So letās show a placeholder message there when there are no messages and hide it when the first message arrives.
Modify your index.page.js to add a placeholder list item to the #messages
list:
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='{ showPlaceholder: true }'
>
<ul id='messages' @htmx:load='showPlaceholder = false'>
<li id='placeholder' x-show='showPlaceholder'>
No messages yet, why not send one?
</li>
</ul>
ā¦
</div>
Run the app and verify that the placeholder gets hidden when the first message arrives in the message list.
The unordered list receives htmx:load
events whenever new data (a list item) is loaded. When it hears this, it sets the showPlaceholder
flag to false
. The placeholder list item is shown or hidden based on the state of this flag using the x-show
attribute.
The flag itself is declared in the parent <div>
using an x-data
attribute.
So that was simple.
But how about this: reduce the height of your chat window so that there is only space for two or three to messages to display. Now send yourself some messages and notice what happens when the fourth or fifth message comes in. Theyāre added to the list but they arenāt shown on screen as theyāre added to the bottom of the chat section.
Letās fix that by adding a bit more Alpine code to make the chat section scroll to the bottom whenever a new message is received:
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='{ showPlaceholder: true }'
>
<ul id='messages' @htmx:load='
showPlaceholder = false
$el.scrollTop = $el.scrollHeight
'>
<li id='placeholder' x-show='showPlaceholder'>
No messages yet, why not send one?
</li>
</ul>
ā¦
</div>
Getting syntax highlighting for Alpine.js
It might be that your editor supports syntax highlighting for embedded Alpine.js code by default. If so, thatās great and you can skip this section.
If it doesnāt, thereās a little trick you can use to get it in any editor that supports syntax highlighting for tagged template literals. Kitten comes with a simple one called kitten.js
that simply passes through anything itās passed. If you use it, however, you will get syntax highlighting for embedded code in editors like Helix Editor. It does add complexity to the code, however, and gives you yet another quote mark that you might have to escape if it appears in your code. For larger pieces of code, it is best to use a standard JavaScript file and include it at runtime in your page.
All that said, this is what the above code would look like if you used it:
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='${kitten.js`{ showPlaceholder: true }`}'
>
<ul id='messages' @htmx:load='${kitten.js`
showPlaceholder = false
$el.scrollTop = $el.scrollHeight
`}'>
<li id='placeholder' x-show='showPlaceholder'>
No messages yet, why not send one?
</li>
</ul>
ā¦
</div>
Adding a status indicator
Since a WebSocket is a persistent connection, it would be good to know when we get disconnected. htmxās WebSocket extension does a good job of queuing messages when this happens but it would help if we knew that we were offline (either because our Internet connection is disrupted or because the server has died).
So letās add a status indicator component that uses Alpine.js to achieve this:
StatusIndicator.component.js
export default () => kitten.html`
<p>Status: <span
id='status'
x-data='{ status: "Initialisingā¦" }'
@htmx:ws-connecting.window='status = "Connectingā¦"'
@htmx:ws-open.window='status = "Online"'
@htmx:ws-close.window='status = "Offline"'
x-text='status'
:class='
status === "Online" ? "online" : status === "Offline" ? "offline" : ""
'>
Initialisingā¦
</span></p>
<style>
.online {color: green}
.offline {color: red}
</style>
`
Finally, letās add the StatusIndicator
component to our index.page.js:
import styles from './index.styles.js'
import StatusIndicator from './StatusIndicator.component.js'
export default () => kitten.html`
<h1>š± <a href='https://codeberg.org/kitten/app'>Kitten</a> Chat</h1>
<${StatusIndicator} />
<div
id='chat'ā¦
`
Now, when you run Kitten Chat, you should see the indicator turn green when youāre online.
Stop Kitten and note that the indicator turns red and shows you that youāre offline.
Restart Kitten and note the indicator turns green again once the app reconnects.
š§ The htmx WebSocket extension implements an exponential backoff algorithm that keeps trying to connect to the server after exponentially longer waiting periods after getting disconnected. There is an issue with this implementation where this interval does not reset even if you close the browser tab. You actually have to restart the browser for the interval to reset. Iām going to look into filing a bug about this and hopefully contribute a fix upstream once I get a chance.
Debugging htmx
If some of the htmx stuff seams rather opaque and magical to you, thatās because, to a degree, it is. Making what htmx is doing visible by logging it might help you to debug your site or app when things go awry.
To that end, letās implement an htmx logger.
While Alpine.js lets you do commonly done things easily, itās sometimes easiest just to pop out into plain old JavaScript for more convoluted things.
Since this is just a regular web page, you can add any number of <script>
tags to it and load in external JavaScript by specifying its path.
So letās add a client-side .js file and implement our htmx logger in that.
š” Note that .js files are just regular client-side JavaScript files. They are served from your server like any other static file. Make sure you donāt put anything sensitive (like API keys, etc.) in them.
index.js
function onLoad() {
// For debugging: log out htmx events to the console.
htmx.logger = function(elt, event, data) {
if(console) {
console.info(event, elt, data)
}
}
}
window.addEventListener('load', onLoad)
š” The
htmx
global is available when htmx is included on the page so we wait for the documentās load event before attempting to attach the debugger.
Of course, just creating our script file isnāt enough, we also need to load it from the page. We could just add it to the end of our page (maybe after the closing <main>
tag) but then weād have a problem: we couldnāt guarantee that it gets loaded after htmx itself does. (And our logger function relies on htmx having loaded so that the htmx
global is available.)
What we really want is to add our script to the page after libraries like htmx have loaded. We can do this using the special page slots and the special Kitten tag called <content>
which we saw earlier in the Layout Components section:
ā¦
</main>
<content for='AFTER_LIBRARIES'>
<script src='./index.js' />
š” If we wanted to use ES6 Modules in our script, weād have to add
type='module'
to our script tag.
Adding final touches for mobile devices using custom JavaScript
Now that we have a place to put arbitrary client-side JavaScript, letās use a trick ā also known as āa hacky workaroundā (oh, hello, welcome to web development) ā to ensure our interface displays correctly even when the address bar is visible in mobile browsers.
Update your index.js file to match the following:
index.js
function fixInterfaceSizeOnMobile () {
function resetHeight() {
let vh = window.innerHeight * 0.01;
document.querySelector(':root').style.setProperty('--vh', `${vh}px`)
}
resetHeight()
window.onresize = function (_event) {
resetHeight()
}
}
function onLoad () {
fixInterfaceSizeOnMobile()
// For debugging: log out htmx events to the console.
htmx.logger = function(elt, event, data) {
if(console) {
console.info(event, elt, data)
}
}
}
window.addEventListener('load', onLoad)
While this is an issue that only surfaces on mobile devices, running the fix does not have a negative effect on desktop browsers so we just always run it when the page first loads by listening for the load
event on the window
.
Focus is hard sometimes
Finally, letās make one last usability improvement. Wouldnāt it be nice if we could keep chatting after sending a message without having to constantly click in the chat box? (In other words, if the chat box kept focus after sending a message, even if we click the Send button, for example.)
This is very easy to do using Alpine.js. Modify #text
input in the #message-form
as shown below:
<form id='message-form' ws-send>
ā¦
<input
id='text' name='text' type='text' required
@htmx:ws-after-send.window='
$el.value = ""
$el.focus()
'
/>
ā¦
</form>
š” Notice that weāre listening for the event a little differently here. Instead of listening for the
@htmx:ws-after-send
event on the<input>
element itself, weāre using the.window
modifier to listen for it on the window. Thatās because the event is dispatched from the<form>
element that contains thews-send
attribute. And that element is our parent so events dispatched from there will not bubble down to us (events in JavaScript bubble up). But the event will bubble up from there to the window so thatās where we listen for it.
Now run the example on a desktop computer and notice that the message box keeps its focus even if you press the Send button.
Thatās nice!
But now run the example on a mobile phone with a virtual keyboard. Ah. By keeping focus on the message field, weāre stopping the keyboard from hiding. And that means we canāt see the message we just sent. Thatās less than ideal. So letās implement another little hack by defining a function that tries to detect if the person is on mobile (remember that none of these hacks are ideal) and then letās see how we can call that JavaScript from our Alpine code.
First, add a function called isMobile()
to index.js:
globalThis.isMobile = () => {
return (/(android|bbd+|meego).+mobile|avantgo|bada/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)/|plucker|pocket|psp|series(4|6)0|symbian|treo|up.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(navigator.userAgent)
|| /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(navigator.userAgent.substr(0,4)))
}
š” Notice how we declared the function in global scope so we can call it from Alpine.js.
Now, modify your index.page.js one last time to only focus the field if the person is not on a mobile device:
<form id='message-form' ws-send>
ā¦
<input id='text' name='text' type='text' required
@htmx:ws-after-send.window='
$el.value = ""
// On mobile we donāt refocus the input box
// in case there is a virtual keyboard so
// the person can see the message they just sent.
if (!isMobile()) { $el.focus() }
'
>
ā¦
</form>
š” Take note that this approach is brittle. We are using the user agent string to make a best guess effort whether the person is on a mobile device. User agent strings can be spoofed. Beyond that, just because a person is on a mobile device, it doesnāt mean that theyāre using a virtual keyboard. They could have a physical keyboard attached. Unfortunately, there isnāt a built-in way of detecting from a web page whether someone is using a virtual keyboard. Although there are other hacks you might want to try.
You can find the final version of this example is the examples/kitten-chat folder.
Persisted Kitten Chat
While we were able to improve the usability of the Kitten Chat example by sprinkling a little Alpine.js here, a little JavaScript there on the client, there is one limitation that we need to implement more server-side functionality to overcome: the messages are not persisted.
If two people are having a chat and someone else enters the room, they donāt see the messages that have already been sent.
JavaScript Database (JSDB) to the rescue once again!
What we need to do is to persist messages when they arrive in our chat socket and display the messages that are already in our database while rendering our index page. Since both the socket and page route now need to create messages, letās start by creating a Message
component that can be used by both of them:
Message.component.js
export default function Message ({ message }) {
return kitten.html`
<li>
<strong>${message.nickname}</strong> ${message.text}
</li>
`
}
This component simply takes a message
object and render a list item that shows the personās nickname
and the text
of their message.
Now, letās refactor our WebSocket route (chat.socket.js) to:
- Create our messages table (if it doesnāt exist),
- Persist received messages to the messages table,
- Render messages using the Message component and send them to all connected clients.
chat.socket.js
import Message from './Message.component'
// Ensure the messages table exists in the database before using it.
if (kitten.db.messages === undefined) kitten.db.messages = []
// Kitten Chat example back-end.
export default function ({ socket, request }) {
socket.addEventListener('message', event => {
const message = JSON.parse(event.data)
if (!isValidMessage(message)) {
console.warn(`Message is invalid; not broadcasting.`)
return
}
// We donāt need to use the message HEADERS, delete them so
// they donāt take unnecessary space when we persist the message.
delete message.HEADERS
// Persist the message in the messages table.
kitten.db.messages.push(message)
const numberOfRecipients = socket.all(
kitten.html`
<div hx-swap-oob="beforeend:#messages">
<${Message} message='${message}' />
</div>
`
)
// ā¦
}
}
// ā¦
š” Messages sent by the htmx WebSocket extension include a htmx-specific
HEADERS
object.For example:
{ nickname: 'Aral', text: 'Hello, everyone!', HEADERS: { 'HX-Request': 'true', 'HX-Trigger': 'message-form', 'HX-Trigger-Name': null, 'HX-Target': 'chat', 'HX-Current-URL': 'https://localhost/' } }
All we want to store in the database is the
nickname
andtext
so we delete theHEADERS
object before persisting so weāre left with an array of objects like the following in our database:
{ nickname: 'Aral', text: 'Hello, everyone!' }
Finally, letās modify our index page to both use our new Message
component and, if there are any messages in the database, to render them in the page:
index.page.js
import styles from './index.styles.js'
import Message from './Message.component.js'
import StatusIndicator from './StatusIndicator.component.js'
// Ensure the messages table exists in the database before using it.
if (kitten.db.messages === undefined) kitten.db.messages = []
export default () => kitten.html`
ā¦
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='{ showPlaceholder: true }'
>
<ul id='messages' @htmx:load='
showPlaceholder = false
$el.scrollTop = $el.scrollHeight
'>
<if ${kitten.db.messages.length === 0}>
<then>
<li id='placeholder' x-show='showPlaceholder'>
No messages yet, why not send one?
</li>
<else>
${kitten.db.messages.map(message => kitten.html`<${Message} message=${message} />`)}
</if>
</ul>
ā¦
</div>
`
š” Since the conditional logic in our template is somewhat verbose, I chose to use Kittenās
<if>
conditional. The thing to be aware of here is that, due to JavaScriptās language limitations, the<if>
conditional cannot short circuit (so every branch of the conditional is evaluated, even if it is false, even if only the the true branch is displayed). This means that if you try to access a property on an object that is null or undefined in the falsey branch, your app will return an error. In this case, it is not a problem but, if it was, you would either use chained optionals in all your branches or one of the following JavaScript-only conditional methods.Hereās a separate example to show how chained optionals would work:
let ok = false const a = {} if (ok) a.b = [] html` <if ${ok}> <ul> ${a.b?.map(c => `<li>${c}</li>`)} </ul> <else> <p>Sad trombone.</p> </if> `
And here are the three different ways you can implement conditional logic in your templates using regular JavaScript:
The first one, which resembles Kittenās
<if>
syntax the most, is to use an immediately-invoked closure:
${(messages => { if (messages.length === 0) { return kitten.html` <li id='placeholder' x-show='showPlaceholder'> No messages yet, why not send one? </li> ` } else { return messages.map(message => kitten.html`<${Message} message=${message} />`) } })(kitten.db.messages)}
If thatās confusing to read, you can also write it as a regular immediately-invoked function expression (IIFE):
${(function (messages) { if (messages.length === 0) { return kitten.html` <li id='placeholder' x-show='showPlaceholder'> No messages yet, why not send one? </li> ` } else { return messages.map(message => kitten.html`<${Message} message=${message} />`) } })(kitten.db.messages)}
Or, if thatās still confusing, you can always use a conditional (ternary) operator. Notice how you refer to
kitten.db.messages
directly if you do this.
${ kitten.db.messages.length === 0 ? kitten.html` <li id='placeholder' _='on htmx:load from #chat hide me'> No messages yet, why not send one? </li> ` : kitten.db.messages.map(message => kitten.html`<${Message} message=${message} />`) }
All three of these approaches are equivalent and, since they use native JavaScript statements, all of them short circuit. Feel free to use the one that reads best for you.
And thatās it: now when you run the app and load it in your browser, you will see any messages that were sent previously when the page first loads.
I guess persistence really does pay off.
(Iām here all week. š±)
Project-specific secret
Kitten automatically generates a cryptographically-secure secret for each project.
For the cryptographers among you, this is a base256-encoded ed25519 private key. For everyone else, itās a lovely string of 32 emoji that looks something like this:
š»š¦š°šØš®š¼šš¦š¦š¦šŖ“šššš®šš§šš¦šš§šØššš§š„šµš®š„š¦šš»
This secret is shown to you only the very first time you run Kitten on a given project (folder).
If youāre wondering how in the world you are going to type that in, donāt worry: youāre not supposed to be able to by design.
Instead, please add this secret to your password manager of choice.
If you implement authenticated routes in your application, you can use your password manager to enter your secret for you.
For technical details, please see the Cryptographical Properties section.
Authenticated Routes
To signal to Kitten that a route is only available when the person is authenticated, you add š
to the end of the route name (thatās a lock emoji).
Kitten itself has just a route thatās available to all apps/sites created with Kitten at:
settingsš/
If you hit the /settings route on any Kitten app/site, youāll be automatically redirected to the /sign-in route if youāre not authenticated and you only see the settings section if you are.
Adding the suffix to a directory, as shown here, ensures that it applies to all routes in that directory.
You can also add it to specific routes (files).
HTTP Routes
Weāve seen examples of simple Kitten apps that use pages and WebSockets, but what if you want to POST data from a web form, implement Ajax with fragments of HTML using htmx, or create an Application Programming Interface (API) that returns JSON?
Enter HTTP Routes.
(OK, technically speaking, everything is an HTTP route but thatās the terminology we use in Kitten to separate Pages from, well, every other HTTP route except WebSocket routes.)
Similar to how you create pages in .page.js files and WebSocket routes in .socket.js files, HTTP routes are declared using a naming convention based on their filename extension which can be any valid HTTP1/1.1 method in lowercase (e.g., .get.js, .post.js, .patch.js, .head.js, etc.)
HTTP Routes do not carry out any processing on whatever value you return for them.
So you can return a fragment of HTML (e.g., a component) if youāre implementing Ajax, or use JSON.stringfy()
to return a JSON repsonse, etc.
e.g.,
my-project
ā index.page.js
ā index.post.js
ā about
ā ā° index.page.js
ā todos
ā ā° index.get.js
ā° chat
ā° index.socket.js
Optionally, to organise larger projects, you can encapsulate your site within a src folder. If a src folder does exist, Kitten will only serve routes from that folder and not from the project root.
e.g.,
my-project
ā src
ā ā index.page.js
ā ā index.post.js
ā ā index.socket.js
ā ā° about
ā ā° index.page.js
ā test
ā ā° index.js
ā° README.md
POST/redirect/GET
One very common HTTP Route is POST, usually used when you want to send data back from a page and persist it.
The Guestbook example (examples/guestbook), demonstrates this pattern in the simplest possible way.
First, letās create a page that will display the form for signing the guestbook and existing guestbook entries:
index.page.js
if (!kitten.db.entries) kitten.db.entries = []
export default () => kitten.html`
<h1>Guestbook</h1>
<h2>Sign</h2>
<form method='POST' action='/sign'>
<label for='message'>Message</label>
<textarea id='message' name='message' required></textarea>
<label for='name'>Name</label>
<input type='text' id='name' name='name' required />
<button type='submit'>Sign</button>
</form>
<h2>Entries</h2>
${kitten.db.entries.length === 0 ?
kitten.html`<p>Hey, no oneās signed yet⦠be the first?</p>`
:''}
<ul>
${kitten.db.entries.map(entry => kitten.html`
<li>
<p class='message'>${entry.message}</p>
<p class='nameAndDate'>${entry.name} (${new Date(entry.date).toLocaleString()})</p>
</li>
`)}
</ul>
<style>
body { font-family: sans-serif; margin-left: auto; margin-right: auto; max-width: 20em; }
label { display: block; }
textarea, input[type='text'] { width: 100%; }
textarea { height: 10em; }
button { width: 100%; height: 2em; margin-top: 1em; font-size: 1em; }
ul { list-style-type: none; padding: 0; }
li { border-top: 1px dashed #999; }
.message, .nameAndDate { font-family: cursive; }
.message { font-size: 1.5em; }
.nameAndDate { font-size: 1.25em; font-style: italic; text-align: right; }
</style>
`
This is very straightforward. Notice that we have a form for signing the guestbook and itās just plain HTML.
<form method='POST' action='/sign'>
<label for='message'>Message</label>
<textarea id='message' name='message' required></textarea>
<label for='name'>Name</label>
<input type='text' id='name' name='name' required />
<button type='submit'>Sign</button>
</form>
Its method
is set to POST
and its action is /sign
. That means that when the submit button is pressed, it will carry out an HTTP POST request to the /sign
route on our server.
In that route, we will save the new guestbook entry and then redirect the personās browser back to the index page. This pattern of handling a POST request and then redirecting to a GET route (our pages are all GET routes), is called the POST/redirect/GET pattern.
So letās create our POST route:
sign.post.js
if (!kitten.db.entries) kitten.db.entries = []
export default ({ request, response }) => {
// Basic validation.
if (!request.body || !request.body.message || !request.body.name) {
return response.forbidden()
}
kitten.db.entries.push({
message: request.body.message,
name: request.body.name,
date: Date.now()
})
response.get('/')
}
And thatās it.
š” Kitten has a number of request and response helpers defined to make your life easier. You just used two of them, above:
response.forbidden()
, which returns a HTTP 403: Forbidden error andresponse.get()
, which is an alias forresponse.seeOther()
, which returns and HTTP 303: See Other response.Additionally, Kitten also has response helpers for returning JSON (
request.json()
) and a JSON file that triggers the download mechanism in a browser (request.jsonFile()
).In this case, since weāre going a Post/Redirect/Get (PRG), using the
get()
alias makes the intent of our code clearer.You could also have manually handled the direction like this:
response.statusCode = 303 response.setHeader('Location', '/') response.end()
(Which is exactly what Kitten does internally when you use the
.get()
/.seeOther()
methods.)In addition to the methods youāve already seen, Kitten also supports the following helpers:
Request
is (array|string)
: returns true/false based on whether thecontent-type
of the request matches the string or array of strings presented.This is used internally for Express Busboy compatibility but you might find it useful in your apps too if youāre doing low-level request handling.
Response
get (location)
,seeOther (location)
: 303 See Other redirect (always uses GET).
redirect (location)
, temporaryRedirect (location)`: 307 Temporary Redirect (does not change the request method).
permanentRedirect (location)
: 308 Permanent Redirect (does not change the request method)
badRequest (body?)
: 400 Bad Request.
unathenticated (body?)
,unauthorised (body?)
,unauthorized (body?)
: 401 Unauthorized (unauthenticated).
forbidden (body?)
: 403 Forbidden (request is authenticated but lacks sufficient rights ā i.e., authorisation ā to access the resource).
notFound (body?)
: 404 Not Found.
error (body?)
,internalServerError (body?)
: 500 Internal Server Error.
Run kitten
command on your project folder and visit https://localhost to see your guestbook.
š” Notice that weāre doing some very basic validation to make sure that body of the request (which is where the formās data is found) is as we expect it.
In case youāre worried about script inject, type
<script>alert("Hehe, I just hacked you!")</script>
in your message box. Try it out and see what happens. Kittenās template engine automatically escapes interpolated string content to avoid such attacks. If you wanted to allow HTML through, Kitten provides a globalkitten.safelyAddHtml()
function you can call that sanisitises the input before allowing it. While it comes with intelligent defaults, you can also customise exactly what you want to let through or not.You can test out the basic server-side validation using a basic
curl
command. First, letās send a bad request and see what we get. In this case, weāre not sending any data at all:curl --include --data-urlencode '' https://localhost/sign/
And we see that our validation works:
HTTP/1.1 403 Forbidden Access-Control-Allow-Origin: * Set-Cookie: sessionId=LbhwT0YqkVyAzQR0S-2KFGPa; Max-Age=28800000; Path=/; HttpOnly; Secure; SameSite=Strict Date: Fri, 11 Aug 2023 12:36:23 GMT Connection: keep-alive Keep-Alive: timeout=5 Content-Length: 0
(The
--include
flag is what tells curl to print out the response header we received.)Finally, letās send a valid request and sign the guestbook from the command-line like proper nerds:
curl --include --data-urlencode 'message=From curl with love.' --data-urlencode 'name=Curl' https://localhost/sign/
This time, we get a much nicer response:
HTTP/1.1 303 See Other Access-Control-Allow-Origin: * Set-Cookie: sessionId=LMw_wcABaqRNWyhYmF7fvHFJ; Max-Age=28800000; Path=/; HttpOnly; Secure; SameSite=Strict Location: / Date: Fri, 11 Aug 2023 12:37:35 GMT Connection: keep-alive Keep-Alive: timeout=5 Content-Length: 0
Itās telling us that we should see the
/
route. So, letās. Go back to your browser and refresh the main page and you should see the guestbook entry from curl.
Multipart forms and file uploads
Kitten has high-level support for multi-part form handling and file uploads.
Uploads sent to POST
routes via <input type='file'>
in your pages are automatically saved in your projectās uploads folder. Kitten automatically assigns them unique IDs and serves them from the /uploads/<unique-id>
route. The Upload objects are also available to your POST
routes in the request.uploads
array.
š” An upload object has the following properties:
.id // Unique id. Used to look up uploads and calculate resource paths. .fileName // Name of the original file that was uploaded. .filePath // Absolute path to uploaded file on server. .resourcePath // Relative URL resource path the upload can be downloaded from. .mimetype // MIME type of file. .field // Name of file upload field in form that file was uploaded from. .encoding // Encoding of file. .truncated // Whether file was truncated or not (boolean). .done // Whether upload was successfully completed or not (boolean).
And the following method:
.delete() // Deletes the upload.
A common idiom is to save the uploadās unique ID (e.g., request.uploads[0].id
), along with any other data in your form (e.g., the alt-text of an image upload), in your own database tables. Then, when you want to, say, render an uploaded image on a page, you can use the global kitten.upoads
object to reference the upload you need and access its resource path.
e.g.,
kitten.html`
<img src='${kitten.uploads.get(uploadId).resourcePath}' alt='ā¦'>
`
š” The
kitten.uploads
collection has the following methods:
.get(id) // Returns Upload object with given ID (or undefined, if it doesnāt exist). .all() // Returns array of all Upload objects. .allIds() // Returns array of strings of all Upload object IDs. .delete(id) // Deletes object with given id (or fails silently if it doesnāt exist).
Kitten can handle multiple file uploads as well as single ones.
š”Note that you must set the
enctype='multipart/form-data'
attribute on your forms for file uploads to work correctly.
The following basic example shows just how easy it is to handle file uploads in Kitten. In it, you can upload one image at a time along with its alt-text and displays them in a grid at the top of the page:
index.post.js
export default function ({ request, response }) {
request.upoads.forEach(upload => {
kitten.db.images.push({
path: upload.resourcePath,
altText: request.body.altText ? request.body.altText : upload.fileName
})
})
response.get('/')
}
index.page.js
if (!kitten.db.images) kitten.db.images = []
export default () => kitten.html`
<h2>Uploaded images</h2>
<if ${kitten.db.images.length === 0}><p>None yet.</p></if>
<ul>
${kitten.db.images.map(image => kitten.html`
<img src=${image.path} alt=${image.altText}>
`)}
</ul>
<h2>Upload an image</h2>
<form method='post' enctype='multipart/form-data'>
<label for='image'>Image</label>
<input type='file' name='image' accept='image/*'>
<label for='alt-text'>Alt text</label>
<input type='text' id='alt-text' name='altText'>
<button type='submit'>Upload</button>
</form>
<style>
body { max-width: 640px; margin: 0 auto; padding: 1em; font-family: sans-serif; }
ul { padding: 0; display: grid; grid-template-columns: 1fr 1fr; }
img { max-height: 30vh; margin: 1em; }
input { width: 100%; margin: 1em 0; }
button { padding: 0.25em 1em; display: block; margin: 0 auto; }
</style>
`
You can find the code for the above example in examples/file-uploads.
End-to-end encrypted Kitten Chat
So now youāve learned how to use WebSockets, authenticated routes, HTTP routes, and how to carry out global tasks using a main.script.js file in Kitten. How about we put it all together and sprinkle some of Kittenās built-in cryptography support to create an end-to-end encrypted version of the Kitten Chat example.
š” This is just a basic example of implementing end-to-end encryption. It is not meant to be used in production or in real world situations for private communication.
š§ Some of the elements you see in this example (like the remote message emitter and the means of retrieving the public keys of remote servers and delivering messages to them) will be implemented in a production-ready manner in Kitten itself soon. Once this happens, Iāll either add a separate example that uses the built-in APIs or update this example to use them based on which I find more useful at the time.
Encryption and threat models
Nothing is entirely secure. Some things are secure enough.
In many situations, the best we can do is to raise the cost of surveillance to ensure that it is only used in specific cases (as opposed to mass surveillance).
End-to-end encryption is one of the means we have open to us for raising the cost of surveillance.
š” Itās important to understand your threat model.
Kittenās security model does not protect you against targetted surveillance by determined adversaries that could compromise your server to install and run their own compromised version of your Kitten app.
Barring any potential vulnerabilities in Kitten, if you are hosting your app using a commercial web host, this means that they will have had to infiltrate your web host and install an app that can steal your secret. This would normally require either a determined person working at the web host or a state-level actor.
If youāre hosting your app on your own hardware at a physical location you control, it would require the compromise of that location.
What end-to-end encryption mainly protects against is the opportunistic person at your web host being able to read your messages even if they compromise your machine.
Messages are already protected in transit via TLS. Their being end-to-end encrypted means that they are also protected at rest in the database on the server.
Since your secret remains on the client (the browser), implementing end-to-end encryption requires the use of JavaScript.
So, in this example, we will be making use of htmx and Alpine.jsās event handling to encrypt and decrypt messages in the browser using JavaScript. The server will only ever see the encrypted text (or as we call it in cyptography, the ciphertext) and never the unencrypted text (or plaintext.)
Weāll start from where we left off in the Persisted Kitten Chat example.
Private vs public routes
In the Persisted Kitten Chat example, we hadnāt implemented authentication and all our routes were public. Anyone could join the chat and, if they wanted to, even bypass our web interface and connect directly to our chat socket.
Needless to say, thatās not something youād normally implement outside of a simple example for a tutorial.
For our end-to-end encrypted chat example, we need to decide which routes we keep public and which ones must be private.
To begin with, make a copy of the Persisted Kitten Chat example and letās create a directory that will require any route placed in it require authentication:
mkdir privateš
š” We saw earlier that we can use the built-in authentication system in Kitten to easily create private routes by appending them with the lock emoji (š).
Now, letās copy all the files that were previously public into our new private folder.
If you were to run the example now and hit https://localhost, youād get the 404 Kitten since we no longer have an index.page.js in the root of our project (everything is in the private folder). So letās add a simple index.html file in the root of our project with a link that takes us to the chat. This is a page anyone will be able to access:
index.html
<h1>Public site</h1>
<a href='/private'>End-to-end encrypted Kitten chat</a>
š” Notice how we didnāt have to add the lock emoji in the link. Kitten is clever enough to strip it off when creating its route patterns.
Now, weāre going to run Kitten a little differently, by explicitly specifying the domain instead of using localhost:
kitten --domain=place1.localhost
Weāre doing this because to test the end-to-end encrypted Kitten chat, we will need to fire up two different instances of the Kitten server so we can use them to chat. Since weāre testing locally, we need to use a subdomain that is an alias for localhost but will be treated as a different domain by browsers (this is to ensure that cookies are isolated between the instances as cookies are not isolated by port but by domain).
Kitten has built-in support for four localhost aliases to help you test the peer-to-peer features of Small Web apps (place1.localhost to place4.localhost).
š” Subdomains on localhost Just Work ⢠on Linux but on macOS you have to manually edit your /etc/hosts file to map the subdomains to 127.0.0.1.
Once youāve made the changes, your hosts file should resemble the one below:
## # Host Database # # localhost is used to configure the loopback interface # when the system is booting. Do not change this entry. ## 127.0.0.1 localhost place1.localhost place2.localhost 255.255.255.255 broadcasthost ::1 localhost
Now, hit https://place1.localhost and you should see the link to the private chat section.
š” When the server starts, Kitten will generate a new secret for you. Note this down in your password manager now.
Follow the link on the page and you should reach Kittenās automatically-generated Sign In page at /sign-in.
Enter the secret you had saved in your password manager to sign in.
š” Did you forget to note it down in your password manager? Thatās OK. If you ever forget the secret for a project youāre testing locally, you can follow the āDatabasesā link that Kitten displays when itās run and delete the folder that holds the database for your project. The next time you run Kitten, it will recreate the password. Donāt forget to note it down this time :)
You should now see the chat interface from before and the chat should function exactly as the Persisted Kitten Chat example did.
Now, letās change it so our messages are end-to-end encrypted.
privateš/index.page.js
First, letās update our interface based on how we envision the chat to work when end-to-end encryption is implemented.
Starting with the interface and working inwards from there is whatās known as āoutside-in design.ā It lets us concentrate on how our tool is going to be used first before we get bogged down on the implementation details.
The first change weāre going to make is to the <div>
tag where the htmx socket is defined.
There, weāre going to have htmx prevent the wsConfigSend
event and call our encryptMessage()
handler so we can encrypt the message before it is sent. (We will then manually send the message ourselves.)
<div
id='chat'
hx-ext='ws'
ws-connect='/private/chat.socket'
x-data='${kitten.js`{ showPlaceholder: true }`}'
@htmx:ws-config-send.prevent='encryptMessage'
>
The only other change weāre going to make is to change the names and labels of the form elements.
Since our app will now enable people at different domains to chat to each other securely, we need to specify which domain we are sending our message to. And, finally, we rename the message itself from text
to plainText
to make it very clear that this is the message before it is encrypted.
<form id='message-form' ws-send >
<label for='domain'>To:</label>
<input id='domain' name='domain' type='text' required />
<label for='plainText'>Message:</label>
<input id='plainText' name='plainText' type='text' required
ā¦
/>
ā¦
</form>
The rest of the page stays the same.
Weāll look at how we implement the encryptMessage()
function later but first, weāve only specified when we should encrypt messages. We havenāt specified when we should decrypt them.
So letās think about that next.
privateš/Message.component.js
We mentioned earlier that the server only ever sees the ciphertext and never the plaintext. So we know that both encryption and decryption must take place on the client (in the browser) using client-side JavaScript.
We also know that, given how htmx works, the chat socket sends new messages as HTML snippets to the client.
Finally, we know that if we want to run custom JavaScript, we can use Alpine.js to do so declaratively.
So, letās combine all this and take a look at how we must modify the Message.component so that messages are automatically decrypted as they load in the browser:
export default function Message ({ message }) {
return kitten.html`
<li
x-data='{
messageText: "${kitten.sanitise(message.cipherText)}"
}'
x-init='$nextTick(() => messageText = decryptMessage("${kitten.sanitise(message.cipherText)}", "${kitten.sanitise(message.from)}", "${kitten.sanitise(message.to)}"))'
>
<strong>${kitten.sanitise(message.from)} ā ${kitten.sanitise(message.to)}</strong> <span x-text='messageText'>${kitten.sanitise(message.cipherText)}</span>
</li>
`
}
What weāre doing here is using Alpine.jsās x-data
directive to set up our data model for the list item node. In it, we populate a property called messageText
with our messageās ciphertext.
Then, we write up the x-init
directive so that when the node is initialised, we call a function called decryptMessage()
, passing it our ciphertext as well the domains that are the sender and receiver of our message.
š” Notice that the
x-init
handler is wrapped in a call for Alpine.jsās magic$nextTick
property. This makes Alpine.js wait until DOM updates are finished so we donāt call the decryption handler before it has had a chance to load.
So whatās happening here is that the ciphertext is sent from the server to the client and, before the message is displayed, it is decrypted on the client and the plaintext is shown in the interface.
Now, letās actually add the encryptMessage()
and decryptMessage()
function implementations to the client-side script imported by our page.
privateš/index.js
Our functions will make use of the built-in cryptography functions in the Kitten Cryptography API. This is a library Kitten serves at runtime from the route /š±/library/crypto-1.js:
import { encryptMessageForDomain, decryptMessageFromDomain } from '/š±/library/crypto-1.js'
globalThis.encryptMessage = async function (event) {
// Encrypt the plain text and send that in the message to the server.
const parameters = event.detail.parameters
const ourPrivateKey = localStorage.getItem('secret')
const encryptedMessage = await encryptMessageForDomain(parameters.plainText, ourPrivateKey, parameters.domain)
event.detail.socketWrapper.send(
JSON.stringify({
cipherText: encryptedMessage,
from: window.location.host,
to: parameters.domain
}),
event.detail.elt // elt = DOM element
)
}
globalThis.decryptMessage = async function (cipherText, fromDomain, toDomain) {
if (fromDomain === window.location.host) {
// This is a message we sent. Since the shared secret is commutative (g^jk === g^kj, or, in other words,
// can either be our private key and their public key or vice-versa), we just flip the from/to domains :)
fromDomain = toDomain
}
const ourPrivateKey = localStorage.getItem('secret')
return await decryptMessageFromDomain(cipherText, ourPrivateKey, fromDomain)
}
Encryption
Remember that the encryptMessage()
function is called by htmx before a message is sent over the socket to the server. (Specifically, when the htmx:wsConfigSend
event fires.)
Based on the htmx documentation, we know that the event
argument will contain a detail
property, which, itself, will contain a parameters
object with the form data, an elt
property that holds a reference to the DOM node that holds the socket, and a socketWrapper
that has a send()
method we can use to manually send the message after weāve encrypted it.
So we:
Retrieve our secret key from local storage (this was automatically saved there for us by Kitten when we signed in.)
Call the
encryptMessageForDomain()
function we imported from Kittenās cryptography library and pass it the plainText and domain from the form as well as our secret (or private key as itās known in cryptography).Finally, manually create a JSON message that contains the encrypted text (cipherText) as well as the
from
andto
properties that address the sender (our domain, which we get fromwindow.location.host
so it includes our port number, which is important when testing locally) and the receiver (the remote domain, which we manually enter into the Domain: textbox in the interface).
Decryption
Similarly, remember that the decryptMessage()
function is called by Alpine.jsās x-init
directive when a new Message component is received from the server via the WebSocket. And we saw in the Message.component that we pass in the ciphertext, sender, and receiver as arguments.
So all we do here is to retrieve our secret (private key) from local storage again, check to see if weāre the sender and, if so, take advantage of the associativity property of Diffie-Hellman shared secrets to swap the receiver and sender domains thereby eventually resulting in the decryption of the message using our own public key.
To understand that last bit more fully, letās also take a look at the encryptMessageForDomain()
and decryptMessageFromDomain()
functions in the Kitten Cryptography API.
Kitten Cryptography API
The Kitten Cryptography API (which you can find in /src/lib/crypto.js) contains, among other things, high-level functions for encrypting and decrypting messages sent between Small Web domains:
const textDecoder = new TextDecoder() // default: 'utf-8'
export async function encryptMessageForDomain (message, ourPrivateKey, domain) {
const sharedSecret = await sharedSecretForDomain(domain, ourPrivateKey)
const encryptedMessageBytes = await encrypt(sharedSecret, message)
const encryptedMessage = bytesToHex(encryptedMessageBytes)
return encryptedMessage
}
export async function decryptMessageFromDomain (encryptedMessage, ourPrivateKey, domain) {
const sharedSecret = await sharedSecretForDomain(domain, ourPrivateKey)
const decryptedMessageUInt8Array = await decrypt(sharedSecret, hexToBytes(encryptedMessage))
const decryptedMessageUtf8String = textDecoder.decode(decryptedMessageUInt8Array)
return decryptedMessageUtf8String
}
Both these methods are fairly spartan and rely on the sharedSecretForDomain()
function to carry out the heavy lifting:
// Cache shared secrets for different domains as theyāre
// expensive to calculate.
const sharedSecrets = {}
async function sharedSecretForDomain (domain, ourPrivateKey) {
if (sharedSecrets[domain] === undefined) {
// We donāt have a shared secret yet. Attempt to calculate one
// by getting the other domainās ed25519 public key.
const domainToContact = `https://${domain}/š/id`
const theirPublicKeyResponse = await fetch(domainToContact)
const theirPublicKeyHex = await theirPublicKeyResponse.text()
const ourPrivateKeyHex = bytesToHex(emojiStringToSecret(ourPrivateKey))
sharedSecrets[domain] = await getSharedSecret(ourPrivateKeyHex, theirPublicKeyHex)
}
return sharedSecrets[domain]
}
The sharedSecretForDomain()
function is where the Diffie-Hellman key exchange happens and the shared secret thatās used to encrypt and decrypt messages between a pair of Small Web domains is calculated.
As part of the Small Web Protocol, every Small Web site serves its ed25519 public key at the /š/id route.
š” The Small Web Protocol requires Small Web routes to be namespaced under the š path. This also happens to be the Small Web logo.
So the first thing we do is get this for the domain we want to send the message to.
Then we combine that with out private key to calculate the shared secret.
The actual calculation of the shared secret uses the getSharedSecret()
function from Paul Millerās noble-ed25519 library.
š” The Kitten Cryptography API also makes extensive use of Paulās ed25519-keygen library.
Once we have the shared secret, the actual encryption and decryption are handled by the encrypt()
and decrypt()
functions from Paulās micro-aes-gcm library, which itself uses low-level cryptographic primitives from the Web Crypto API.
š” AES-GCM stands for Advanced Encryption Standard Galois/Counter Mode but all you really need to know is that Paul and the folks who implemented the Web Crypto API in your browser have done the hard work of implementing the cryptography and all you need to do is to use the high-level
encryptMessageForDomain()
anddecryptMessageFromDomain()
functions exposed by the Kitten Cryptography API when creating end-to-end-encrypted messages to send between peer-to-peer Small Web places.Also note that while the Kitten Cryptography API is available on both the server and the client, the encryption and decryption functions are only meant to be run in the browser. In fact, under the current Kitten runtime (Node version 18 LTS), they will cause a runtime error as the global
crypto
object that exposes the Web Cryptography API is not present. It can be easily polyfilled but isnāt on purpose as you shouldnāt be using it. When Kitten moves onto a runtime thatās version 19+, the calls wonāt fail but you still shouldnāt be using them on the server.
At this point, we have made all the changes we need to on the client side but we still need to handle messages differently on the server. So, next, letās take a look at the server-side code.
/privateš/chat.socket.js
import Message from './Message.component.js'
if (kitten.db.messages === undefined) kitten.db.messages = [];
function messageHtml (message) {
// Wrap the same Message component we use in the page
// with a node instructing htmx to add this node to
// the end of the messages list on the page.
return kitten.html`
<div hx-swap-oob="beforeend:#messages">
<${Message} message=${message} />
</div>
`
}
// Kitten Chat example back-end.
export default function ({ socket, request }) {
const saveAndBroadcastMessage = message => {
// Persist the message in the messages table.
kitten.db.messages.push(message)
// Since we are not optimistically showing messages
// as sent on the client, we send to all() local clients, including
// the one that sent the message. If we were optimistically
// updating the messages list, we would use the broadcast()
// method on the socket instead.
const numberOfRecipients = socket.all(messageHtml(message))
// Log the number of recipients message was sent to
// and make sure we pluralise the log message properly.
console.info(`š«§ Kitten ${request.originalUrl} message from ${message.from} to ${message.to} broadcast to `
+ `${numberOfRecipients} recipient`
+ `${numberOfRecipients === 1 ? '' : 's'}.`)
}
// Handle remote messages.
const remoteMessageHandler = message => {
if (!isValidMessage(message)) {
console.warn(`Message from remote place is invalid; not saving or broadcasting to local place.`)
return
}
saveAndBroadcastMessage(message)
}
kitten.events.on('message', remoteMessageHandler)
socket.addEventListener('close', () => {
// Housekeeping: stop listening for remote message events
// when the socket is closed so we donāt end up processing
// them multiple times when the client disconnects/reconnects.
kitten.events.off('message', remoteMessageHandler)
})
socket.addEventListener('message', async event => {
// A new message has been received from a client connected to
// our own Small Web place: broadcast it to all clients
// in the same room and deliver it to the remote Small Web place
// it is being sent to after performing basic validation.
const message = JSON.parse(event.data)
if (!isValidMessage(message)) {
console.warn(`Message from local place is invalid; not saving or delivering to remote place.`)
return
}
// We donāt need to use the message HEADERS, delete them so
// they donāt take unnecessary space when we persist the message.
delete message.HEADERS
saveAndBroadcastMessage(message)
// Deliver message to remote placeās inbox.
// Note: we are not doing any error handling here.
// In a real-world application, we would be and weād be
// alerting the person if their message couldnāt be
// delivered, etc., and giving them the option to retry.
const body = JSON.stringify(message)
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
}
const inbox = `https://${message.to}/inbox`
try {
await fetch(inbox, requestOptions)
} catch (error) {
console.error(`Could not deliver message to ${message.to}`, error)
}
})
}
// Some basic validation.
// ā¦
There are two major changes here so letās examine them separately.
When we receive a message from a local client, we must not only save it in the database but also deliver it to the remote node.
We must also have a way of knowing when a message has been delivered to us so we can both save it in our database and broadcast it to all connected local clients.
We handle the first requirement by making a POST
call to the remote serverās /inbox route:
const body = JSON.stringify(message)
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
}
const inbox = `https://${message.to}/inbox`
try {
await fetch(inbox, requestOptions)
} catch (error) {
console.error(`Could not deliver message to ${message.to}`, error)
}
We will see what the /inbox route looks like when we implement it next.
But for now, letās also look at how we statisfy the second requirement:
// Handle remote messages.
const remoteMessageHandler = message => {
if (!isValidMessage(message)) {
console.warn(`Message from remote place is invalid; not saving or broadcasting to local place.`)
return
}
saveAndBroadcastMessage(message)
}
kitten.events.on('message', remoteMessageHandler)
socket.addEventListener('close', () => {
// Housekeeping: stop listening for remote message events
// when the socket is closed so we donāt end up processing
// them multiple times when the client disconnects/reconnects.
kitten.events.off('message', remoteMessageHandler)
})
Here we listen tomessage
events on a global Kitten object called Events
that we havenāt seen before.
Events
is just global instance of Nodeās EventEmitter
thatās made available to you for ease of authoring.
When we hear that a message has been received, we save it before broadcasting it to all local clients. And we make sure we stop listening for the event when the socket is closed so that we donāt handle messages multiple times in case clients disconnect and reconnect.
At this point, we have just one thing left to create: the /inbox route where other places can send us messages.
So letās build that now.
/inbox.post.js
The /inbox route, as we saw earlier in the code that sends messages to it, is a POST
route. Also notice that it is not in our private directory. Any other Small Web place should be able to send a message to our inbox so it must be public.
The code for it couldnāt be simpler:
export default function ({ request, response }) {
const message = request.body
console.info('š„ /inbox received:', message)
kitten.events.emit('message', message)
response.end('ok')
}
All weāre doing is getting the message from the body of the request (which Kitten automatically parses from JSON for us) and then using Kittenās convenient global EventEmitter
instance, Events
, to dispatch a message
event.
š” Again, this is a basic example. In a real-world scenario, you would carry out further validation on received message.
Testing peer-to-peer Small Web features
To test the end-to-end encrypted Kitten chat example, you have to run more than one instance of it. So open two Terminal windows (or tabs or panes within your Terminal app) and run two instances of the example:
Terminal 1
kitten --domain=place1.localhost
Terminal 2
kitten --domain=place2.localhost --port=444
š” Notice that you have to use not just a different domain but, when testing locally, also a different port.
Now, open https://place1.localhost/private in one browser window and https://place2.localhost:444/private in another.
In each one, enter the address of the other in the Domain: field (without the https:// prefix) and send a few messages between them.
Remember that when running in deployment from a domain name, these places would be owned by different people and reside on different computers around the world.
š” If you want a little respite from all the cryptography stuff and give yourself a little reward, check out the Animated End-to-End Encrypted Kitten Chat project in the examples folder ;)
Wow, that was the longest tutorial yet! And guess what?ā¦
š Congratulations!
Youāve reached the end of the tutorials section!
Hope this has given you an idea of whatās possible with Kitten and inspired you to continue playing with it. These tutorials will constantly be iterated upon and new ones added, so do check back from time to time.
To continue learning about every little detail of Kitten, check out the Kitten Reference.