Skip to content

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.

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.

Every v1 proof signs the UTF-8 bytes of a canonical JSON value. To make the value canonical:

  1. Sort object keys alphabetically, at every depth.
  2. Drop object properties whose value is undefined.
  3. Keep array order as it is.
  4. Serialise with JSON.stringify.
  5. 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);
}

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.

Install the SDK and create a client with the user’s Rotur token:

Terminal window
npm install rotur-sdk
import { 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.

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.

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.

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.

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.

For signed messages and edits, the server requires:

  • author_id equal to the signed-in user’s Rotur ID. Otherwise: Signed message author does not match the authenticated user.
  • key_id starting with rotur_sk_, and a signature of 80 to 100 characters. Otherwise: Message signature is invalid.
  • timestamp within 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.