Signing and verification
A signature is a portable proof that a Rotur account wrote a piece of content. Signing is optional for messages and required for slash calls from Rotur accounts. This guide shows how to build the signed bytes, sign with rotur-sdk, keep a clock that the server accepts, and verify signatures on messages you receive.
Signing is separate from signing in. Authentication proves who controls the connection. A signature travels with the content, so anyone can check it later, on any client.
When to sign
Section titled “When to sign”The handshake advertises two signing features:
| Capability | What you can sign |
|---|---|
message_signatures_v1 |
message_new, your own message_edit, and individual embeds. |
slash_signatures_v1 |
slash_call. The server copies the proof into the result message. |
Only Rotur accounts sign. When ready.user.cracked is true, the server ignores any proof fields and stores the content as unsigned.
Before you sign, keep these values for the connection:
handshake.val.server_time, to start a clock that matches the server’s.handshake.val.signing_url, which goes into every signed payload exactly as given.
Don’t build signing_url from the WebSocket address. A proxy or a second hostname can make them differ.
Build the signed bytes
Section titled “Build the signed bytes”Every v1 proof signs the UTF-8 bytes of a canonical JSON value. To make the value canonical:
- Sort object keys alphabetically, at every depth.
- Drop object properties whose value is
undefined. - Keep array order as it is.
- Serialise with
JSON.stringify. - Encode the string as UTF-8.
rotur-sdk exports signingBytes and canonicalSigningValue for this. An equivalent implementation:
function canonicalSigningValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalSigningValue); if (value && typeof value === "object") { const result: Record<string, unknown> = {}; for (const key of Object.keys(value).sort()) { const item = (value as Record<string, unknown>)[key]; if (item !== undefined) result[key] = canonicalSigningValue(item); } return result; } return value;}
function signingBytes(value: unknown): Uint8Array { const json = JSON.stringify(canonicalSigningValue(value)); if (json === undefined) throw new TypeError("Signed content must be JSON serializable"); return new TextEncoder().encode(json);}Payload formats
Section titled “Payload formats”A message proof covers the author, the text, the attachments, the signed time, and the server:
["originchats.message.v1", authorId, content, attachments, timestamp, signingUrl]Use "" when there is no content and [] when there are no attachments.
An embed is signed on its own, without its proof fields (author_id, key_id, signature) and without any verification state your client added:
["originchats.embed.v1", authorId, embedWithoutProofFields]A slash call adds the command, its arguments, and a nonce:
["originchats.slash.v1", authorId, command, args, timestamp, signingUrl, nonce]The nonce must be 16 to 128 characters and unique for each call. crypto.randomUUID() works.
Sign a message
Section titled “Sign a message”Install the SDK and create a client with the user’s Rotur token:
npm install rotur-sdkimport { Rotur } from "rotur-sdk";
const rotur = new Rotur({ token: roturToken });rotur.signing.sign passes your callback the user ID bound to the signing key. Use that ID, not a profile field:
async function signMessage(payload: Record<string, unknown>, signingUrl: string) { const timestamp = serverNow(); const content = typeof payload.content === "string" ? payload.content : ""; const attachments = Array.isArray(payload.attachments) ? payload.attachments : [];
const proof = await rotur.signing.sign((authorId) => [ "originchats.message.v1", authorId, content, attachments, timestamp, signingUrl, ]);
return { ...payload, timestamp, ...proof };}The proof has three fields: author_id, key_id, and a base64url signature. Send all three, plus timestamp, on the message_new request.
Sign each embed with its own call first. Then sign the message once its final attachment list is known, because the attachments are part of the signed payload.
Keep a server clock
Section titled “Keep a server clock”The server accepts a signed message only if its timestamp is within five minutes of the server’s clock. Device clocks are often wrong, so don’t sign with Date.now() directly. Start from handshake.val.server_time and advance it with a monotonic clock such as performance.now().
To refine the estimate after sign-in, send ping with your send time in Unix seconds:
{ "cmd": "ping", "sent_at": 1788177600.1 }{ "cmd": "ping", "sent_at": 1788177600.1, "server_received_at": 1788177600.184, "server_sent_at": 1788177600.185}With these and your receive time, compute the offset as described in time fields. Discard samples with negative timings or very long round trips, and trust fast samples more.
Sign an edit
Section titled “Sign an edit”When you edit the content or attachments of your own signed message, you must sign the result. Otherwise the server answers A signed message requires a signed edit.
The signature covers the complete new content and attachment list, not only the part you changed. If your edit leaves one out, take it from the current message before you sign:
const nextContent = edit.content ?? current.content;const nextAttachments = edit.attachments ?? current.attachments ?? [];The signed timestamp must be newer than the message’s current signed_at. The server then sets signed_at to your timestamp and edited_at to its own time.
A moderator can edit someone else’s message without their key. If that edit changes the content or attachments, the server removes key_id, signature, signed_at, and edited_at, and sets edited_by to the moderator’s user ID. Show such a message as unsigned.
Sign slash calls
Section titled “Sign slash calls”Rotur accounts must sign every slash_call. An unsigned call fails with Slash commands must be signed.
const timestamp = serverNow();const nonce = crypto.randomUUID();const proof = await rotur.signing.sign((authorId) => [ "originchats.slash.v1", authorId, command, args, timestamp, signingUrl, nonce,]);
socket.send(JSON.stringify({ cmd: "slash_call", channel, command, args, timestamp, nonce, ...proof }));For slash calls, the server checks only that the timestamp is positive and the nonce is 16 to 128 characters. It doesn’t enforce a time window or remember nonces. Create fresh values for every call anyway, because other clients verify them.
Verify incoming content
Section titled “Verify incoming content”rotur.signing.verify fetches the public key for author_id and key_id, checks the optional username, and verifies the canonical bytes:
const valid = await rotur.signing.verify( { author_id: message.author_id, key_id: message.key_id, signature: message.signature, username: message.user, }, [ "originchats.message.v1", message.author_id, message.content, message.attachments || [], message.signed_at, signingUrl, ],);To verify without the SDK, fetch the key from Rotur at /users/{userId}/signing-keys/{keyId}. Check that the returned user_id, key_id, and algorithm: "Ed25519" match before you import public_key_jwk.
Also check the time: signed_at must exist and be within five minutes of edited_at for an edited message, or of timestamp otherwise.
Verify embeds separately. A bad embed signature doesn’t invalidate a good message signature.
If you show verification status, keep “invalid” apart from “couldn’t check”:
| State | Meaning |
|---|---|
unsigned |
The content has no complete proof. |
pending |
A key lookup or check is still running. |
verified |
The author, time, and Ed25519 checks all passed. |
invalid |
The proof is complete, but a check failed. |
unavailable |
The check couldn’t finish, for example because the key service was down. |
Never change the content you are verifying.
What the server checks
Section titled “What the server checks”For signed messages and edits, the server requires:
author_idequal to the signed-in user’s Rotur ID. Otherwise:Signed message author does not match the authenticated user.key_idstarting withrotur_sk_, and asignatureof 80 to 100 characters. Otherwise:Message signature is invalid.timestampwithin five minutes of the server’s clock. Otherwise:Message signed timestamp is outside the allowed window.
Signed edits fail with Only the original author can sign a message edit when someone else signs, and use Message edit in place of Message in the other two errors. A signed edit whose timestamp isn’t newer than the last one fails with Message edit signed timestamp must be newer than the previous signature.
Slash calls fail with Signed slash command author does not match the authenticated user, Slash command signature is invalid, or Slash command freshness proof is invalid.
Passing these checks doesn’t make a signature valid. A stored signature has never been through Ed25519 on the server, so verify it yourself.
Next steps
Section titled “Next steps”- Uploading attachments: add files to messages, then sign them.
- Message schema: where proof fields appear on stored messages.
slash_call: the full slash request and response.
