Skip to content

Signing & verification

OriginChats content signatures let a client prove that content came from a particular Rotur identity. They are separate from the validator used to authenticate a WebSocket connection: authentication establishes the connection’s user, while signing attaches a portable Ed25519 proof to content.

The official client implementation is split between src/lib/signing/messages.ts, src/lib/signing/slash.ts, and src/lib/signing/clock.ts in originchats-client. The examples below use the same payloads and canonicalization rules.

Only enable a signing feature when the handshake advertises its capability:

Capability Signed content
message_signatures_v1 New messages, signed edits, and embeds
slash_signatures_v1 Slash-command invocations and the interaction copied to replies

Save handshake.val.server_time and handshake.val.signing_url. Timestamps are Unix seconds and may be fractional. Use an estimate of server time rather than trusting the device clock, and use signing_url exactly as supplied in the signed payload. The official client anchors the handshake time to a monotonic clock and refines it with ping round trips.

Signing always covers a JSON value, not the surrounding WebSocket frame.

const messageContent = [
'originchats.message.v1',
authorId,
content,
attachments,
timestamp,
signingUrl,
];
const embedContent = [
'originchats.embed.v1',
authorId,
embedWithoutProofFields,
];
const slashContent = [
'originchats.slash.v1',
authorId,
command,
args,
timestamp,
signingUrl,
nonce,
];

For embeds, remove author_id, key_id, signature, and client-only _verification before signing. Object keys at every depth are sorted lexicographically, properties whose value is undefined are omitted, arrays retain their order, and the result is encoded as UTF-8 JSON. Array order therefore matters for attachments, poll-like values, and nested data.

Install the same SDK used by the official client:

Terminal window
pnpm add rotur-sdk

Create one Rotur instance and give it the signed-in user’s Rotur token. Keep that token private; never send it to an OriginChats server.

import { Rotur } from 'rotur-sdk';
const rotur = new Rotur({ token: roturToken });
function messageSigningContent(
authorId: string,
content: string,
attachments: unknown[],
timestamp: number,
signingUrl: string,
) {
return ['originchats.message.v1', authorId, content, attachments, timestamp, signingUrl];
}

The callback form is important: the SDK loads the user’s signing identity and supplies the authoritative author_id before constructing the bytes to sign.

async function signMessageNew(
payload: Record<string, unknown>,
timestamp: number,
signingUrl: string,
) {
const content = typeof payload.content === 'string' ? payload.content : '';
const attachments = Array.isArray(payload.attachments) ? payload.attachments : [];
const proof = await rotur.signing.sign((authorId) =>
messageSigningContent(authorId, content, attachments, timestamp, signingUrl),
);
return { ...payload, timestamp, ...proof };
}
const packet = await signMessageNew(
{
cmd: 'message_new',
channel: 'general',
content: 'This message is signed.',
attachments: [],
},
estimatedServerTime(),
handshake.val.signing_url,
);
socket.send(JSON.stringify(packet));

The proof contributes author_id, key_id, and a base64url signature. On success the server exposes the submitted signing timestamp as message.signed_at; the stored message’s normal timestamp remains the server’s creation time.

Sign the complete post-edit content and attachment array, not merely the changed fields. Use a timestamp newer than the previous signed_at:

const content = update.content ?? current.content;
const attachments = update.attachments ?? current.attachments ?? [];
const timestamp = Math.max(estimatedServerTime(), (current.signed_at ?? 0) + 0.001);
const proof = await rotur.signing.sign((authorId) =>
messageSigningContent(authorId, content, attachments, timestamp, signingUrl),
);
socket.send(JSON.stringify({
cmd: 'message_edit',
channel,
id: current.id,
...update,
timestamp,
...proof,
}));

An author cannot change the content or attachments of their signed message without a new signature. A moderator editing someone else’s signed payload removes the stored proof.

Sign each embed separately, then add its proof to that embed. For slash commands, generate a unique nonce between 16 and 128 characters and sign the normalized args object that is sent:

const timestamp = estimatedServerTime();
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,
}));

Slash commands must be signed. The server carries the proof into the reply message’s interaction, allowing receiving clients to verify who invoked it.

Rebuild the canonical payload from the received public message and pass the displayed username as an identity binding:

const valid = await rotur.signing.verify(
{
author_id: message.author_id,
key_id: message.key_id,
signature: message.signature,
username: message.user,
},
messageSigningContent(
message.author_id,
message.content,
message.attachments ?? [],
message.signed_at,
signingUrl,
),
);

Before verification, require all three proof fields and a numeric signed_at. The official client also rejects a message when signed_at differs from edited_at ?? timestamp by more than five minutes. Treat key lookup/network failure as unavailable, missing proof fields as unsigned, and a completed false verification as invalid; those states should not be collapsed into one.

The raw implementation uses the same Rotur HTTP endpoints and Web Crypto. The private signing-key endpoint requires the user’s bearer token; the public-key endpoint does not.

const ROTUR_API = 'https://api.rotur.dev/v2';
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 serialized = JSON.stringify(canonicalSigningValue(value));
if (serialized === undefined) throw new TypeError('Content must be JSON serializable');
return new TextEncoder().encode(serialized);
}
function bytesToBase64url(value: ArrayBuffer): string {
let binary = '';
for (const byte of new Uint8Array(value)) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
function base64urlToBytes(value: string): Uint8Array {
const base64 = value.replaceAll('-', '+').replaceAll('_', '/');
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}

Fetch the user’s current private key, validate its declared algorithm, import it as Ed25519, and sign the canonical bytes:

async function signWithoutSdk(
token: string,
content: (authorId: string) => unknown,
) {
const response = await fetch(`${ROTUR_API}/me/signing-key`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`Signing-key request failed: ${response.status}`);
const identity = await response.json();
if (identity.algorithm !== 'Ed25519') throw new Error('Unsupported signing algorithm');
const key = await crypto.subtle.importKey(
'jwk',
identity.private_key_jwk,
{ name: 'Ed25519' },
false,
['sign'],
);
const signature = await crypto.subtle.sign(
{ name: 'Ed25519' },
key,
signingBytes(content(identity.user_id)),
);
return {
author_id: identity.user_id,
key_id: identity.key_id,
signature: bytesToBase64url(signature),
};
}

Cache the imported identity for the lifetime of the token, as the official SDK does. Never persist or expose private_key_jwk, and clear the cached identity when the Rotur token changes.

Verification fetches the exact public key named by the proof and confirms that the response is bound to the expected identity before importing it:

async function verifyWithoutSdk(
proof: { author_id: string; key_id: string; signature: string; username?: string },
content: unknown,
) {
const path = `/users/${encodeURIComponent(proof.author_id)}`
+ `/signing-keys/${encodeURIComponent(proof.key_id)}`;
const response = await fetch(ROTUR_API + path);
if (!response.ok) throw new Error(`Public-key request failed: ${response.status}`);
const identity = await response.json();
if (
identity.algorithm !== 'Ed25519'
|| identity.user_id !== proof.author_id
|| identity.key_id !== proof.key_id
|| (proof.username && identity.username.toLowerCase() !== proof.username.toLowerCase())
) return false;
const key = await crypto.subtle.importKey(
'jwk',
identity.public_key_jwk,
{ name: 'Ed25519' },
false,
['verify'],
);
return crypto.subtle.verify(
{ name: 'Ed25519' },
key,
base64urlToBytes(proof.signature),
signingBytes(content),
);
}

The SDK retries a public-key lookup once with a cache-busting query when the key’s username does not match. A raw client should likewise avoid permanently caching failed or mismatched lookups.

originchats-osl does not fetch Rotur public keys or perform Ed25519 verification. For messages it checks that the proof is complete, author_id equals the authenticated user ID, the key ID begins with rotur_sk_, the signature length is plausible, and the timestamp is within five minutes of server time. Signed edits must also have a timestamp newer than the previous proof. Slash calls require the same author/key/signature shape plus a positive timestamp and a 16–128 character nonce; they do not currently enforce the message-style five-minute window.

Cryptographic verification is therefore a receiving-client responsibility. Never show a message as verified merely because the server accepted or rebroadcast it.

  • Re-sign a queued message before retrying it; an old timestamp may leave the five-minute acceptance window.
  • Preserve the same signed values between canonicalization and transmission.
  • Do not partially attach a proof. Supplying any of author_id, key_id, or signature makes the server treat the request as a signing attempt.
  • Keep unsigned content usable when the capability is absent, but require signing for slash_call on the current server.
  • Cache public keys by both author_id and key_id, not by username alone.