Refine documentation and support experience

This commit is contained in:
rosetta
2026-07-31 04:32:34 -07:00
parent 179e2e65c6
commit 33d4574583
18 changed files with 924 additions and 1296 deletions

44
known-issues.json Normal file
View File

@@ -0,0 +1,44 @@
[
{
"id": "lost-recovery-phrase",
"title": "Lost recovery phrase",
"summary": "The recovery phrase cannot be restored by Rosetta.",
"solution": "Rosetta does not keep a copy of your recovery phrase or private key. If another device is still signed in, secure any data you need and create a new account before access is lost. Never send a recovery phrase to support.",
"keywords": ["lost", "recovery", "phrase", "seed", "restore", "account", "login"]
},
{
"id": "new-device-confirmation",
"title": "New device requires confirmation",
"summary": "A restored account may require approval before the new device is trusted.",
"solution": "Keep the existing Rosetta device online and approve the new device when prompted. Check that the recovery phrase was entered in the original order. If no trusted device remains available, contact support with the client version and operating system, but never include private keys.",
"keywords": ["new device", "confirmation", "approve", "trusted", "restore", "handshake", "login"]
},
{
"id": "attachment-stuck",
"title": "Attachment remains in sending state",
"summary": "Large media can remain pending when the upload or connection is interrupted.",
"solution": "Keep Rosetta open and connected until the transfer finishes. Confirm that the file is still available locally, retry the message, and verify that the current client version is installed. Include the attachment type and approximate size when contacting support.",
"keywords": ["attachment", "file", "photo", "video", "upload", "sending", "stuck", "pending", "media"]
},
{
"id": "missing-media-permission",
"title": "Photos, files, or microphone are unavailable",
"summary": "The operating system may have denied a required permission.",
"solution": "Open the operating-system privacy settings for Rosetta and enable access to Photos, Files, or Microphone as needed. Restart Rosetta after changing permissions and select the attachment again.",
"keywords": ["permission", "microphone", "photo", "camera", "file", "voice", "denied", "unavailable"]
},
{
"id": "message-not-delivered",
"title": "Message is not delivered",
"summary": "Delivery can be delayed by connectivity, client state, or an unavailable recipient.",
"solution": "Check the network connection and confirm that Rosetta reports a connected state. Leave the application open briefly and retry once. If the problem continues, provide support with the sender platform, recipient platform, approximate time, and client version. Do not include message content or encryption material.",
"keywords": ["message", "deliver", "delivered", "waiting", "error", "offline", "connection", "send"]
},
{
"id": "security-report",
"title": "Report abuse or a security issue",
"summary": "Security reports should contain reproducible technical details without secrets.",
"solution": "Email support@rosetta.im with the affected platform, client version, expected behavior, actual behavior, and reproduction steps. Never include a recovery phrase, private key, decrypted private messages, or another person's personal information.",
"keywords": ["security", "abuse", "report", "vulnerability", "privacy", "spam", "scam"]
}
]

View File

@@ -12,6 +12,12 @@
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.9</priority> <priority>0.9</priority>
</url> </url>
<url>
<loc>https://rosetta.im/proto/docs/</loc>
<lastmod>2026-07-31</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url> <url>
<loc>https://rosetta.im/encryption/</loc> <loc>https://rosetta.im/encryption/</loc>
<lastmod>2026-07-30</lastmod> <lastmod>2026-07-30</lastmod>

View File

@@ -178,5 +178,5 @@ const totals = layers.reduce(
); );
console.log( console.log(
`Generated Proto docs from ${layers.length} layers: ${totals.packets} packets, ${totals.types} objects, ${totals.enums} enums.`, `Generated Proto docs from ${layers.length} layers: ${totals.packets} packets, ${totals.types} types, ${totals.enums} enums.`,
); );

View File

@@ -15,6 +15,9 @@ const EncryptionPage = lazy(() =>
const ProtoPage = lazy(() => const ProtoPage = lazy(() =>
import('./pages/ProtoPage').then((module) => ({ default: module.ProtoPage })), import('./pages/ProtoPage').then((module) => ({ default: module.ProtoPage })),
); );
const ProtoDocsPage = lazy(() =>
import('./pages/ProtoPage').then((module) => ({ default: module.ProtoDocsPage })),
);
const SupportPage = lazy(() => const SupportPage = lazy(() =>
import('./pages/SupportPage').then((module) => ({ default: module.SupportPage })), import('./pages/SupportPage').then((module) => ({ default: module.SupportPage })),
); );
@@ -51,6 +54,7 @@ export default function App() {
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/encryption" element={<EncryptionPage />} /> <Route path="/encryption" element={<EncryptionPage />} />
<Route path="/proto" element={<ProtoPage />} /> <Route path="/proto" element={<ProtoPage />} />
<Route path="/proto/docs" element={<ProtoDocsPage />} />
<Route path="/support" element={<SupportPage />} /> <Route path="/support" element={<SupportPage />} />
<Route path="/privacy" element={<PrivacyPage />} /> <Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} /> <Route path="/terms" element={<TermsPage />} />

View File

@@ -15,21 +15,21 @@
width: 100%; width: 100%;
max-width: 900px; max-width: 900px;
margin-inline: auto; margin-inline: auto;
padding: clamp(32px, 6vw, 72px); padding: clamp(30px, 5vw, 60px);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8)); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
} }
.intro { .intro {
max-width: 760px; max-width: 760px;
padding-bottom: 48px; padding-bottom: 36px;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
} }
.title { .title {
margin-top: var(--mantine-spacing-sm); margin-top: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: clamp(38px, 6vw, 56px); font-size: clamp(28px, 4vw, 36px);
font-weight: 600; font-weight: 600;
letter-spacing: -0.03em; letter-spacing: -0.03em;
line-height: 1.08; line-height: 1.08;
@@ -39,8 +39,8 @@
max-width: 780px; max-width: 780px;
margin: 40px 0 0; margin: 40px 0 0;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
font-size: 20px; font-size: 17px;
line-height: 1.6; line-height: 1.72;
} }
.text, .text,
@@ -65,7 +65,7 @@
} }
.list li::marker { .list li::marker {
color: var(--mantine-color-blue-5); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
} }
.section { .section {
@@ -77,7 +77,7 @@
.sectionTitle { .sectionTitle {
margin: 0; margin: 0;
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white)); color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
font-size: clamp(21px, 3vw, 26px); font-size: clamp(19px, 3vw, 23px);
font-weight: 650; font-weight: 650;
line-height: 1.25; line-height: 1.25;
} }
@@ -95,8 +95,8 @@
.address { .address {
margin: var(--mantine-spacing-md) 0; margin: var(--mantine-spacing-md) 0;
border-left: 3px solid var(--mantine-color-blue-5); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6)); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
} }
.address p { .address p {

View File

@@ -33,9 +33,9 @@ export function LegalPage({
<Box className={classes.page}> <Box className={classes.page}>
<SiteHeader /> <SiteHeader />
<Container className={classes.shell}> <Container className={classes.shell}>
<Paper component="main" withBorder radius="lg" className={classes.main}> <Paper component="main" withBorder radius="sm" className={classes.main}>
<Box className={classes.intro}> <Box className={classes.intro}>
<Text tt="uppercase" fw={700} fz="xs" c="blue" lts="0.08em"> <Text tt="uppercase" fw={700} fz="xs" c="dimmed" lts="0.08em">
{eyebrow} {eyebrow}
</Text> </Text>
<Title order={1} className={classes.title}> <Title order={1} className={classes.title}>
@@ -93,7 +93,7 @@ export function LegalList({ children }: { children: ReactNode }) {
export function LegalAddress({ children }: { children: ReactNode }) { export function LegalAddress({ children }: { children: ReactNode }) {
return ( return (
<Paper radius="md" p="md" className={classes.address}> <Paper radius="sm" p="md" className={classes.address}>
<Text component="p">{children}</Text> <Text component="p">{children}</Text>
</Paper> </Paper>
); );

View File

@@ -55,7 +55,7 @@ export function SiteFooter() {
Terms of Use Terms of Use
</Anchor> </Anchor>
<Anchor href="mailto:support@rosetta.im" c="dimmed" size="sm"> <Anchor href="mailto:support@rosetta.im" c="dimmed" size="sm">
Support@rosetta.im support@rosetta.im
</Anchor> </Anchor>
</Stack> </Stack>
</SimpleGrid> </SimpleGrid>

View File

@@ -19,7 +19,11 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white)); color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
text-decoration: none; text-decoration: none !important;
}
.brand:hover {
text-decoration: none !important;
} }
.brand img { .brand img {

View File

@@ -26,7 +26,7 @@ export function SiteHeader() {
const links = NAVIGATION.map(({ label, to }) => { const links = NAVIGATION.map(({ label, to }) => {
const active = const active =
(to === '/proto' && pathname === '/proto') || (to === '/proto' && pathname.startsWith('/proto')) ||
(to === '/encryption' && pathname === '/encryption') || (to === '/encryption' && pathname === '/encryption') ||
(to === '/support' && pathname === '/support'); (to === '/support' && pathname === '/support');

View File

@@ -71,7 +71,7 @@ export const protoLayers = [
{ {
"name": "HandshakeStage", "name": "HandshakeStage",
"type": "int8", "type": "int8",
"description": "", "description": "Handshake Stage (need for set device confirm view after pass seed)",
"yaml": "HandshakeStage:\n type: int8\n values:\n - id: 0\n name: COMPLETED\n - id: 1\n name: NEED_DEVICE_VERIFICATION", "yaml": "HandshakeStage:\n type: int8\n values:\n - id: 0\n name: COMPLETED\n - id: 1\n name: NEED_DEVICE_VERIFICATION",
"values": [ "values": [
{ {

View File

@@ -1,162 +1,30 @@
.page { .subheading {
padding: 48px 0 112px; margin: 28px 0 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9)); color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-dark-0));
} font-size: 17px;
.hero,
.notice,
.contents,
.section {
width: 100%;
max-width: 900px;
margin-inline: auto;
}
.hero {
padding: 64px 0 48px;
}
.hero h1 {
margin-top: var(--mantine-spacing-lg);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: clamp(48px, 7vw, 74px);
font-weight: 600;
letter-spacing: -0.045em;
line-height: 1.02;
}
.heroDescription {
max-width: 790px;
margin-top: var(--mantine-spacing-lg);
font-size: 19px;
line-height: 1.7;
}
.notice {
padding: var(--mantine-spacing-xl);
border-left: 4px solid var(--mantine-color-blue-5);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
}
.contents {
margin-top: var(--mantine-spacing-lg);
padding-bottom: 48px;
}
.contentLink {
padding: 7px 12px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: 999px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-xs);
font-weight: 600;
text-decoration: none;
}
.contentLink:hover {
border-color: var(--mantine-color-blue-4);
color: var(--mantine-color-blue-6);
text-decoration: none;
}
.section {
scroll-margin-top: 104px;
margin-top: 24px;
padding: 64px clamp(24px, 5vw, 64px);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-xl);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
}
.section > h2 {
max-width: 760px;
margin-top: var(--mantine-spacing-xs);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: clamp(30px, 4vw, 43px);
font-weight: 600;
letter-spacing: -0.035em;
line-height: 1.12;
}
.sectionBody {
margin-top: 30px;
}
.sectionBody > h3 {
margin-top: 42px;
margin-bottom: 12px;
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
font-size: 21px;
font-weight: 650; font-weight: 650;
} }
.codeBlock { .code {
margin: var(--mantine-spacing-lg) 0; margin: 18px 0;
padding: 18px 20px; padding: 16px;
border: 1px solid light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-9)); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-xs);
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.1)); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1)); color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
font-size: 13px; font-size: 12px;
line-height: 1.7; line-height: 1.65;
overflow-x: auto; overflow-x: auto;
white-space: pre; white-space: pre;
} }
.list { .table {
margin: 18px 0 24px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.references {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
line-height: 1.7; line-height: 1.7;
} }
.list li::marker {
color: var(--mantine-color-blue-5);
}
.table {
margin-top: var(--mantine-spacing-md);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
}
.table th {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
}
.boundary {
background: light-dark(var(--mantine-color-orange-0), rgba(253, 126, 20, 0.08));
}
.terms {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 32px;
margin: 0;
}
.term {
padding: 20px 0;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.term dd {
margin: 6px 0 0;
}
@media (max-width: 48em) {
.page {
padding: 32px 0 80px;
}
.hero {
padding: 48px 0 40px;
}
.section {
padding: 42px 24px;
}
.terms {
grid-template-columns: 1fr;
}
}

View File

@@ -1,101 +1,25 @@
import { Anchor, Code, List, Table, Text, Title } from '@mantine/core';
import { import {
Anchor, LegalList,
Badge, LegalPage,
Box, LegalSection,
Code, LegalText,
Container, } from '../components/LegalPage/LegalPage';
Group,
List,
Paper,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconBinaryTree,
IconFileTextShield,
IconKey,
IconLock,
IconRoute,
IconShieldCheck,
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { SEO } from '../components/SEO/SEO'; import { SEO } from '../components/SEO/SEO';
import { SiteFooter } from '../components/SiteFooter/SiteFooter';
import { SiteHeader } from '../components/SiteHeader/SiteHeader';
import classes from './EncryptionPage.module.css'; import classes from './EncryptionPage.module.css';
const MESSAGE_FLOW = [
{
number: '01',
title: 'Create message material',
description: 'Generate a fresh 32-byte content key and a fresh 24-byte nonce.',
},
{
number: '02',
title: 'Encrypt the content',
description: 'Protect UTF-8 message content with XChaCha20-Poly1305.',
},
{
number: '03',
title: 'Wrap for the recipient',
description: 'Encrypt the key and nonce for the recipient using ephemeral secp256k1 ECDH.',
},
{
number: '04',
title: 'Transmit ciphertext',
description: 'Send the encrypted content, key envelope, routing data, and attachment metadata.',
},
];
const ATTACHMENT_LAYOUT = [ const ATTACHMENT_LAYOUT = [
['Magic', '4 bytes', 'ASCII RAC2 (52 41 43 32)'], ['Magic', '4 bytes', 'ASCII RAC2 (52 41 43 32)'],
['Salt', '16 bytes', 'Random per attachment encryption session'], ['Salt', '16 bytes', 'Random per attachment encryption session'],
['IV', '12 bytes', 'Random and unique for every encrypted chunk'], ['IV', '12 bytes', 'Random for every encrypted chunk'],
['Ciphertext + tag', 'Variable', 'AES-256-GCM output including its authentication tag'], ['Ciphertext + tag', 'Variable', 'AES-256-GCM output and authentication tag'],
]; ];
function TechnicalSection({ function TechnicalCode({ children }: { children: string }) {
id,
eyebrow,
title,
children,
}: {
id: string;
eyebrow: string;
title: string;
children: React.ReactNode;
}) {
return ( return (
<Box component="section" id={id} className={classes.section}> <Code block className={classes.code}>
<Text c="blue" fw={700} size="xs" tt="uppercase" lts="0.08em"> {children}
{eyebrow} </Code>
</Text>
<Title order={2}>{title}</Title>
<Box className={classes.sectionBody}>{children}</Box>
</Box>
);
}
function Term({
term,
children,
}: {
term: string;
children: React.ReactNode;
}) {
return (
<Box className={classes.term}>
<Text component="dt" fw={700}>
{term}
</Text>
<Text component="dd" c="dimmed" size="sm" lh={1.65}>
{children}
</Text>
</Box>
); );
} }
@@ -108,404 +32,213 @@ export function EncryptionPage() {
canonical="https://rosetta.im/encryption/" canonical="https://rosetta.im/encryption/"
keywords="Rosetta encryption, XChaCha20-Poly1305, AES-256-GCM, secp256k1, encrypted attachments" keywords="Rosetta encryption, XChaCha20-Poly1305, AES-256-GCM, secp256k1, encrypted attachments"
/> />
<SiteHeader /> <LegalPage eyebrow="Technical documentation" title="Encryption">
<main className={classes.page}> <LegalText lead>
<Container> This document describes the current Rosetta desktop implementation. It covers
<Anchor component={Link} to="/" size="sm"> direct messages, per-message keys, recipient key envelopes, group messages,
Rosetta local storage, and encrypted attachments. It is an implementation reference,
</Anchor> not a claim of formal verification or an independent security audit.
<Text component="span" c="dimmed" size="sm"> </LegalText>
{' '}
/ Encryption
</Text>
<Box className={classes.hero}> <LegalSection title="1. Hybrid encryption model">
<Group gap="sm"> <LegalText>
<ThemeIcon size={46} radius="md" variant="light"> Rosetta does not encrypt an entire message or file with public-key
<IconShieldCheck size={24} /> cryptography. Public-key operations are comparatively slow and are not
</ThemeIcon> designed for large payloads. They are used only to establish and protect
<Badge variant="light">Implementation reference</Badge> the small secret required by the content cipher.
</Group> </LegalText>
<Title order={1}>Rosetta Encryption</Title> <LegalText>
<Text c="dimmed" className={classes.heroDescription}> The message text itself is encrypted with a symmetric AEAD cipher because
A technical description of how the current Rosetta desktop client symmetric cryptography is fast and appropriate for arbitrary amounts of
derives identity keys, protects direct and group messages, encrypts data. In other words, the asymmetric part protects the key; the unique
attachments, and separates plaintext from server-visible data. symmetric key protects the message.
</Text> </LegalText>
</Box> <LegalText>
Every direct message receives its own fresh 32-byte content key and its own
fresh 24-byte nonce. A key is not reused as the general content key for the
next message.
</LegalText>
</LegalSection>
<Paper withBorder radius="lg" className={classes.notice}> <LegalSection title="2. Direct-message encryption">
<Text fw={700}>Scope of this document</Text> <Title order={3} className={classes.subheading}>
<Text c="dimmed" size="sm" mt={6} lh={1.65}> Content cipher
This page documents the current client implementation, including its </Title>
compatibility formats. It is an implementation reference, not a claim <LegalText>
of formal verification, an independent security audit, or a frozen The client generates the content key and nonce with the platform
protocol specification. cryptographic random number generator. XChaCha20-Poly1305 encrypts the
</Text> UTF-8 message and produces an authentication tag. A modified ciphertext or
</Paper> an incorrect key causes decryption to fail.
</LegalText>
<TechnicalCode>{`contentKey = randomBytes(32) # 256 bits
nonce = randomBytes(24) # 192 bits
ciphertext = XChaCha20-Poly1305(contentKey, nonce, UTF8(message))`}</TechnicalCode>
<Group component="nav" gap="xs" className={classes.contents}> <Title order={3} className={classes.subheading}>
{[ Recipient key envelope
['Direct messages', '#direct-messages'], </Title>
['Attachments', '#attachments'], <LegalList>
['Groups', '#groups'], <List.Item>
['Key material', '#key-material'], The client creates an ephemeral secp256k1 key pair for the message.
['Security properties', '#security-properties'], </List.Item>
['Terminology', '#terminology'], <List.Item>
].map(([label, href]) => ( ECDH combines the ephemeral private key with the recipient&apos;s
<Anchor key={href} href={href} className={classes.contentLink}> long-term public key.
{label} </List.Item>
</Anchor> <List.Item>
))} The x-coordinate of the shared point is used as a 32-byte AES wrapping
</Group> key.
</List.Item>
<TechnicalSection <List.Item>
id="direct-messages" The 56-byte message secret — content key followed by nonce — is wrapped
eyebrow="Direct messages" with AES-CBC and PKCS#7 padding using a random 16-byte IV.
title="A new authenticated cipher for every message." </List.Item>
> <List.Item>
<Text c="dimmed" lh={1.75}> The envelope contains the IV, encrypted message secret, and ephemeral
Rosetta encrypts the text of each one-to-one message with a newly public key. The content key is never sent in plaintext.
generated content key. The symmetric secret is never sent in plaintext: </List.Item>
it is placed in a separate recipient-specific key envelope. </LegalList>
</Text> <TechnicalCode>{`messageSecret = contentKey || nonce
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mt="xl">
{MESSAGE_FLOW.map(({ number, title, description }) => (
<Paper key={number} withBorder radius="md" p="lg">
<Text c="blue" fw={700} size="xs">
{number}
</Text>
<Text fw={700} mt="sm">
{title}
</Text>
<Text c="dimmed" size="sm" mt={6} lh={1.6}>
{description}
</Text>
</Paper>
))}
</SimpleGrid>
<Title order={3}>Content cipher</Title>
<Code block className={classes.codeBlock}>
{`contentKey = randomBytes(32) # 256 bits
nonce = randomBytes(24) # 192 bits
ciphertext = XChaCha20-Poly1305(
key = contentKey,
nonce = nonce,
plaintext = UTF8(message)
)`}
</Code>
<Text c="dimmed" lh={1.75}>
XChaCha20-Poly1305 is an AEAD construction: it encrypts the message and
produces an authentication tag. Decryption fails if the protected
ciphertext has been changed or the wrong key is supplied.
</Text>
<Title order={3}>Recipient key envelope</Title>
<List spacing="sm" className={classes.list}>
<List.Item>
The client generates an ephemeral secp256k1 key pair for the
message.
</List.Item>
<List.Item>
ECDH combines the ephemeral private key with the recipient&apos;s
long-term public key.
</List.Item>
<List.Item>
The x-coordinate of the shared point becomes a 32-byte AES key.
</List.Item>
<List.Item>
The 56-byte message material — content key followed by nonce — is
encrypted with AES-CBC and PKCS#7 padding using a random 16-byte IV.
</List.Item>
<List.Item>
The envelope carries the IV, encrypted material, and uncompressed
ephemeral public key. The recipient reconstructs the same shared
secret with their private key.
</List.Item>
</List>
<Code block className={classes.codeBlock}>
{`messageSecret = contentKey || nonce
ephemeral = secp256k1.randomKeyPair() ephemeral = secp256k1.randomKeyPair()
sharedPoint = ECDH(ephemeral.private, recipient.public) sharedPoint = ECDH(ephemeral.private, recipient.public)
wrappingKey = sharedPoint.x[0..31] wrappingKey = sharedPoint.x[0..31]
keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.public)`} keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.public)`}</TechnicalCode>
</Code>
<Title order={3}>What crosses the service boundary</Title> <LegalText>
<Text c="dimmed" lh={1.75}> The packet sent to the service contains routing identifiers, timestamp,
The message packet contains sender and recipient identifiers, a message ID, encrypted content, recipient key envelope, and attachment
timestamp, message ID, encrypted content, the recipient key envelope, descriptors. The service does not receive the direct-message content key
and attachment descriptors. The server can route this packet, but it or plaintext message.
does not receive the direct-message content key or plaintext message. </LegalText>
</Text> </LegalSection>
</TechnicalSection>
<TechnicalSection <LegalSection title="3. Attachment encryption">
id="attachments" <LegalText>
eyebrow="Attachments" Files, images, audio, and video are encrypted before upload. Binary data is
title="Authenticated encryption, one chunk at a time." split into independent chunks so that large files can be streamed, retried,
> cached, and decrypted by range without loading the complete file into
<Text c="dimmed" lh={1.75}> memory.
Files, images, audio, and video are encrypted before upload. Large </LegalText>
payloads are processed as independent chunks so the client can stream, <LegalText>
retry, cache, and decrypt ranges without holding an entire file in The attachment password is derived from the secret of the message that
memory. carries the attachment. PBKDF2-SHA-256 with 100,000 iterations and a random
</Text> 16-byte salt derives an AES-256-GCM key. Each chunk receives a new random
12-byte IV. AES-GCM authenticates the encrypted chunk and rejects modified
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" mt="xl"> data before plaintext is returned.
{[ </LegalText>
['AES-256-GCM', 'Authenticated encryption for every binary chunk'], <TechnicalCode>
['PBKDF2-SHA-256', '100,000 iterations and a 16-byte random salt'], RAC2 || salt[16] || iv[12] || AES-256-GCM-ciphertext-and-tag
['Random IV', 'A fresh 12-byte IV for every chunk'], </TechnicalCode>
].map(([title, description], index) => { <Table.ScrollContainer minWidth={560}>
const Icon = [IconFileTextShield, IconBinaryTree, IconKey][index]; <Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
return ( <Table.Thead>
<Paper key={title} withBorder radius="md" p="lg"> <Table.Tr>
<ThemeIcon variant="light" radius="xl"> <Table.Th>Segment</Table.Th>
<Icon size={18} /> <Table.Th>Length</Table.Th>
</ThemeIcon> <Table.Th>Purpose</Table.Th>
<Text fw={700} mt="md"> </Table.Tr>
{title} </Table.Thead>
</Text> <Table.Tbody>
<Text c="dimmed" size="sm" mt={6} lh={1.6}> {ATTACHMENT_LAYOUT.map(([segment, length, purpose]) => (
{description} <Table.Tr key={segment}>
</Text> <Table.Td>{segment}</Table.Td>
</Paper> <Table.Td>{length}</Table.Td>
); <Table.Td>{purpose}</Table.Td>
})}
</SimpleGrid>
<Title order={3}>RAC2 encrypted chunk format</Title>
<Code block className={classes.codeBlock}>
RAC2 || salt[16] || iv[12] || AES-256-GCM-ciphertext-and-tag
</Code>
<Table.ScrollContainer minWidth={620}>
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
<Table.Thead>
<Table.Tr>
<Table.Th>Segment</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Purpose</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> ))}
<Table.Tbody> </Table.Tbody>
{ATTACHMENT_LAYOUT.map(([segment, length, purpose]) => ( </Table>
<Table.Tr key={segment}> </Table.ScrollContainer>
<Table.Td> <LegalText>
<Code>{segment}</Code> The CDN stores ciphertext and transfer metadata such as object ID, size,
</Table.Td> and chunk count. It does not receive the attachment decryption secret.
<Table.Td>{length}</Table.Td> </LegalText>
<Table.Td> </LegalSection>
<Text c="dimmed" size="sm">
{purpose}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Text c="dimmed" mt="xl" lh={1.75}> <LegalSection title="4. Groups and compatibility format">
One random salt and derived AES key are reused within an attachment <LegalText>
encryption session; every chunk receives an independent random IV. A group uses a shared group secret available to its members. Group messages
AES-GCM&apos;s authentication tag lets the client reject modified or do not carry a new recipient envelope for every member. The current group
corrupted chunks before exposing plaintext. The CDN stores encrypted and inline-payload compatibility codec compresses the input with DEFLATE,
chunks and object metadata, not the file decryption secret. derives an AES key with PBKDF2, and encrypts it with AES-CBC using a random
</Text> IV.
</TechnicalSection> </LegalText>
<TechnicalCode>{`input
→ DEFLATE
→ PBKDF2-SHA-256(password, "rosetta", 1,000 iterations)
→ AES-CBC with a random 16-byte IV
→ Base64(iv) : Base64(ciphertext)`}</TechnicalCode>
<LegalText>
This compatibility format provides confidentiality but does not include an
AEAD authentication tag. Binary attachments use the newer RAC2 AES-GCM
format described above.
</LegalText>
</LegalSection>
<TechnicalSection <LegalSection title="5. Account keys and local storage">
id="groups" <LegalText>
eyebrow="Groups and compatibility" The recovery phrase is expanded to a BIP39 seed. SHA-256 of that seed
title="Shared group keys and inline payloads." becomes the 32-byte secp256k1 private key, and the client derives the
> compressed public key used as the account identity.
<Text c="dimmed" lh={1.75}> </LegalText>
A Rosetta group has a shared secret available to its members. Group <LegalText>
messages do not transmit a new recipient envelope with every message; The private key, recovery phrase, readable message cache, and sender-side
the shared group key is used by the client&apos;s compatibility codec. copy of the message secret are encrypted locally with material derived from
Inline attachment data, sender-readable message copies, and protected the user&apos;s password or account private key. The plaintext account
local values use the same codec. password is not sent to the service.
</Text> </LegalText>
<Code block className={classes.codeBlock}> </LegalSection>
{`input
→ DEFLATE compression
→ PBKDF2(password, "rosetta", 1,000 iterations)
→ AES-CBC with random 16-byte IV
→ Base64(iv) : Base64(ciphertext)`}
</Code>
<Text c="dimmed" lh={1.75}>
This compatibility format provides confidentiality but does not include
an AEAD authentication tag. Binary attachments use the newer RAC2
AES-GCM format described above.
</Text>
</TechnicalSection>
<TechnicalSection <LegalSection title="6. Security boundaries">
id="key-material" <LegalText>
eyebrow="Identity and storage" End-to-end encryption protects direct-message content and binary attachment
title="Long-term keys remain device-controlled." contents. It does not hide sender and recipient identifiers, timestamps,
> message IDs, delivery state, attachment size, object IDs, network address,
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg"> or request timing.
<Paper withBorder radius="md" p="xl"> </LegalText>
<ThemeIcon variant="light" radius="xl"> <LegalText>
<IconKey size={19} /> The direct-message ciphertext has AEAD integrity, but the current recipient
</ThemeIcon> key envelope is not a digital signature. Compromise of a recipient&apos;s
<Title order={3}>Account identity</Title> long-term private key can expose previously captured key envelopes, so the
<Text c="dimmed" size="sm" mt="sm" lh={1.7}> current design does not claim long-term forward secrecy. Group keys also
The recovery phrase is expanded to a BIP39 seed. SHA-256 of that require rotation when membership changes.
seed becomes the 32-byte secp256k1 private key, and the client </LegalText>
derives a compressed public key for the account identity. </LegalSection>
</Text>
</Paper>
<Paper withBorder radius="md" p="xl">
<ThemeIcon variant="light" radius="xl">
<IconLock size={19} />
</ThemeIcon>
<Title order={3}>Local protection</Title>
<Text c="dimmed" size="sm" mt="sm" lh={1.7}>
The private key, recovery phrase, readable message cache, and
sender-side message secret are encrypted locally with material
derived from the user&apos;s password or account private key.
The plaintext account password is not sent to the service.
</Text>
</Paper>
</SimpleGrid>
</TechnicalSection>
<TechnicalSection <LegalSection title="7. Terminology and references">
id="security-properties" <LegalList>
eyebrow="Security properties" <List.Item>
title="What the current design protects — and what remains visible." <strong>AEAD:</strong> authenticated encryption that provides
> confidentiality and detects modification.
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg"> </List.Item>
<Paper withBorder radius="md" p="xl"> <List.Item>
<Text c="teal" fw={700}> <strong>ECDH:</strong> elliptic-curve key agreement used to calculate a
Protected shared secret without transmitting it.
</Text> </List.Item>
<List spacing="sm" mt="md" className={classes.list}> <List.Item>
<List.Item>Direct-message plaintext and per-message secret</List.Item> <strong>Nonce / IV:</strong> a value that must not repeat for a given
<List.Item>Binary attachment contents and integrity</List.Item> key and encryption mode.
<List.Item>Locally stored private key and recovery phrase</List.Item> </List.Item>
<List.Item>Sender-side readable message cache</List.Item> <List.Item>
</List> <strong>Forward secrecy:</strong> protection of old session keys after a
</Paper> long-term key is compromised.
<Paper withBorder radius="md" p="xl"> </List.Item>
<Text c="orange" fw={700}> </LegalList>
Not hidden by end-to-end encryption <Text className={classes.references}>
</Text> References:{' '}
<List spacing="sm" mt="md" className={classes.list}> <Anchor href="https://csrc.nist.gov/pubs/sp/800/38/d/final">
<List.Item>Sender and recipient or group identifiers</List.Item> NIST SP 800-38D
<List.Item>Message timestamps, IDs, and delivery metadata</List.Item> </Anchor>
<List.Item>Attachment object IDs, sizes, and transfer metadata</List.Item> ,{' '}
<List.Item>Network information such as IP addresses and timing</List.Item> <Anchor href="https://www.rfc-editor.org/rfc/rfc8018">RFC 8018</Anchor>,{' '}
</List> <Anchor href="https://www.secg.org/sec2-v2.pdf">SEC 2</Anchor>, and{' '}
</Paper> <Anchor href="https://github.com/paulmillr/noble-ciphers">
</SimpleGrid> noble-ciphers
</Anchor>
<Paper withBorder radius="md" p="xl" mt="lg" className={classes.boundary}> .
<Group gap="md" align="flex-start" wrap="nowrap"> </Text>
<ThemeIcon variant="light" radius="xl" color="orange"> </LegalSection>
<IconRoute size={19} /> </LegalPage>
</ThemeIcon>
<div>
<Text fw={700}>Important implementation boundaries</Text>
<Text c="dimmed" size="sm" mt={6} lh={1.7}>
Direct-message ciphertext has AEAD integrity, but the
current recipient key envelope is not a digital signature.
Compromise of a recipient&apos;s long-term private key can
expose previously captured direct-message key envelopes, so
this design does not claim long-term forward secrecy.
Shared group keys similarly require careful rotation when
membership changes.
</Text>
</div>
</Group>
</Paper>
</TechnicalSection>
<TechnicalSection
id="terminology"
eyebrow="Terminology"
title="Cryptographic terms used by Rosetta."
>
<Box component="dl" className={classes.terms}>
<Term term="AEAD">
Authenticated Encryption with Associated Data. A cipher mode that
protects confidentiality and detects unauthorized modification.
</Term>
<Term term="XChaCha20-Poly1305">
A stream-cipher-based AEAD construction with a 192-bit nonce,
allowing securely generated random nonces with a very low collision
probability.
</Term>
<Term term="AES-GCM">
An AES mode that combines counter-mode encryption with a Galois
authentication tag. Rosetta uses it for RAC2 attachment chunks.
</Term>
<Term term="ECDH">
Elliptic Curve Diffie–Hellman. Two parties combine a private key
with another party&apos;s public key to calculate the same shared
secret without transmitting that secret.
</Term>
<Term term="secp256k1">
The elliptic curve used for Rosetta account keys and ephemeral
direct-message key envelopes.
</Term>
<Term term="PBKDF2">
A password-based key derivation function that repeatedly applies a
pseudorandom function with a salt to derive key material.
</Term>
<Term term="Nonce / IV">
A value that must be unique for a key and encryption operation.
Rosetta generates random nonces and IVs using the platform
cryptographic random number generator.
</Term>
<Term term="Forward secrecy">
A property in which compromise of a long-term key does not reveal
earlier session keys. The current Rosetta direct-message envelope
does not claim this property.
</Term>
</Box>
<Stack gap="xs" mt="xl">
<Text fw={700}>Standards and implementation references</Text>
<Anchor
href="https://csrc.nist.gov/pubs/sp/800/38/d/final"
target="_blank"
rel="noreferrer"
>
NIST SP 800-38D — GCM and GMAC
</Anchor>
<Anchor
href="https://www.rfc-editor.org/rfc/rfc8018"
target="_blank"
rel="noreferrer"
>
RFC 8018 — PBKDF2 and PKCS #5
</Anchor>
<Anchor
href="https://www.secg.org/sec2-v2.pdf"
target="_blank"
rel="noreferrer"
>
SEC 2 — secp256k1 domain parameters
</Anchor>
<Anchor
href="https://github.com/paulmillr/noble-ciphers"
target="_blank"
rel="noreferrer"
>
noble-ciphers — XChaCha20-Poly1305 implementation
</Anchor>
</Stack>
</TechnicalSection>
</Container>
</main>
<SiteFooter />
</> </>
); );
} }

View File

@@ -1,103 +1,41 @@
.hero { .hero {
overflow: hidden;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8)); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
} }
.heroGrid { .heroContainer {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(340px, 0.92fr); place-items: center;
gap: 72px; min-height: 620px;
min-height: calc(100vh - 72px); padding-top: 88px;
padding-top: 72px; padding-bottom: 96px;
padding-bottom: 72px; }
.heroCopy {
width: 100%;
max-width: 760px;
} }
.heroTitle { .heroTitle {
max-width: 720px; margin-top: 30px;
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: clamp(52px, 7vw, 76px); font-size: clamp(44px, 6vw, 64px);
font-weight: 600; font-weight: 600;
letter-spacing: -0.045em; letter-spacing: -0.04em;
line-height: 1.02; line-height: 1.06;
text-align: center;
} }
.heroDescription { .heroDescription {
max-width: 680px; max-width: 650px;
margin-top: 28px; margin-top: 24px;
font-size: 20px; font-size: 19px;
line-height: 1.65; line-height: 1.65;
} }
.preview {
position: relative;
display: grid;
place-items: center;
min-height: 640px;
}
.preview::before {
position: absolute;
width: 520px;
height: 520px;
border-radius: 50%;
background: light-dark(rgba(34, 139, 230, 0.11), rgba(34, 139, 230, 0.16));
content: '';
filter: blur(2px);
}
.phone {
position: relative;
z-index: 1;
width: min(100%, 380px);
padding: 20px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
}
.phoneHeader {
padding-bottom: var(--mantine-spacing-md);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.messages {
padding: 32px 0 24px;
}
.messageIncoming,
.messageOutgoing {
max-width: 84%;
}
.messageIncoming {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.messageOutgoing {
margin-left: auto;
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.18));
}
.encryptionNote {
color: var(--mantine-color-blue-6);
padding-top: var(--mantine-spacing-md);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.floatingCard {
position: absolute;
z-index: 2;
right: -24px;
bottom: 84px;
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-md);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
}
.featuresSection, .featuresSection,
.appsSection, .appsSection,
.encryptionSection { .encryptionSection {
padding: 112px 0; padding: 104px 0;
} }
.featuresSection { .featuresSection {
@@ -115,10 +53,10 @@
.sectionHeading h2 { .sectionHeading h2 {
margin-top: var(--mantine-spacing-xs); margin-top: var(--mantine-spacing-xs);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: clamp(36px, 5vw, 52px); font-size: clamp(34px, 5vw, 48px);
font-weight: 600; font-weight: 600;
letter-spacing: -0.035em; letter-spacing: -0.035em;
line-height: 1.08; line-height: 1.1;
} }
.sectionHeading > p:last-child { .sectionHeading > p:last-child {
@@ -135,11 +73,15 @@
} }
.appCard { .appCard {
min-height: 320px; min-height: 280px;
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
} }
.downloadButton {
min-height: 42px;
}
.encryptionSection { .encryptionSection {
padding-top: 0; padding-top: 0;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8)); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
@@ -150,65 +92,47 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 48px; gap: 48px;
padding: 56px; padding: 52px;
background: linear-gradient(135deg, var(--mantine-color-blue-8), var(--mantine-color-blue-5)); background: linear-gradient(135deg, var(--mantine-color-blue-8), var(--mantine-color-blue-5));
} }
.encryptionCard h2 { .encryptionCard h2 {
font-size: clamp(32px, 4vw, 46px); font-size: clamp(30px, 4vw, 42px);
font-weight: 600; font-weight: 600;
letter-spacing: -0.03em; letter-spacing: -0.03em;
line-height: 1.1; line-height: 1.12;
} }
.encryptionButton { .encryptionButton {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--mantine-color-blue-8) !important;
} }
@media (max-width: 62em) { @media (max-width: 62em) {
.heroGrid { .heroContainer {
grid-template-columns: 1fr;
gap: 28px;
padding-top: 88px;
}
.preview {
min-height: 560px; min-height: 560px;
} }
.floatingCard {
right: 8%;
}
} }
@media (max-width: 48em) { @media (max-width: 48em) {
.heroGrid { .heroContainer {
min-height: auto; min-height: 500px;
padding-top: 64px; padding-top: 64px;
padding-bottom: 64px; padding-bottom: 64px;
} }
.heroTitle { .heroTitle {
font-size: clamp(44px, 13vw, 62px); font-size: clamp(40px, 12vw, 54px);
} }
.heroDescription { .heroDescription {
font-size: 18px; font-size: 18px;
} }
.preview {
min-height: 520px;
}
.floatingCard {
right: 0;
bottom: 40px;
}
.featuresSection, .featuresSection,
.appsSection, .appsSection,
.encryptionSection { .encryptionSection {
padding: 80px 0; padding: 76px 0;
} }
.encryptionSection { .encryptionSection {
@@ -218,24 +142,18 @@
.encryptionCard { .encryptionCard {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
padding: 36px 28px; padding: 34px 28px;
} }
} }
@media (max-width: 36em) { @media (max-width: 36em) {
.heroCopy :global(.mantine-Group-root) { .heroActions {
align-items: stretch; align-items: stretch;
} flex-direction: column;
.heroCopy :global(.mantine-Button-root) {
width: 100%; width: 100%;
} }
.preview { .heroActions :global(.mantine-Button-root) {
min-height: 480px; width: 100%;
}
.floatingCard {
display: none;
} }
} }

View File

@@ -1,5 +1,4 @@
import { import {
Badge,
Box, Box,
Button, Button,
Card, Card,
@@ -18,14 +17,11 @@ import {
IconBrandAndroid, IconBrandAndroid,
IconBrandApple, IconBrandApple,
IconBrandWindows, IconBrandWindows,
IconCheck,
IconCode, IconCode,
IconDatabaseOff, IconDatabaseOff,
IconDeviceDesktop, IconDeviceDesktop,
IconFileTextShield, IconFileTextShield,
IconKey,
IconLock, IconLock,
IconMessageCircle,
IconPhoneOff, IconPhoneOff,
type TablerIcon, type TablerIcon,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -73,7 +69,7 @@ const FEATURES: Array<{ icon: TablerIcon; title: string; description: string }>
icon: IconCode, icon: IconCode,
title: 'Documented protocol', title: 'Documented protocol',
description: description:
'Every packet, object, enum, and field is published from the protocol source schemas.', 'Every packet, type, enum, and field is published from the protocol source schemas.',
}, },
]; ];
@@ -175,7 +171,7 @@ function DownloadApps() {
</Text> </Text>
</Box> </Box>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg" mt={48}> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg" mt={48}>
{PLATFORMS.map(({ key, title, description, icon: Icon }) => { {PLATFORMS.map(({ key, title, description, icon: Icon }) => {
const platformReleases = groupedReleases[key] || []; const platformReleases = groupedReleases[key] || [];
@@ -200,6 +196,7 @@ function DownloadApps() {
variant="light" variant="light"
justify="space-between" justify="space-between"
rightSection={<IconArrowDown size={16} />} rightSection={<IconArrowDown size={16} />}
className={classes.downloadButton}
> >
{architectureName(release.arch)} · {release.version} {architectureName(release.arch)} · {release.version}
</Button> </Button>
@@ -239,32 +236,18 @@ export function LandingPage() {
<SiteHeader /> <SiteHeader />
<main> <main>
<Box component="section" className={classes.hero}> <Box component="section" className={classes.hero}>
<Container className={classes.heroGrid}> <Container className={classes.heroContainer}>
<Stack justify="center" gap={0} className={classes.heroCopy}> <Stack align="center" gap={0} className={classes.heroCopy}>
<Group gap="md" mb="xl"> <RosettaLogo size={82} />
<RosettaLogo size={74} />
<Badge size="lg" variant="light" radius="xl">
Private messaging
</Badge>
</Group>
<Title order={1} className={classes.heroTitle}> <Title order={1} className={classes.heroTitle}>
A new era of{' '} Private messaging, simply.
<Text
component="span"
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
inherit
>
private
</Text>{' '}
messaging.
</Title> </Title>
<Text className={classes.heroDescription} c="dimmed"> <Text className={classes.heroDescription} c="dimmed" ta="center">
Rosetta keeps identity, conversations, and media under your control Rosetta keeps identity, conversations, and media under your control
— without requiring a phone number or building a readable message — without requiring a phone number or building a readable message
archive on the server. archive on the server.
</Text> </Text>
<Group mt={36}> <Group mt={32} className={classes.heroActions}>
<Button component="a" href="#apps" size="lg" radius="md"> <Button component="a" href="#apps" size="lg" radius="md">
Download Rosetta Download Rosetta
</Button> </Button>
@@ -278,86 +261,7 @@ export function LandingPage() {
Explore Proto Explore Proto
</Button> </Button>
</Group> </Group>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg" mt={48}>
{[
['No phone', 'Identity without personal contact data'],
['Encrypted', 'Messages and media protected by default'],
['Local', 'Readable history stays on your devices'],
].map(([value, label]) => (
<div key={value}>
<Text fw={700}>{value}</Text>
<Text c="dimmed" size="xs" mt={4} lh={1.45}>
{label}
</Text>
</div>
))}
</SimpleGrid>
</Stack> </Stack>
<Box className={classes.preview} aria-label="Rosetta conversation preview">
<Paper withBorder radius={32} shadow="xl" className={classes.phone}>
<Group justify="space-between" className={classes.phoneHeader}>
<Group gap="sm">
<RosettaLogo size={36} />
<div>
<Text fw={700} size="sm">
Rosetta
</Text>
<Text c="blue" size="xs">
Encrypted
</Text>
</div>
</Group>
<ThemeIcon variant="light" radius="xl">
<IconLock size={16} />
</ThemeIcon>
</Group>
<Stack gap="md" className={classes.messages}>
<Paper radius="lg" p="md" className={classes.messageIncoming}>
<Text size="sm">Your recovery phrase never leaves your device.</Text>
<Text c="dimmed" size="xs" ta="right" mt={6}>
10:42
</Text>
</Paper>
<Paper radius="lg" p="md" className={classes.messageOutgoing}>
<Text size="sm">And the server cannot read this conversation.</Text>
<Group justify="flex-end" gap={4} mt={6}>
<Text c="blue" size="xs">
10:43
</Text>
<IconCheck size={14} color="var(--mantine-color-blue-6)" />
</Group>
</Paper>
<Paper radius="lg" p="md" className={classes.messageIncoming}>
<Text size="sm">Exactly. Privacy is the default.</Text>
<Text c="dimmed" size="xs" ta="right" mt={6}>
10:43
</Text>
</Paper>
</Stack>
<Group justify="center" gap="xs" className={classes.encryptionNote}>
<IconKey size={15} />
<Text size="xs">End-to-end encrypted session</Text>
</Group>
</Paper>
<Paper withBorder radius="lg" shadow="md" className={classes.floatingCard}>
<ThemeIcon radius="xl" variant="light">
<IconMessageCircle size={17} />
</ThemeIcon>
<div>
<Text fw={600} size="sm">
Delivered privately
</Text>
<Text c="dimmed" size="xs">
No readable server history
</Text>
</div>
</Paper>
</Box>
</Container> </Container>
</Box> </Box>
@@ -413,7 +317,7 @@ export function LandingPage() {
<Button <Button
component={Link} component={Link}
to="/encryption" to="/encryption"
color="white" color="blue"
variant="white" variant="white"
size="lg" size="lg"
className={classes.encryptionButton} className={classes.encryptionButton}

View File

@@ -1,117 +1,168 @@
.protoCode {
margin: 18px 0;
padding: 16px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-xs);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
font-size: 12px;
line-height: 1.65;
overflow-x: auto;
white-space: pre;
}
.docsLink {
display: inline-block;
margin-top: 44px;
font-size: var(--mantine-font-size-sm);
font-weight: 600;
text-decoration: none;
}
.page { .page {
padding: 48px 0 112px; padding: 40px 0 104px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8)); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
} }
.hero { .hero {
max-width: 780px; max-width: 760px;
padding: 64px 0 48px; padding: 48px 0 44px;
} }
.hero h1 { .hero h1 {
margin-top: var(--mantine-spacing-lg);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: clamp(52px, 7vw, 76px); font-size: clamp(32px, 5vw, 44px);
font-weight: 600; font-weight: 600;
letter-spacing: -0.045em; letter-spacing: -0.03em;
line-height: 1.02; line-height: 1.08;
} }
.heroDescription { .heroDescription {
margin-top: var(--mantine-spacing-lg); margin-top: var(--mantine-spacing-md);
font-size: 19px; font-size: 16px;
line-height: 1.65; line-height: 1.7;
} }
.stats { .stats {
margin-bottom: 56px; margin: 0 0 52px;
} }
.docsGrid { .docsGrid {
display: grid; display: grid;
grid-template-columns: 200px minmax(0, 1fr); grid-template-columns: 220px minmax(0, 1fr);
gap: 48px; gap: 44px;
align-items: start; align-items: start;
} }
.sidebar { .sidebar {
position: sticky; position: sticky;
top: 104px; top: 92px;
max-height: calc(100vh - 112px);
padding-right: 10px;
overflow-y: auto;
scrollbar-width: thin;
} }
.sidebar a { .sidebar a {
width: fit-content; width: fit-content;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
text-decoration: none; text-decoration: none;
} }
.sidebar a:hover {
color: var(--mantine-color-blue-6);
text-decoration: none;
}
.layerDescription {
padding: 12px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-xs);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
}
.layerDescription code {
display: inline-block;
font-size: 11px;
}
.documentation { .documentation {
min-width: 0; min-width: 0;
} }
.filters { .filters {
display: grid; display: grid;
gap: var(--mantine-spacing-md); gap: var(--mantine-spacing-sm);
margin-bottom: 56px; margin-bottom: 16px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
} }
.schemaSection { .schemaSection {
scroll-margin-top: 104px; scroll-margin-top: 96px;
margin-top: 72px; margin-top: 64px;
}
.schemaSection:first-of-type {
margin-top: 0;
} }
.schemaSection > h2 { .schemaSection > h2 {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: 36px; font-size: 28px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.03em; letter-spacing: -0.02em;
} }
.definition { .definition {
scroll-margin-top: 104px; scroll-margin-top: 96px;
margin-top: 44px; margin-top: 38px;
padding-top: 36px; padding-top: 32px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
} }
.definition h3 { .definition h3 {
margin-top: 5px; margin-top: 5px;
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white)); color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
font-size: 27px; font-size: 23px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.015em;
}
.yamlDetails {
margin: 20px 0;
}
.yamlSummary {
width: fit-content;
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
font-size: var(--mantine-font-size-sm);
font-weight: 600;
cursor: pointer;
user-select: none;
} }
.yamlBlock { .yamlBlock {
margin: var(--mantine-spacing-lg) 0; margin-top: 10px;
padding: 12px 12px 16px 18px; padding: 12px 12px 16px 18px;
border: 1px solid light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-9)); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-xs);
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.1)); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
} }
.yamlBlock code { .yamlBlock code {
display: block; display: block;
padding: 0; padding: 0;
background: transparent; background: transparent;
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1)); color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
font-size: 13px; font-size: 12px;
line-height: 1.65; line-height: 1.65;
overflow-x: auto; overflow-x: auto;
white-space: pre; white-space: pre;
} }
.table { .table {
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-xs);
} }
.table th { .table th {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
} }
.typeLink { .typeLink {
@@ -132,30 +183,32 @@
.sidebar { .sidebar {
position: static; position: static;
max-height: none;
padding: var(--mantine-spacing-lg); padding: var(--mantine-spacing-lg);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-lg); border-radius: var(--mantine-radius-sm);
overflow: visible;
} }
} }
@media (max-width: 48em) { @media (max-width: 48em) {
.page { .page {
padding-top: 32px; padding-top: 28px;
} }
.hero { .hero {
padding-top: 48px; padding-top: 38px;
} }
.docsGrid { .docsGrid {
gap: 32px; gap: 28px;
} }
.schemaSection { .schemaSection {
margin-top: 56px; margin-top: 48px;
} }
.definition { .definition {
margin-top: 36px; margin-top: 32px;
} }
} }

View File

@@ -7,8 +7,8 @@ import {
Container, Container,
CopyButton, CopyButton,
Group, Group,
List,
Paper, Paper,
SegmentedControl,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
@@ -23,7 +23,6 @@ import {
IconBraces, IconBraces,
IconCheck, IconCheck,
IconCopy, IconCopy,
IconFileCode,
IconList, IconList,
IconPackage, IconPackage,
IconSearch, IconSearch,
@@ -31,6 +30,12 @@ import {
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { SEO } from '../components/SEO/SEO'; import { SEO } from '../components/SEO/SEO';
import {
LegalList,
LegalPage,
LegalSection,
LegalText,
} from '../components/LegalPage/LegalPage';
import { SiteFooter } from '../components/SiteFooter/SiteFooter'; import { SiteFooter } from '../components/SiteFooter/SiteFooter';
import { SiteHeader } from '../components/SiteHeader/SiteHeader'; import { SiteHeader } from '../components/SiteHeader/SiteHeader';
import { protoLayers } from '../generated/protoDocs'; import { protoLayers } from '../generated/protoDocs';
@@ -43,8 +48,6 @@ import type {
} from '../types/proto'; } from '../types/proto';
import classes from './ProtoPage.module.css'; import classes from './ProtoPage.module.css';
type SchemaCategory = 'all' | 'packets' | 'types' | 'enums';
const PRIMITIVE_TYPES = new Set([ const PRIMITIVE_TYPES = new Set([
'bool', 'bool',
'bytes', 'bytes',
@@ -86,16 +89,29 @@ function entityMatches(
return searchable.includes(query); return searchable.includes(query);
} }
function TypeReference({ type, layer }: { type: string; layer: ProtoLayer }) { function TypeReference({
type,
layer,
onNavigate,
}: {
type: string;
layer: ProtoLayer;
onNavigate: (targetId: string) => void;
}) {
const array = type.endsWith('[]'); const array = type.endsWith('[]');
const baseType = array ? type.slice(0, -2) : type; const baseType = array ? type.slice(0, -2) : type;
const targetType = layer.types.find((item) => item.name === baseType); const targetType = layer.types.find((item) => item.name === baseType);
const targetEnum = layer.enums.find((item) => item.name === baseType); const targetEnum = layer.enums.find((item) => item.name === baseType);
if (targetType || targetEnum) { if (targetType || targetEnum) {
const targetId = `${targetType ? 'type' : 'enum'}-${slug(baseType)}`;
return ( return (
<Anchor <Anchor
href={`#${targetType ? 'type' : 'enum'}-${slug(baseType)}`} href={`#${targetId}`}
onClick={(event) => {
event.preventDefault();
onNavigate(targetId);
}}
className={classes.typeLink} className={classes.typeLink}
> >
{baseType} {baseType}
@@ -111,7 +127,15 @@ function TypeReference({ type, layer }: { type: string; layer: ProtoLayer }) {
return <Code>{type}</Code>; return <Code>{type}</Code>;
} }
function FieldTable({ fields, layer }: { fields: ProtoField[]; layer: ProtoLayer }) { function FieldTable({
fields,
layer,
onNavigate,
}: {
fields: ProtoField[];
layer: ProtoLayer;
onNavigate: (targetId: string) => void;
}) {
if (fields.length === 0) { if (fields.length === 0) {
return ( return (
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
@@ -148,7 +172,11 @@ function FieldTable({ fields, layer }: { fields: ProtoField[]; layer: ProtoLayer
)} )}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<TypeReference type={field.type} layer={layer} /> <TypeReference
type={field.type}
layer={layer}
onNavigate={onNavigate}
/>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text c={field.description ? undefined : 'dimmed'} size="sm"> <Text c={field.description ? undefined : 'dimmed'} size="sm">
@@ -166,10 +194,7 @@ function FieldTable({ fields, layer }: { fields: ProtoField[]; layer: ProtoLayer
function YamlSnippet({ value }: { value: string }) { function YamlSnippet({ value }: { value: string }) {
return ( return (
<Box className={classes.yamlBlock}> <Box className={classes.yamlBlock}>
<Group justify="space-between" mb="xs"> <Group justify="flex-end" mb="xs">
<Text fw={700} size="xs" c="dimmed" tt="uppercase">
YAML schema
</Text>
<CopyButton value={value} timeout={1600}> <CopyButton value={value} timeout={1600}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy YAML'}> <Tooltip label={copied ? 'Copied' : 'Copy YAML'}>
@@ -190,7 +215,26 @@ function YamlSnippet({ value }: { value: string }) {
); );
} }
function PacketDefinition({ packet, layer }: { packet: ProtoPacket; layer: ProtoLayer }) { function HiddenYaml({ value }: { value: string }) {
return (
<Box component="details" className={classes.yamlDetails}>
<Box component="summary" className={classes.yamlSummary}>
See YAML schema
</Box>
<YamlSnippet value={value} />
</Box>
);
}
function PacketDefinition({
packet,
layer,
onNavigate,
}: {
packet: ProtoPacket;
layer: ProtoLayer;
onNavigate: (targetId: string) => void;
}) {
return ( return (
<Box component="article" id={`packet-${slug(packet.name)}`} className={classes.definition}> <Box component="article" id={`packet-${slug(packet.name)}`} className={classes.definition}>
<Text c="blue" fw={700} size="xs" tt="uppercase"> <Text c="blue" fw={700} size="xs" tt="uppercase">
@@ -200,24 +244,32 @@ function PacketDefinition({ packet, layer }: { packet: ProtoPacket; layer: Proto
<Text c="dimmed" mt="sm" lh={1.65}> <Text c="dimmed" mt="sm" lh={1.65}>
{packet.description || `Packet ${packet.name} in protocol layer ${layer.version}.`} {packet.description || `Packet ${packet.name} in protocol layer ${layer.version}.`}
</Text> </Text>
<YamlSnippet value={packet.yaml} /> <HiddenYaml value={packet.yaml} />
<FieldTable fields={packet.fields} layer={layer} /> <FieldTable fields={packet.fields} layer={layer} onNavigate={onNavigate} />
</Box> </Box>
); );
} }
function TypeDefinition({ type, layer }: { type: ProtoType; layer: ProtoLayer }) { function TypeDefinition({
type,
layer,
onNavigate,
}: {
type: ProtoType;
layer: ProtoLayer;
onNavigate: (targetId: string) => void;
}) {
return ( return (
<Box component="article" id={`type-${slug(type.name)}`} className={classes.definition}> <Box component="article" id={`type-${slug(type.name)}`} className={classes.definition}>
<Text c="blue" fw={700} size="xs" tt="uppercase"> <Text c="blue" fw={700} size="xs" tt="uppercase">
Object Type
</Text> </Text>
<Title order={3}>{type.name}</Title> <Title order={3}>{type.name}</Title>
<Text c="dimmed" mt="sm" lh={1.65}> <Text c="dimmed" mt="sm" lh={1.65}>
{type.description || `Object ${type.name} in protocol layer ${layer.version}.`} {type.description || `Type ${type.name} in protocol layer ${layer.version}.`}
</Text> </Text>
<YamlSnippet value={type.yaml} /> <HiddenYaml value={type.yaml} />
<FieldTable fields={type.fields} layer={layer} /> <FieldTable fields={type.fields} layer={layer} onNavigate={onNavigate} />
</Box> </Box>
); );
} }
@@ -232,7 +284,7 @@ function EnumDefinition({ item, layer }: { item: ProtoEnum; layer: ProtoLayer })
<Text c="dimmed" mt="sm" lh={1.65}> <Text c="dimmed" mt="sm" lh={1.65}>
{item.description || `Enumeration ${item.name} in protocol layer ${layer.version}.`} {item.description || `Enumeration ${item.name} in protocol layer ${layer.version}.`}
</Text> </Text>
<YamlSnippet value={item.yaml} /> <HiddenYaml value={item.yaml} />
<Table.ScrollContainer minWidth={520}> <Table.ScrollContainer minWidth={520}>
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}> <Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
<Table.Thead> <Table.Thead>
@@ -268,9 +320,80 @@ function EnumDefinition({ item, layer }: { item: ProtoEnum; layer: ProtoLayer })
} }
export function ProtoPage() { export function ProtoPage() {
return (
<>
<SEO
title="Rosetta Proto"
description="A technical overview of the Rosetta TLV protocol and its versioned schemas."
canonical="https://rosetta.im/proto/"
keywords="Rosetta protocol, TLV, packet schema, binary protocol"
/>
<LegalPage eyebrow="Protocol" title="Proto">
<LegalText lead>
Rosetta uses a compact binary protocol based on TLV fields. A packet starts
with a two-byte unsigned packet ID. The remaining payload is a sequence of
typed fields that can be decoded without relying on their physical order.
</LegalText>
<LegalSection title="Packet layout">
<LegalText>
Each field begins with a one-byte field ID and a one-byte wire type. Fixed
numeric and boolean types are followed directly by their value. Strings,
byte arrays, nested types, and other variable-length values include a
two-byte unsigned length before the value bytes.
</LegalText>
<Code block className={classes.protoCode}>
{`packet_id:uint16
field_id:uint8 | wire_type:uint8 | [length:uint16] | value
field_id:uint8 | wire_type:uint8 | [length:uint16] | value
...`}
</Code>
</LegalSection>
<LegalSection title="TLV behavior">
<LegalList>
<List.Item>
<strong>Tag:</strong> the decimal field ID defined in the selected
schema layer.
</List.Item>
<List.Item>
<strong>Type:</strong> the wire type used to interpret the value.
</List.Item>
<List.Item>
<strong>Length:</strong> present for strings, byte arrays, and nested
values; fixed-width primitives do not need it.
</List.Item>
<List.Item>
<strong>Value:</strong> the serialized field data in network byte order.
</List.Item>
<List.Item>
Optional fields are omitted when absent. Lists are represented by
repeating the same field ID. Nested types are encoded as
length-delimited TLV payloads.
</List.Item>
</LegalList>
</LegalSection>
<LegalSection title="Layers and compatibility">
<LegalText>
Protocol layers are generated from RCC YAML schemas. Every layer defines
packet IDs, field IDs, primitive types, reusable types, enumerations, and
optional values. Keeping field IDs stable allows compatible decoders to
read known fields while the schema evolves.
</LegalText>
</LegalSection>
<Anchor component={Link} to="/proto/docs" className={classes.docsLink}>
See docs of PROTO →
</Anchor>
</LegalPage>
</>
);
}
export function ProtoDocsPage() {
const latestLayer = protoLayers.at(-1)!; const latestLayer = protoLayers.at(-1)!;
const [selectedVersion, setSelectedVersion] = useState(String(latestLayer.version)); const [selectedVersion, setSelectedVersion] = useState(String(latestLayer.version));
const [category, setCategory] = useState<SchemaCategory>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const layer = const layer =
@@ -279,17 +402,24 @@ export function ProtoPage() {
const packets = layer.packets.filter((packet) => entityMatches(packet, query)); const packets = layer.packets.filter((packet) => entityMatches(packet, query));
const types = layer.types.filter((type) => entityMatches(type, query)); const types = layer.types.filter((type) => entityMatches(type, query));
const enums = layer.enums.filter((item) => entityMatches(item, query)); const enums = layer.enums.filter((item) => entityMatches(item, query));
const resultCount = const resultCount = packets.length + types.length + enums.length;
(category === 'all' || category === 'packets' ? packets.length : 0) +
(category === 'all' || category === 'types' ? types.length : 0) + const navigateToDefinition = (targetId: string) => {
(category === 'all' || category === 'enums' ? enums.length : 0); setSearch('');
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
window.history.replaceState(null, '', `#${targetId}`);
document.getElementById(targetId)?.scrollIntoView({ block: 'start' });
});
});
};
return ( return (
<> <>
<SEO <SEO
title="Rosetta Proto Documentation" title="Rosetta Proto Documentation"
description="Generated Rosetta protocol documentation for packets, objects, enums, fields, and schema layers." description="Generated Rosetta protocol documentation for packets, types, enums, fields, and schema layers."
canonical="https://rosetta.im/proto/" canonical="https://rosetta.im/proto/docs/"
keywords="Rosetta protocol, packet schema, YAML documentation, Proto" keywords="Rosetta protocol, packet schema, YAML documentation, Proto"
/> />
<SiteHeader /> <SiteHeader />
@@ -300,117 +430,192 @@ export function ProtoPage() {
</Anchor> </Anchor>
<Text component="span" c="dimmed" size="sm"> <Text component="span" c="dimmed" size="sm">
{' '} {' '}
/ Proto / Proto / Docs
</Text> </Text>
<Box className={classes.hero}> <Box className={classes.hero}>
<Group gap="sm"> <Title order={1}>Protocol documentation</Title>
<ThemeIcon size={46} radius="md" variant="light">
<IconFileCode size={24} />
</ThemeIcon>
<Badge variant="light">Generated from YAML</Badge>
</Group>
<Title order={1}>Rosetta Proto</Title>
<Text c="dimmed" className={classes.heroDescription}> <Text c="dimmed" className={classes.heroDescription}>
Complete protocol reference generated from the RCC layer schemas. Browse Complete protocol reference generated from the RCC layer schemas. Browse
packet definitions, objects, enumerations, field IDs, optional values, packet definitions, types, enumerations, field IDs, optional values,
comments, and linked types. comments, and linked types.
</Text> </Text>
</Box> </Box>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" className={classes.stats}>
{[
[IconPackage, layer.packets.length, 'Packets'],
[IconBraces, layer.types.length, 'Objects'],
[IconList, layer.enums.length, 'Enums'],
].map(([Icon, value, label]) => {
const StatIcon = Icon as typeof IconPackage;
return (
<Paper key={String(label)} withBorder radius="md" p="lg">
<Group>
<ThemeIcon variant="light" radius="xl">
<StatIcon size={18} />
</ThemeIcon>
<div>
<Text fw={700} fz="xl">
{String(value)}
</Text>
<Text c="dimmed" size="sm">
{String(label)}
</Text>
</div>
</Group>
</Paper>
);
})}
</SimpleGrid>
<Box className={classes.docsGrid}> <Box className={classes.docsGrid}>
<aside className={classes.sidebar}> <aside className={classes.sidebar}>
<Stack gap="lg"> <Stack gap="lg">
<Select <Select
label="Protocol layer" label="Protocol layer"
value={selectedVersion} value={selectedVersion}
onChange={(value) => value && setSelectedVersion(value)} onChange={(value) => {
if (value) {
setSelectedVersion(value);
setSearch('');
}
}}
data={protoLayers.map((item) => ({ data={protoLayers.map((item) => ({
value: String(item.version), value: String(item.version),
label: `Layer ${item.version}${item === latestLayer ? ' · latest' : ''}`, label: `Layer ${item.version}${item === latestLayer ? ' · latest' : ''}`,
}))} }))}
/> />
<Box className={classes.layerDescription}>
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
Description
</Text>
<Text size="xs" mt={6} lh={1.55}>
{layer.description || 'Initial protocol schema.'}
</Text>
<Code mt="xs">{layer.source}</Code>
</Box>
<Stack gap={6}> <Stack gap={6}>
<Text fw={700} size="xs" tt="uppercase" c="dimmed"> <Text fw={700} size="xs" tt="uppercase" c="dimmed">
Contents Contents
</Text> </Text>
<Anchor href="#packets" size="sm"> <Anchor
href="#packets"
size="sm"
onClick={(event) => {
event.preventDefault();
navigateToDefinition('packets');
}}
>
Packets ({layer.packets.length}) Packets ({layer.packets.length})
</Anchor> </Anchor>
<Anchor href="#objects" size="sm"> <Anchor
Objects ({layer.types.length}) href="#types"
size="sm"
onClick={(event) => {
event.preventDefault();
navigateToDefinition('types');
}}
>
Types ({layer.types.length})
</Anchor> </Anchor>
<Anchor href="#enums" size="sm"> <Anchor
href="#enums"
size="sm"
onClick={(event) => {
event.preventDefault();
navigateToDefinition('enums');
}}
>
Enums ({layer.enums.length}) Enums ({layer.enums.length})
</Anchor> </Anchor>
</Stack> </Stack>
<Stack gap={6}> <Stack gap={4}>
<Text fw={700} size="xs" tt="uppercase" c="dimmed"> <Text fw={700} size="xs" tt="uppercase" c="dimmed">
Source Packets
</Text> </Text>
<Code>{layer.source}</Code> {layer.packets.map((packet) => {
<Text c="dimmed" size="xs" lh={1.5}> const targetId = `packet-${slug(packet.name)}`;
{layer.description || 'Initial protocol schema.'} return (
<Anchor
key={packet.name}
href={`#${targetId}`}
size="xs"
onClick={(event) => {
event.preventDefault();
navigateToDefinition(targetId);
}}
>
{packet.name}
</Anchor>
);
})}
</Stack>
<Stack gap={4}>
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
Types
</Text> </Text>
{layer.types.map((type) => {
const targetId = `type-${slug(type.name)}`;
return (
<Anchor
key={type.name}
href={`#${targetId}`}
size="xs"
onClick={(event) => {
event.preventDefault();
navigateToDefinition(targetId);
}}
>
{type.name}
</Anchor>
);
})}
</Stack>
<Stack gap={4}>
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
Enums
</Text>
{layer.enums.map((item) => {
const targetId = `enum-${slug(item.name)}`;
return (
<Anchor
key={item.name}
href={`#${targetId}`}
size="xs"
onClick={(event) => {
event.preventDefault();
navigateToDefinition(targetId);
}}
>
{item.name}
</Anchor>
);
})}
</Stack> </Stack>
</Stack> </Stack>
</aside> </aside>
<Box className={classes.documentation}> <Box className={classes.documentation}>
<Paper withBorder radius="lg" p="lg" className={classes.filters}> <Paper withBorder radius="sm" p="lg" className={classes.filters}>
<TextInput <TextInput
value={search} value={search}
onChange={(event) => setSearch(event.currentTarget.value)} onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={17} />} leftSection={<IconSearch size={17} />}
placeholder="Search packets, objects, fields, or types" placeholder="Search packets, types, fields, or enums"
aria-label="Search Proto documentation" aria-label="Search Proto documentation"
/> />
<SegmentedControl
value={category}
onChange={(value) => setCategory(value as SchemaCategory)}
data={[
{ label: 'All', value: 'all' },
{ label: 'Packets', value: 'packets' },
{ label: 'Objects', value: 'types' },
{ label: 'Enums', value: 'enums' },
]}
fullWidth
/>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{resultCount} definitions in layer {layer.version} {resultCount} definitions in layer {layer.version}
</Text> </Text>
</Paper> </Paper>
{(category === 'all' || category === 'packets') && packets.length > 0 && ( <SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" className={classes.stats}>
{[
[IconPackage, packets.length, 'Packets'],
[IconBraces, types.length, 'Types'],
[IconList, enums.length, 'Enums'],
].map(([Icon, value, label]) => {
const StatIcon = Icon as typeof IconPackage;
return (
<Paper key={String(label)} withBorder radius="sm" p="md">
<Group>
<ThemeIcon variant="light" radius="xl">
<StatIcon size={18} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">
{String(value)}
</Text>
<Text c="dimmed" size="xs">
{String(label)}
</Text>
</div>
</Group>
</Paper>
);
})}
</SimpleGrid>
{packets.length > 0 && (
<Box component="section" id="packets" className={classes.schemaSection}> <Box component="section" id="packets" className={classes.schemaSection}>
<Title order={2}>Packets</Title> <Title order={2}>Packets</Title>
<Text c="dimmed" mt="xs"> <Text c="dimmed" mt="xs">
@@ -421,24 +626,30 @@ export function ProtoPage() {
key={packet.name} key={packet.name}
packet={packet} packet={packet}
layer={layer} layer={layer}
onNavigate={navigateToDefinition}
/> />
))} ))}
</Box> </Box>
)} )}
{(category === 'all' || category === 'types') && types.length > 0 && ( {types.length > 0 && (
<Box component="section" id="objects" className={classes.schemaSection}> <Box component="section" id="types" className={classes.schemaSection}>
<Title order={2}>Objects</Title> <Title order={2}>Types</Title>
<Text c="dimmed" mt="xs"> <Text c="dimmed" mt="xs">
Reusable structured values referenced by packet fields. Reusable structured values referenced by packet fields.
</Text> </Text>
{types.map((type) => ( {types.map((type) => (
<TypeDefinition key={type.name} type={type} layer={layer} /> <TypeDefinition
key={type.name}
type={type}
layer={layer}
onNavigate={navigateToDefinition}
/>
))} ))}
</Box> </Box>
)} )}
{(category === 'all' || category === 'enums') && enums.length > 0 && ( {enums.length > 0 && (
<Box component="section" id="enums" className={classes.schemaSection}> <Box component="section" id="enums" className={classes.schemaSection}>
<Title order={2}>Enums</Title> <Title order={2}>Enums</Title>
<Text c="dimmed" mt="xs"> <Text c="dimmed" mt="xs">
@@ -454,7 +665,7 @@ export function ProtoPage() {
<Paper withBorder radius="lg" p="xl" ta="center"> <Paper withBorder radius="lg" p="xl" ta="center">
<Text fw={600}>No schema definitions found.</Text> <Text fw={600}>No schema definitions found.</Text>
<Text c="dimmed" size="sm" mt="xs"> <Text c="dimmed" size="sm" mt="xs">
Try another search term or category. Try another search term.
</Text> </Text>
</Paper> </Paper>
)} )}

View File

@@ -1,74 +1,72 @@
.lead { .intro {
max-width: 780px; margin-top: 36px;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: 20px; font-size: var(--mantine-font-size-md);
line-height: 1.6; line-height: 1.7;
} }
.primaryAction { .searchArea {
height: 54px; margin-top: 28px;
padding-inline: 38px;
} }
.topic { .problemField textarea {
padding-top: 16px;
padding-left: 48px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
font-size: var(--mantine-font-size-md);
line-height: 1.65;
}
.suggestions {
margin-top: 8px;
padding: 14px;
}
.suggestion {
display: block;
padding: 12px 10px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition:
transform 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
} }
.topic:hover { .suggestion:first-child {
transform: translateY(-2px); border-top: 0;
border-color: light-dark(var(--mantine-color-blue-3), var(--mantine-color-blue-7));
box-shadow: var(--mantine-shadow-md);
} }
.section { .suggestion:hover {
scroll-margin-top: var(--mantine-spacing-xl); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
padding-top: 48px; text-decoration: none;
}
.issuesSection,
.contact {
margin-top: 48px;
padding-top: 36px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
} }
.sectionTitle, .issuesSection > h2,
.contactTitle { .contact > h2 {
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white)); font-size: 22px;
font-weight: 650; font-weight: 650;
} }
.sectionTitle { .issueList {
font-size: clamp(21px, 3vw, 26px); margin-top: 20px;
} }
.section :global(.mantine-Accordion-root) { .issue {
margin-top: var(--mantine-spacing-lg); scroll-margin-top: 100px;
padding: 26px 0;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
} }
.contact { .issue:first-child {
border: 1px solid light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-9)); border-top: 0;
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
} }
.contactTitle { .issue h3 {
font-size: 26px; font-size: 17px;
} font-weight: 650;
.contactCopy {
max-width: 540px;
}
@media (max-width: 48em) {
.primaryAction {
padding-inline: 18px;
}
.section {
padding-top: 36px;
}
.contact :global(.mantine-Group-root) {
align-items: flex-start;
flex-direction: column;
}
} }

View File

@@ -1,252 +1,137 @@
import { import { Anchor, Box, Button, Paper, Stack, Text, Textarea, Title } from '@mantine/core';
Accordion, import { IconMail, IconSearch } from '@tabler/icons-react';
Anchor, import { useMemo, useState } from 'react';
Box, import knownIssues from '../../known-issues.json';
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconKey,
IconMail,
IconMessageCircle,
IconShieldLock,
type TablerIcon,
} from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { LegalPage } from '../components/LegalPage/LegalPage'; import { LegalPage } from '../components/LegalPage/LegalPage';
import { SEO } from '../components/SEO/SEO'; import { SEO } from '../components/SEO/SEO';
import classes from './SupportPage.module.css'; import classes from './SupportPage.module.css';
const TOPICS: Array<{
href: string;
icon: TablerIcon;
title: string;
description: string;
}> = [
{
href: '#account',
icon: IconKey,
title: 'Account and access',
description: 'Recovery phrases, device access, and account security.',
},
{
href: '#messaging',
icon: IconMessageCircle,
title: 'Messaging and media',
description: 'Messages, photos, videos, files, voice messages, and groups.',
},
{
href: '#privacy',
icon: IconShieldLock,
title: 'Privacy and safety',
description: 'Security questions, privacy information, and abuse reports.',
},
];
const ACCOUNT_FAQS = [
{
question: 'How do I access my account on a new device?',
answer:
'Install Rosetta and enter your recovery phrase in the exact order it was created. Rosetta may ask you to confirm a new device to protect your account.',
},
{
question: 'I lost my recovery phrase. Can it be restored?',
answer:
'No. Your recovery phrase is the key to your account. Rosetta cannot recover it for you, so keep it private and backed up before you need it.',
},
{
question: 'Why am I asked to confirm a new device?',
answer:
'New-device confirmation helps prevent someone who has obtained a recovery phrase from silently accessing your account on another device.',
},
{
question: 'How do I report an account-access problem?',
answer:
'Email us with the device model, Rosetta version, and a short description of what happened. Do not include your recovery phrase or private keys.',
},
];
const MESSAGING_FAQS = [
{
question: 'How do I send a photo, video, or file?',
answer:
'Open a chat, tap the attachment button next to the message field, choose the item you want to share, then send it. Allow Photos or Files access when iOS asks for it.',
},
{
question: 'Why is an attachment still sending?',
answer:
'Media uploads depend on the file size and your connection. Keep Rosetta open and connected until the send indicator completes. You can retry a message that failed.',
},
{
question: 'How do I send a voice message?',
answer:
'In a chat, press and hold the microphone control to record, then release it to send. Microphone permission is required the first time you record.',
},
{
question: 'How do I get help with a group?',
answer:
'Contact us with the group name and a description of the issue. Do not send private recovery phrases, message contents, or encryption keys.',
},
];
const PRIVACY_FAQS = [
{
question: 'How is message content protected?',
answer:
'Rosetta is designed to encrypt message content before it is sent. For details about how we handle data, read the Rosetta Privacy Policy.',
},
{
question: 'How do I report abuse or a security issue?',
answer: (
<>
Email{' '}
<Anchor href="mailto:support@rosetta.im?subject=Rosetta%20Security%20Report">
Support@rosetta.im
</Anchor>{' '}
with a description of the concern. Do not include recovery phrases or private
keys.
</>
),
},
];
interface SupportSectionProps {
id: string;
title: string;
description: string;
items: Array<{ question: string; answer: ReactNode }>;
}
function SupportSection({ id, title, description, items }: SupportSectionProps) {
return (
<Box component="section" id={id} className={classes.section}>
<div>
<Title order={2} className={classes.sectionTitle}>
{title}
</Title>
<Text c="dimmed" mt="xs" lh={1.65}>
{description}
</Text>
</div>
<Accordion multiple variant="separated" radius="md">
{items.map(({ question, answer }) => (
<Accordion.Item key={question} value={question}>
<Accordion.Control>{question}</Accordion.Control>
<Accordion.Panel>
<Text c="dimmed" size="sm" lh={1.65}>
{answer}
</Text>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Box>
);
}
export function SupportPage() { export function SupportPage() {
const [query, setQuery] = useState('');
const normalizedQuery = query.trim().toLowerCase();
const suggestions = useMemo(() => {
if (!normalizedQuery) {
return [];
}
const terms = normalizedQuery
.split(/\s+/)
.map((term) => term.replace(/[^a-z0-9-]/g, ''))
.filter((term) => term.length > 2);
return knownIssues
.map((issue) => {
const searchable = [issue.title, issue.summary, issue.solution, ...issue.keywords]
.join(' ')
.toLowerCase();
const score = terms.filter((term) => searchable.includes(term)).length;
return { issue, score };
})
.filter(({ score }) => score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, 4);
}, [normalizedQuery]);
return ( return (
<> <>
<SEO <SEO
title="Rosetta Support" title="Rosetta Support"
description="Contact Rosetta Support for help with account access, recovery phrases, messaging, media, privacy, and security." description="Find known Rosetta issues or contact support for help with account access, messaging, media, privacy, and security."
canonical="https://rosetta.im/support/" canonical="https://rosetta.im/support/"
keywords="Rosetta support, account recovery, messaging help, privacy help" keywords="Rosetta support, known issues, account recovery, messaging help"
/> />
<LegalPage <LegalPage eyebrow="Help centre" title="Support">
eyebrow="Rosetta Support" <Text className={classes.intro}>
title="Help with your Rosetta account and messages." Describe the problem in your own words. Rosetta will suggest a relevant known
> issue while you type. The text is processed only in this page and is not sent
<Stack gap="xl"> to the support service.
<div> </Text>
<Text className={classes.lead}>
Find answers about account access, recovery phrases, private messaging,
shared media, and privacy. For anything else, contact our support team.
</Text>
<Button
component="a"
href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request"
leftSection={<IconMail size={18} />}
variant="default"
mt="xl"
size="xl"
className={classes.primaryAction}
>
Email Rosetta Support
</Button>
</div>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md"> <Box className={classes.searchArea}>
{TOPICS.map(({ href, icon: Icon, title, description }) => ( <Textarea
<Card value={query}
key={href} onChange={(event) => setQuery(event.currentTarget.value)}
component="a" leftSection={<IconSearch size={18} />}
href={href} leftSectionProps={{ style: { alignItems: 'flex-start', paddingTop: 16 } }}
withBorder placeholder="For example: my attachment is still sending…"
radius="md" aria-label="Describe your Rosetta problem"
padding="lg" minRows={6}
className={classes.topic} autosize
> maxRows={10}
<ThemeIcon variant="light" radius="xl" size="lg"> radius="sm"
<Icon size={18} /> className={classes.problemField}
</ThemeIcon> />
<Text fw={600} mt="md">
{title} {normalizedQuery && (
<Paper withBorder radius="sm" className={classes.suggestions}>
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
Suggested issues
</Text>
{suggestions.length > 0 ? (
<Stack gap={0} mt="xs">
{suggestions.map(({ issue }) => (
<Anchor
key={issue.id}
href={`#${issue.id}`}
className={classes.suggestion}
>
<Text fw={600} size="sm">
{issue.title}
</Text>
<Text c="dimmed" size="xs" mt={3}>
{issue.summary}
</Text>
</Anchor>
))}
</Stack>
) : (
<Text c="dimmed" size="sm" mt="xs">
No matching known issue. You can contact support below.
</Text> </Text>
<Text c="dimmed" size="sm" mt={6} lh={1.5}> )}
{description} </Paper>
)}
</Box>
<Box component="section" className={classes.issuesSection}>
<Title order={2}>Known issues</Title>
<Text c="dimmed" size="sm" mt="xs" lh={1.7}>
Common situations and the information needed to resolve them.
</Text>
<Box className={classes.issueList}>
{knownIssues.map((issue) => (
<Box component="article" id={issue.id} key={issue.id} className={classes.issue}>
<Title order={3}>{issue.title}</Title>
<Text c="dimmed" size="sm" mt={6} lh={1.7}>
{issue.summary}
</Text> </Text>
</Card> <Text size="sm" mt="md" lh={1.75}>
{issue.solution}
</Text>
</Box>
))} ))}
</SimpleGrid> </Box>
</Box>
<SupportSection <Box component="section" className={classes.contact}>
id="account" <Title order={2}>Contact support</Title>
title="Account and access" <Text c="dimmed" size="sm" mt="xs" lh={1.7}>
description="Rosetta accounts are accessed with a recovery phrase instead of a phone number. Keep your phrase private and stored somewhere safe." Include your operating system, Rosetta version, approximate time of the
items={ACCOUNT_FAQS} problem, and reproduction steps. Never send a recovery phrase or private
/> key.
<SupportSection </Text>
id="messaging" <Button
title="Messaging and media" component="a"
description="Rosetta supports private chats, groups, photos, videos, files, and voice messages. Attachment choices can vary depending on your device permissions and the selected item." href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request"
items={MESSAGING_FAQS} leftSection={<IconMail size={17} />}
/> variant="default"
<SupportSection mt="lg"
id="privacy" >
title="Privacy and safety" support@rosetta.im
description="Rosetta is designed for private communication. If you have a security concern or a question about data handling, contact us directly." </Button>
items={PRIVACY_FAQS} </Box>
/>
<Paper radius="lg" p="xl" className={classes.contact}>
<Group justify="space-between" align="center">
<div>
<Title order={2} className={classes.contactTitle}>
Need more help?
</Title>
<Text c="dimmed" mt="xs" className={classes.contactCopy}>
Send us your device model, app version, and a short explanation.
Never send a recovery phrase, private key, or private message
content.
</Text>
</div>
<Anchor
href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request"
fw={700}
>
Support@rosetta.im
</Anchor>
</Group>
</Paper>
</Stack>
</LegalPage> </LegalPage>
</> </>
); );