forked from Rosetta/landing
Refine documentation and support experience
This commit is contained in:
@@ -15,6 +15,9 @@ const EncryptionPage = lazy(() =>
|
||||
const ProtoPage = lazy(() =>
|
||||
import('./pages/ProtoPage').then((module) => ({ default: module.ProtoPage })),
|
||||
);
|
||||
const ProtoDocsPage = lazy(() =>
|
||||
import('./pages/ProtoPage').then((module) => ({ default: module.ProtoDocsPage })),
|
||||
);
|
||||
const SupportPage = lazy(() =>
|
||||
import('./pages/SupportPage').then((module) => ({ default: module.SupportPage })),
|
||||
);
|
||||
@@ -51,6 +54,7 @@ export default function App() {
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/encryption" element={<EncryptionPage />} />
|
||||
<Route path="/proto" element={<ProtoPage />} />
|
||||
<Route path="/proto/docs" element={<ProtoDocsPage />} />
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
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));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.intro {
|
||||
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));
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
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;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.08;
|
||||
@@ -39,8 +39,8 @@
|
||||
max-width: 780px;
|
||||
margin: 40px 0 0;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
font-size: 17px;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.text,
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.list li::marker {
|
||||
color: var(--mantine-color-blue-5);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -77,7 +77,7 @@
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
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;
|
||||
line-height: 1.25;
|
||||
}
|
||||
@@ -95,8 +95,8 @@
|
||||
|
||||
.address {
|
||||
margin: var(--mantine-spacing-md) 0;
|
||||
border-left: 3px solid var(--mantine-color-blue-5);
|
||||
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.address p {
|
||||
|
||||
@@ -33,9 +33,9 @@ export function LegalPage({
|
||||
<Box className={classes.page}>
|
||||
<SiteHeader />
|
||||
<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}>
|
||||
<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}
|
||||
</Text>
|
||||
<Title order={1} className={classes.title}>
|
||||
@@ -93,7 +93,7 @@ export function LegalList({ children }: { children: ReactNode }) {
|
||||
|
||||
export function LegalAddress({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Paper radius="md" p="md" className={classes.address}>
|
||||
<Paper radius="sm" p="md" className={classes.address}>
|
||||
<Text component="p">{children}</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export function SiteFooter() {
|
||||
Terms of Use
|
||||
</Anchor>
|
||||
<Anchor href="mailto:support@rosetta.im" c="dimmed" size="sm">
|
||||
Support@rosetta.im
|
||||
support@rosetta.im
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
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 {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SiteHeader() {
|
||||
|
||||
const links = NAVIGATION.map(({ label, to }) => {
|
||||
const active =
|
||||
(to === '/proto' && pathname === '/proto') ||
|
||||
(to === '/proto' && pathname.startsWith('/proto')) ||
|
||||
(to === '/encryption' && pathname === '/encryption') ||
|
||||
(to === '/support' && pathname === '/support');
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const protoLayers = [
|
||||
{
|
||||
"name": "HandshakeStage",
|
||||
"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",
|
||||
"values": [
|
||||
{
|
||||
|
||||
@@ -1,162 +1,30 @@
|
||||
.page {
|
||||
padding: 48px 0 112px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
|
||||
}
|
||||
|
||||
.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;
|
||||
.subheading {
|
||||
margin: 28px 0 10px;
|
||||
color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-dark-0));
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
margin: var(--mantine-spacing-lg) 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-9));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.1));
|
||||
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1));
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
.code {
|
||||
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;
|
||||
}
|
||||
|
||||
.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));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,25 @@
|
||||
import { Anchor, Code, List, Table, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Code,
|
||||
Container,
|
||||
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';
|
||||
LegalList,
|
||||
LegalPage,
|
||||
LegalSection,
|
||||
LegalText,
|
||||
} from '../components/LegalPage/LegalPage';
|
||||
import { SEO } from '../components/SEO/SEO';
|
||||
import { SiteFooter } from '../components/SiteFooter/SiteFooter';
|
||||
import { SiteHeader } from '../components/SiteHeader/SiteHeader';
|
||||
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 = [
|
||||
['Magic', '4 bytes', 'ASCII RAC2 (52 41 43 32)'],
|
||||
['Salt', '16 bytes', 'Random per attachment encryption session'],
|
||||
['IV', '12 bytes', 'Random and unique for every encrypted chunk'],
|
||||
['Ciphertext + tag', 'Variable', 'AES-256-GCM output including its authentication tag'],
|
||||
['IV', '12 bytes', 'Random for every encrypted chunk'],
|
||||
['Ciphertext + tag', 'Variable', 'AES-256-GCM output and authentication tag'],
|
||||
];
|
||||
|
||||
function TechnicalSection({
|
||||
id,
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function TechnicalCode({ children }: { children: string }) {
|
||||
return (
|
||||
<Box component="section" id={id} className={classes.section}>
|
||||
<Text c="blue" fw={700} size="xs" tt="uppercase" lts="0.08em">
|
||||
{eyebrow}
|
||||
</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>
|
||||
<Code block className={classes.code}>
|
||||
{children}
|
||||
</Code>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,404 +32,213 @@ export function EncryptionPage() {
|
||||
canonical="https://rosetta.im/encryption/"
|
||||
keywords="Rosetta encryption, XChaCha20-Poly1305, AES-256-GCM, secp256k1, encrypted attachments"
|
||||
/>
|
||||
<SiteHeader />
|
||||
<main className={classes.page}>
|
||||
<Container>
|
||||
<Anchor component={Link} to="/" size="sm">
|
||||
Rosetta
|
||||
</Anchor>
|
||||
<Text component="span" c="dimmed" size="sm">
|
||||
{' '}
|
||||
/ Encryption
|
||||
</Text>
|
||||
<LegalPage eyebrow="Technical documentation" title="Encryption">
|
||||
<LegalText lead>
|
||||
This document describes the current Rosetta desktop implementation. It covers
|
||||
direct messages, per-message keys, recipient key envelopes, group messages,
|
||||
local storage, and encrypted attachments. It is an implementation reference,
|
||||
not a claim of formal verification or an independent security audit.
|
||||
</LegalText>
|
||||
|
||||
<Box className={classes.hero}>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={46} radius="md" variant="light">
|
||||
<IconShieldCheck size={24} />
|
||||
</ThemeIcon>
|
||||
<Badge variant="light">Implementation reference</Badge>
|
||||
</Group>
|
||||
<Title order={1}>Rosetta Encryption</Title>
|
||||
<Text c="dimmed" className={classes.heroDescription}>
|
||||
A technical description of how the current Rosetta desktop client
|
||||
derives identity keys, protects direct and group messages, encrypts
|
||||
attachments, and separates plaintext from server-visible data.
|
||||
</Text>
|
||||
</Box>
|
||||
<LegalSection title="1. Hybrid encryption model">
|
||||
<LegalText>
|
||||
Rosetta does not encrypt an entire message or file with public-key
|
||||
cryptography. Public-key operations are comparatively slow and are not
|
||||
designed for large payloads. They are used only to establish and protect
|
||||
the small secret required by the content cipher.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The message text itself is encrypted with a symmetric AEAD cipher because
|
||||
symmetric cryptography is fast and appropriate for arbitrary amounts of
|
||||
data. In other words, the asymmetric part protects the key; the unique
|
||||
symmetric key protects the message.
|
||||
</LegalText>
|
||||
<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}>
|
||||
<Text fw={700}>Scope of this document</Text>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.65}>
|
||||
This page documents the current client implementation, including its
|
||||
compatibility formats. It is an implementation reference, not a claim
|
||||
of formal verification, an independent security audit, or a frozen
|
||||
protocol specification.
|
||||
</Text>
|
||||
</Paper>
|
||||
<LegalSection title="2. Direct-message encryption">
|
||||
<Title order={3} className={classes.subheading}>
|
||||
Content cipher
|
||||
</Title>
|
||||
<LegalText>
|
||||
The client generates the content key and nonce with the platform
|
||||
cryptographic random number generator. XChaCha20-Poly1305 encrypts the
|
||||
UTF-8 message and produces an authentication tag. A modified ciphertext or
|
||||
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}>
|
||||
{[
|
||||
['Direct messages', '#direct-messages'],
|
||||
['Attachments', '#attachments'],
|
||||
['Groups', '#groups'],
|
||||
['Key material', '#key-material'],
|
||||
['Security properties', '#security-properties'],
|
||||
['Terminology', '#terminology'],
|
||||
].map(([label, href]) => (
|
||||
<Anchor key={href} href={href} className={classes.contentLink}>
|
||||
{label}
|
||||
</Anchor>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<TechnicalSection
|
||||
id="direct-messages"
|
||||
eyebrow="Direct messages"
|
||||
title="A new authenticated cipher for every message."
|
||||
>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
Rosetta encrypts the text of each one-to-one message with a newly
|
||||
generated content key. The symmetric secret is never sent in plaintext:
|
||||
it is placed in a separate recipient-specific key envelope.
|
||||
</Text>
|
||||
|
||||
<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'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
|
||||
<Title order={3} className={classes.subheading}>
|
||||
Recipient key envelope
|
||||
</Title>
|
||||
<LegalList>
|
||||
<List.Item>
|
||||
The client creates an ephemeral secp256k1 key pair for the message.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
ECDH combines the ephemeral private key with the recipient's
|
||||
long-term public key.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
The x-coordinate of the shared point is used as a 32-byte AES wrapping
|
||||
key.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
The 56-byte message secret — content key followed by nonce — is wrapped
|
||||
with AES-CBC and PKCS#7 padding using a random 16-byte IV.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
The envelope contains the IV, encrypted message secret, and ephemeral
|
||||
public key. The content key is never sent in plaintext.
|
||||
</List.Item>
|
||||
</LegalList>
|
||||
<TechnicalCode>{`messageSecret = contentKey || nonce
|
||||
ephemeral = secp256k1.randomKeyPair()
|
||||
sharedPoint = ECDH(ephemeral.private, recipient.public)
|
||||
wrappingKey = sharedPoint.x[0..31]
|
||||
keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.public)`}
|
||||
</Code>
|
||||
keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.public)`}</TechnicalCode>
|
||||
|
||||
<Title order={3}>What crosses the service boundary</Title>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
The message packet contains sender and recipient identifiers, a
|
||||
timestamp, message ID, encrypted content, the recipient key envelope,
|
||||
and attachment descriptors. The server can route this packet, but it
|
||||
does not receive the direct-message content key or plaintext message.
|
||||
</Text>
|
||||
</TechnicalSection>
|
||||
<LegalText>
|
||||
The packet sent to the service contains routing identifiers, timestamp,
|
||||
message ID, encrypted content, recipient key envelope, and attachment
|
||||
descriptors. The service does not receive the direct-message content key
|
||||
or plaintext message.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="attachments"
|
||||
eyebrow="Attachments"
|
||||
title="Authenticated encryption, one chunk at a time."
|
||||
>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
Files, images, audio, and video are encrypted before upload. Large
|
||||
payloads are processed as independent chunks so the client can stream,
|
||||
retry, cache, and decrypt ranges without holding an entire file in
|
||||
memory.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" mt="xl">
|
||||
{[
|
||||
['AES-256-GCM', 'Authenticated encryption for every binary chunk'],
|
||||
['PBKDF2-SHA-256', '100,000 iterations and a 16-byte random salt'],
|
||||
['Random IV', 'A fresh 12-byte IV for every chunk'],
|
||||
].map(([title, description], index) => {
|
||||
const Icon = [IconFileTextShield, IconBinaryTree, IconKey][index];
|
||||
return (
|
||||
<Paper key={title} withBorder radius="md" p="lg">
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} mt="md">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.6}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
<LegalSection title="3. Attachment encryption">
|
||||
<LegalText>
|
||||
Files, images, audio, and video are encrypted before upload. Binary data is
|
||||
split into independent chunks so that large files can be streamed, retried,
|
||||
cached, and decrypted by range without loading the complete file into
|
||||
memory.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The attachment password is derived from the secret of the message that
|
||||
carries the attachment. PBKDF2-SHA-256 with 100,000 iterations and a random
|
||||
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
|
||||
data before plaintext is returned.
|
||||
</LegalText>
|
||||
<TechnicalCode>
|
||||
RAC2 || salt[16] || iv[12] || AES-256-GCM-ciphertext-and-tag
|
||||
</TechnicalCode>
|
||||
<Table.ScrollContainer minWidth={560}>
|
||||
<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.Thead>
|
||||
<Table.Tbody>
|
||||
{ATTACHMENT_LAYOUT.map(([segment, length, purpose]) => (
|
||||
<Table.Tr key={segment}>
|
||||
<Table.Td>{segment}</Table.Td>
|
||||
<Table.Td>{length}</Table.Td>
|
||||
<Table.Td>{purpose}</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{ATTACHMENT_LAYOUT.map(([segment, length, purpose]) => (
|
||||
<Table.Tr key={segment}>
|
||||
<Table.Td>
|
||||
<Code>{segment}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{length}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text c="dimmed" size="sm">
|
||||
{purpose}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<LegalText>
|
||||
The CDN stores ciphertext and transfer metadata such as object ID, size,
|
||||
and chunk count. It does not receive the attachment decryption secret.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<Text c="dimmed" mt="xl" lh={1.75}>
|
||||
One random salt and derived AES key are reused within an attachment
|
||||
encryption session; every chunk receives an independent random IV.
|
||||
AES-GCM's authentication tag lets the client reject modified or
|
||||
corrupted chunks before exposing plaintext. The CDN stores encrypted
|
||||
chunks and object metadata, not the file decryption secret.
|
||||
</Text>
|
||||
</TechnicalSection>
|
||||
<LegalSection title="4. Groups and compatibility format">
|
||||
<LegalText>
|
||||
A group uses a shared group secret available to its members. Group messages
|
||||
do not carry a new recipient envelope for every member. The current group
|
||||
and inline-payload compatibility codec compresses the input with DEFLATE,
|
||||
derives an AES key with PBKDF2, and encrypts it with AES-CBC using a random
|
||||
IV.
|
||||
</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
|
||||
id="groups"
|
||||
eyebrow="Groups and compatibility"
|
||||
title="Shared group keys and inline payloads."
|
||||
>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
A Rosetta group has a shared secret available to its members. Group
|
||||
messages do not transmit a new recipient envelope with every message;
|
||||
the shared group key is used by the client's compatibility codec.
|
||||
Inline attachment data, sender-readable message copies, and protected
|
||||
local values use the same codec.
|
||||
</Text>
|
||||
<Code block className={classes.codeBlock}>
|
||||
{`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>
|
||||
<LegalSection title="5. Account keys and local storage">
|
||||
<LegalText>
|
||||
The recovery phrase is expanded to a BIP39 seed. SHA-256 of that seed
|
||||
becomes the 32-byte secp256k1 private key, and the client derives the
|
||||
compressed public key used as the account identity.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The private key, recovery phrase, readable message cache, and sender-side
|
||||
copy of the message secret are encrypted locally with material derived from
|
||||
the user's password or account private key. The plaintext account
|
||||
password is not sent to the service.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="key-material"
|
||||
eyebrow="Identity and storage"
|
||||
title="Long-term keys remain device-controlled."
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<IconKey size={19} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>Account identity</Title>
|
||||
<Text c="dimmed" size="sm" mt="sm" lh={1.7}>
|
||||
The recovery phrase is expanded to a BIP39 seed. SHA-256 of that
|
||||
seed becomes the 32-byte secp256k1 private key, and the client
|
||||
derives a compressed public key for the account identity.
|
||||
</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's password or account private key.
|
||||
The plaintext account password is not sent to the service.
|
||||
</Text>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</TechnicalSection>
|
||||
<LegalSection title="6. Security boundaries">
|
||||
<LegalText>
|
||||
End-to-end encryption protects direct-message content and binary attachment
|
||||
contents. It does not hide sender and recipient identifiers, timestamps,
|
||||
message IDs, delivery state, attachment size, object IDs, network address,
|
||||
or request timing.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The direct-message ciphertext has AEAD integrity, but the current recipient
|
||||
key envelope is not a digital signature. Compromise of a recipient's
|
||||
long-term private key can expose previously captured key envelopes, so the
|
||||
current design does not claim long-term forward secrecy. Group keys also
|
||||
require rotation when membership changes.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="security-properties"
|
||||
eyebrow="Security properties"
|
||||
title="What the current design protects — and what remains visible."
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text c="teal" fw={700}>
|
||||
Protected
|
||||
</Text>
|
||||
<List spacing="sm" mt="md" className={classes.list}>
|
||||
<List.Item>Direct-message plaintext and per-message secret</List.Item>
|
||||
<List.Item>Binary attachment contents and integrity</List.Item>
|
||||
<List.Item>Locally stored private key and recovery phrase</List.Item>
|
||||
<List.Item>Sender-side readable message cache</List.Item>
|
||||
</List>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text c="orange" fw={700}>
|
||||
Not hidden by end-to-end encryption
|
||||
</Text>
|
||||
<List spacing="sm" mt="md" className={classes.list}>
|
||||
<List.Item>Sender and recipient or group identifiers</List.Item>
|
||||
<List.Item>Message timestamps, IDs, and delivery metadata</List.Item>
|
||||
<List.Item>Attachment object IDs, sizes, and transfer metadata</List.Item>
|
||||
<List.Item>Network information such as IP addresses and timing</List.Item>
|
||||
</List>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md" p="xl" mt="lg" className={classes.boundary}>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="xl" color="orange">
|
||||
<IconRoute size={19} />
|
||||
</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'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'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 />
|
||||
<LegalSection title="7. Terminology and references">
|
||||
<LegalList>
|
||||
<List.Item>
|
||||
<strong>AEAD:</strong> authenticated encryption that provides
|
||||
confidentiality and detects modification.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>ECDH:</strong> elliptic-curve key agreement used to calculate a
|
||||
shared secret without transmitting it.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Nonce / IV:</strong> a value that must not repeat for a given
|
||||
key and encryption mode.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Forward secrecy:</strong> protection of old session keys after a
|
||||
long-term key is compromised.
|
||||
</List.Item>
|
||||
</LegalList>
|
||||
<Text className={classes.references}>
|
||||
References:{' '}
|
||||
<Anchor href="https://csrc.nist.gov/pubs/sp/800/38/d/final">
|
||||
NIST SP 800-38D
|
||||
</Anchor>
|
||||
,{' '}
|
||||
<Anchor href="https://www.rfc-editor.org/rfc/rfc8018">RFC 8018</Anchor>,{' '}
|
||||
<Anchor href="https://www.secg.org/sec2-v2.pdf">SEC 2</Anchor>, and{' '}
|
||||
<Anchor href="https://github.com/paulmillr/noble-ciphers">
|
||||
noble-ciphers
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
</LegalSection>
|
||||
</LegalPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,41 @@
|
||||
.hero {
|
||||
overflow: hidden;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.heroGrid {
|
||||
.heroContainer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(340px, 0.92fr);
|
||||
gap: 72px;
|
||||
min-height: calc(100vh - 72px);
|
||||
padding-top: 72px;
|
||||
padding-bottom: 72px;
|
||||
place-items: center;
|
||||
min-height: 620px;
|
||||
padding-top: 88px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
max-width: 720px;
|
||||
margin-top: 30px;
|
||||
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;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.06;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
max-width: 680px;
|
||||
margin-top: 28px;
|
||||
font-size: 20px;
|
||||
max-width: 650px;
|
||||
margin-top: 24px;
|
||||
font-size: 19px;
|
||||
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,
|
||||
.appsSection,
|
||||
.encryptionSection {
|
||||
padding: 112px 0;
|
||||
padding: 104px 0;
|
||||
}
|
||||
|
||||
.featuresSection {
|
||||
@@ -115,10 +53,10 @@
|
||||
.sectionHeading h2 {
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
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;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.08;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
@@ -135,11 +73,15 @@
|
||||
}
|
||||
|
||||
.appCard {
|
||||
min-height: 320px;
|
||||
min-height: 280px;
|
||||
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));
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.encryptionSection {
|
||||
padding-top: 0;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
@@ -150,65 +92,47 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 48px;
|
||||
padding: 56px;
|
||||
padding: 52px;
|
||||
background: linear-gradient(135deg, var(--mantine-color-blue-8), var(--mantine-color-blue-5));
|
||||
}
|
||||
|
||||
.encryptionCard h2 {
|
||||
font-size: clamp(32px, 4vw, 46px);
|
||||
font-size: clamp(30px, 4vw, 42px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.encryptionButton {
|
||||
flex: 0 0 auto;
|
||||
color: var(--mantine-color-blue-8) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 62em) {
|
||||
.heroGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
.heroContainer {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.floatingCard {
|
||||
right: 8%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.heroGrid {
|
||||
min-height: auto;
|
||||
.heroContainer {
|
||||
min-height: 500px;
|
||||
padding-top: 64px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(44px, 13vw, 62px);
|
||||
font-size: clamp(40px, 12vw, 54px);
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.floatingCard {
|
||||
right: 0;
|
||||
bottom: 40px;
|
||||
}
|
||||
|
||||
.featuresSection,
|
||||
.appsSection,
|
||||
.encryptionSection {
|
||||
padding: 80px 0;
|
||||
padding: 76px 0;
|
||||
}
|
||||
|
||||
.encryptionSection {
|
||||
@@ -218,24 +142,18 @@
|
||||
.encryptionCard {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 36px 28px;
|
||||
padding: 34px 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 36em) {
|
||||
.heroCopy :global(.mantine-Group-root) {
|
||||
.heroActions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heroCopy :global(.mantine-Button-root) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.floatingCard {
|
||||
display: none;
|
||||
.heroActions :global(.mantine-Button-root) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -18,14 +17,11 @@ import {
|
||||
IconBrandAndroid,
|
||||
IconBrandApple,
|
||||
IconBrandWindows,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconDatabaseOff,
|
||||
IconDeviceDesktop,
|
||||
IconFileTextShield,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconMessageCircle,
|
||||
IconPhoneOff,
|
||||
type TablerIcon,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -73,7 +69,7 @@ const FEATURES: Array<{ icon: TablerIcon; title: string; description: string }>
|
||||
icon: IconCode,
|
||||
title: 'Documented protocol',
|
||||
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>
|
||||
</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 }) => {
|
||||
const platformReleases = groupedReleases[key] || [];
|
||||
|
||||
@@ -200,6 +196,7 @@ function DownloadApps() {
|
||||
variant="light"
|
||||
justify="space-between"
|
||||
rightSection={<IconArrowDown size={16} />}
|
||||
className={classes.downloadButton}
|
||||
>
|
||||
{architectureName(release.arch)} · {release.version}
|
||||
</Button>
|
||||
@@ -239,32 +236,18 @@ export function LandingPage() {
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<Box component="section" className={classes.hero}>
|
||||
<Container className={classes.heroGrid}>
|
||||
<Stack justify="center" gap={0} className={classes.heroCopy}>
|
||||
<Group gap="md" mb="xl">
|
||||
<RosettaLogo size={74} />
|
||||
<Badge size="lg" variant="light" radius="xl">
|
||||
Private messaging
|
||||
</Badge>
|
||||
</Group>
|
||||
<Container className={classes.heroContainer}>
|
||||
<Stack align="center" gap={0} className={classes.heroCopy}>
|
||||
<RosettaLogo size={82} />
|
||||
<Title order={1} className={classes.heroTitle}>
|
||||
A new era of{' '}
|
||||
<Text
|
||||
component="span"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
inherit
|
||||
>
|
||||
private
|
||||
</Text>{' '}
|
||||
messaging.
|
||||
Private messaging, simply.
|
||||
</Title>
|
||||
<Text className={classes.heroDescription} c="dimmed">
|
||||
<Text className={classes.heroDescription} c="dimmed" ta="center">
|
||||
Rosetta keeps identity, conversations, and media under your control
|
||||
— without requiring a phone number or building a readable message
|
||||
archive on the server.
|
||||
</Text>
|
||||
<Group mt={36}>
|
||||
<Group mt={32} className={classes.heroActions}>
|
||||
<Button component="a" href="#apps" size="lg" radius="md">
|
||||
Download Rosetta
|
||||
</Button>
|
||||
@@ -278,86 +261,7 @@ export function LandingPage() {
|
||||
Explore Proto
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
@@ -413,7 +317,7 @@ export function LandingPage() {
|
||||
<Button
|
||||
component={Link}
|
||||
to="/encryption"
|
||||
color="white"
|
||||
color="blue"
|
||||
variant="white"
|
||||
size="lg"
|
||||
className={classes.encryptionButton}
|
||||
|
||||
@@ -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 {
|
||||
padding: 48px 0 112px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
padding: 40px 0 104px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 780px;
|
||||
padding: 64px 0 48px;
|
||||
max-width: 760px;
|
||||
padding: 48px 0 44px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
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;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
font-size: 19px;
|
||||
line-height: 1.65;
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-bottom: 56px;
|
||||
margin: 0 0 52px;
|
||||
}
|
||||
|
||||
.docsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
gap: 48px;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 44px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 104px;
|
||||
top: 92px;
|
||||
max-height: calc(100vh - 112px);
|
||||
padding-right: 10px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
width: fit-content;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
gap: var(--mantine-spacing-md);
|
||||
margin-bottom: 56px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
gap: var(--mantine-spacing-sm);
|
||||
margin-bottom: 16px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.schemaSection {
|
||||
scroll-margin-top: 104px;
|
||||
margin-top: 72px;
|
||||
}
|
||||
|
||||
.schemaSection:first-of-type {
|
||||
margin-top: 0;
|
||||
scroll-margin-top: 96px;
|
||||
margin-top: 64px;
|
||||
}
|
||||
|
||||
.schemaSection > h2 {
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: 36px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
scroll-margin-top: 104px;
|
||||
margin-top: 44px;
|
||||
padding-top: 36px;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
scroll-margin-top: 96px;
|
||||
margin-top: 38px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.definition h3 {
|
||||
margin-top: 5px;
|
||||
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
|
||||
font-size: 27px;
|
||||
font-size: 23px;
|
||||
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 {
|
||||
margin: var(--mantine-spacing-lg) 0;
|
||||
margin-top: 10px;
|
||||
padding: 12px 12px 16px 18px;
|
||||
border: 1px solid light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-9));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.1));
|
||||
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));
|
||||
}
|
||||
|
||||
.yamlBlock code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1));
|
||||
font-size: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -132,30 +183,32 @@
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
padding: var(--mantine-spacing-lg);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.page {
|
||||
padding-top: 32px;
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-top: 48px;
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.docsGrid {
|
||||
gap: 32px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.schemaSection {
|
||||
margin-top: 56px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.definition {
|
||||
margin-top: 36px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Container,
|
||||
CopyButton,
|
||||
Group,
|
||||
List,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
IconBraces,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFileCode,
|
||||
IconList,
|
||||
IconPackage,
|
||||
IconSearch,
|
||||
@@ -31,6 +30,12 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SEO } from '../components/SEO/SEO';
|
||||
import {
|
||||
LegalList,
|
||||
LegalPage,
|
||||
LegalSection,
|
||||
LegalText,
|
||||
} from '../components/LegalPage/LegalPage';
|
||||
import { SiteFooter } from '../components/SiteFooter/SiteFooter';
|
||||
import { SiteHeader } from '../components/SiteHeader/SiteHeader';
|
||||
import { protoLayers } from '../generated/protoDocs';
|
||||
@@ -43,8 +48,6 @@ import type {
|
||||
} from '../types/proto';
|
||||
import classes from './ProtoPage.module.css';
|
||||
|
||||
type SchemaCategory = 'all' | 'packets' | 'types' | 'enums';
|
||||
|
||||
const PRIMITIVE_TYPES = new Set([
|
||||
'bool',
|
||||
'bytes',
|
||||
@@ -86,16 +89,29 @@ function entityMatches(
|
||||
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 baseType = array ? type.slice(0, -2) : type;
|
||||
const targetType = layer.types.find((item) => item.name === baseType);
|
||||
const targetEnum = layer.enums.find((item) => item.name === baseType);
|
||||
|
||||
if (targetType || targetEnum) {
|
||||
const targetId = `${targetType ? 'type' : 'enum'}-${slug(baseType)}`;
|
||||
return (
|
||||
<Anchor
|
||||
href={`#${targetType ? 'type' : 'enum'}-${slug(baseType)}`}
|
||||
href={`#${targetId}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onNavigate(targetId);
|
||||
}}
|
||||
className={classes.typeLink}
|
||||
>
|
||||
{baseType}
|
||||
@@ -111,7 +127,15 @@ function TypeReference({ type, layer }: { type: string; layer: ProtoLayer }) {
|
||||
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) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm">
|
||||
@@ -148,7 +172,11 @@ function FieldTable({ fields, layer }: { fields: ProtoField[]; layer: ProtoLayer
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<TypeReference type={field.type} layer={layer} />
|
||||
<TypeReference
|
||||
type={field.type}
|
||||
layer={layer}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<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 }) {
|
||||
return (
|
||||
<Box className={classes.yamlBlock}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={700} size="xs" c="dimmed" tt="uppercase">
|
||||
YAML schema
|
||||
</Text>
|
||||
<Group justify="flex-end" mb="xs">
|
||||
<CopyButton value={value} timeout={1600}>
|
||||
{({ copied, copy }) => (
|
||||
<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 (
|
||||
<Box component="article" id={`packet-${slug(packet.name)}`} className={classes.definition}>
|
||||
<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}>
|
||||
{packet.description || `Packet ${packet.name} in protocol layer ${layer.version}.`}
|
||||
</Text>
|
||||
<YamlSnippet value={packet.yaml} />
|
||||
<FieldTable fields={packet.fields} layer={layer} />
|
||||
<HiddenYaml value={packet.yaml} />
|
||||
<FieldTable fields={packet.fields} layer={layer} onNavigate={onNavigate} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeDefinition({ type, layer }: { type: ProtoType; layer: ProtoLayer }) {
|
||||
function TypeDefinition({
|
||||
type,
|
||||
layer,
|
||||
onNavigate,
|
||||
}: {
|
||||
type: ProtoType;
|
||||
layer: ProtoLayer;
|
||||
onNavigate: (targetId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box component="article" id={`type-${slug(type.name)}`} className={classes.definition}>
|
||||
<Text c="blue" fw={700} size="xs" tt="uppercase">
|
||||
Object
|
||||
Type
|
||||
</Text>
|
||||
<Title order={3}>{type.name}</Title>
|
||||
<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>
|
||||
<YamlSnippet value={type.yaml} />
|
||||
<FieldTable fields={type.fields} layer={layer} />
|
||||
<HiddenYaml value={type.yaml} />
|
||||
<FieldTable fields={type.fields} layer={layer} onNavigate={onNavigate} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -232,7 +284,7 @@ function EnumDefinition({ item, layer }: { item: ProtoEnum; layer: ProtoLayer })
|
||||
<Text c="dimmed" mt="sm" lh={1.65}>
|
||||
{item.description || `Enumeration ${item.name} in protocol layer ${layer.version}.`}
|
||||
</Text>
|
||||
<YamlSnippet value={item.yaml} />
|
||||
<HiddenYaml value={item.yaml} />
|
||||
<Table.ScrollContainer minWidth={520}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
|
||||
<Table.Thead>
|
||||
@@ -268,9 +320,80 @@ function EnumDefinition({ item, layer }: { item: ProtoEnum; layer: ProtoLayer })
|
||||
}
|
||||
|
||||
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 [selectedVersion, setSelectedVersion] = useState(String(latestLayer.version));
|
||||
const [category, setCategory] = useState<SchemaCategory>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const layer =
|
||||
@@ -279,17 +402,24 @@ export function ProtoPage() {
|
||||
const packets = layer.packets.filter((packet) => entityMatches(packet, query));
|
||||
const types = layer.types.filter((type) => entityMatches(type, query));
|
||||
const enums = layer.enums.filter((item) => entityMatches(item, query));
|
||||
const resultCount =
|
||||
(category === 'all' || category === 'packets' ? packets.length : 0) +
|
||||
(category === 'all' || category === 'types' ? types.length : 0) +
|
||||
(category === 'all' || category === 'enums' ? enums.length : 0);
|
||||
const resultCount = packets.length + types.length + enums.length;
|
||||
|
||||
const navigateToDefinition = (targetId: string) => {
|
||||
setSearch('');
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.history.replaceState(null, '', `#${targetId}`);
|
||||
document.getElementById(targetId)?.scrollIntoView({ block: 'start' });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEO
|
||||
title="Rosetta Proto Documentation"
|
||||
description="Generated Rosetta protocol documentation for packets, objects, enums, fields, and schema layers."
|
||||
canonical="https://rosetta.im/proto/"
|
||||
description="Generated Rosetta protocol documentation for packets, types, enums, fields, and schema layers."
|
||||
canonical="https://rosetta.im/proto/docs/"
|
||||
keywords="Rosetta protocol, packet schema, YAML documentation, Proto"
|
||||
/>
|
||||
<SiteHeader />
|
||||
@@ -300,117 +430,192 @@ export function ProtoPage() {
|
||||
</Anchor>
|
||||
<Text component="span" c="dimmed" size="sm">
|
||||
{' '}
|
||||
/ Proto
|
||||
/ Proto / Docs
|
||||
</Text>
|
||||
|
||||
<Box className={classes.hero}>
|
||||
<Group gap="sm">
|
||||
<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>
|
||||
<Title order={1}>Protocol documentation</Title>
|
||||
<Text c="dimmed" className={classes.heroDescription}>
|
||||
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.
|
||||
</Text>
|
||||
</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}>
|
||||
<aside className={classes.sidebar}>
|
||||
<Stack gap="lg">
|
||||
<Select
|
||||
label="Protocol layer"
|
||||
value={selectedVersion}
|
||||
onChange={(value) => value && setSelectedVersion(value)}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedVersion(value);
|
||||
setSearch('');
|
||||
}
|
||||
}}
|
||||
data={protoLayers.map((item) => ({
|
||||
value: String(item.version),
|
||||
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}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Contents
|
||||
</Text>
|
||||
<Anchor href="#packets" size="sm">
|
||||
<Anchor
|
||||
href="#packets"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition('packets');
|
||||
}}
|
||||
>
|
||||
Packets ({layer.packets.length})
|
||||
</Anchor>
|
||||
<Anchor href="#objects" size="sm">
|
||||
Objects ({layer.types.length})
|
||||
<Anchor
|
||||
href="#types"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition('types');
|
||||
}}
|
||||
>
|
||||
Types ({layer.types.length})
|
||||
</Anchor>
|
||||
<Anchor href="#enums" size="sm">
|
||||
<Anchor
|
||||
href="#enums"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition('enums');
|
||||
}}
|
||||
>
|
||||
Enums ({layer.enums.length})
|
||||
</Anchor>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Stack gap={4}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Source
|
||||
Packets
|
||||
</Text>
|
||||
<Code>{layer.source}</Code>
|
||||
<Text c="dimmed" size="xs" lh={1.5}>
|
||||
{layer.description || 'Initial protocol schema.'}
|
||||
{layer.packets.map((packet) => {
|
||||
const targetId = `packet-${slug(packet.name)}`;
|
||||
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>
|
||||
{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>
|
||||
</aside>
|
||||
|
||||
<Box className={classes.documentation}>
|
||||
<Paper withBorder radius="lg" p="lg" className={classes.filters}>
|
||||
<Paper withBorder radius="sm" p="lg" className={classes.filters}>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={17} />}
|
||||
placeholder="Search packets, objects, fields, or types"
|
||||
placeholder="Search packets, types, fields, or enums"
|
||||
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">
|
||||
{resultCount} definitions in layer {layer.version}
|
||||
</Text>
|
||||
</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}>
|
||||
<Title order={2}>Packets</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
@@ -421,24 +626,30 @@ export function ProtoPage() {
|
||||
key={packet.name}
|
||||
packet={packet}
|
||||
layer={layer}
|
||||
onNavigate={navigateToDefinition}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(category === 'all' || category === 'types') && types.length > 0 && (
|
||||
<Box component="section" id="objects" className={classes.schemaSection}>
|
||||
<Title order={2}>Objects</Title>
|
||||
{types.length > 0 && (
|
||||
<Box component="section" id="types" className={classes.schemaSection}>
|
||||
<Title order={2}>Types</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
Reusable structured values referenced by packet fields.
|
||||
</Text>
|
||||
{types.map((type) => (
|
||||
<TypeDefinition key={type.name} type={type} layer={layer} />
|
||||
<TypeDefinition
|
||||
key={type.name}
|
||||
type={type}
|
||||
layer={layer}
|
||||
onNavigate={navigateToDefinition}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(category === 'all' || category === 'enums') && enums.length > 0 && (
|
||||
{enums.length > 0 && (
|
||||
<Box component="section" id="enums" className={classes.schemaSection}>
|
||||
<Title order={2}>Enums</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
@@ -454,7 +665,7 @@ export function ProtoPage() {
|
||||
<Paper withBorder radius="lg" p="xl" ta="center">
|
||||
<Text fw={600}>No schema definitions found.</Text>
|
||||
<Text c="dimmed" size="sm" mt="xs">
|
||||
Try another search term or category.
|
||||
Try another search term.
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@@ -1,74 +1,72 @@
|
||||
.lead {
|
||||
max-width: 780px;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
.intro {
|
||||
margin-top: 36px;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
height: 54px;
|
||||
padding-inline: 38px;
|
||||
.searchArea {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.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;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.topic:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: light-dark(var(--mantine-color-blue-3), var(--mantine-color-blue-7));
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
.suggestion:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
scroll-margin-top: var(--mantine-spacing-xl);
|
||||
padding-top: 48px;
|
||||
.suggestion:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
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));
|
||||
}
|
||||
|
||||
.sectionTitle,
|
||||
.contactTitle {
|
||||
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
|
||||
.issuesSection > h2,
|
||||
.contact > h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: clamp(21px, 3vw, 26px);
|
||||
.issueList {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.section :global(.mantine-Accordion-root) {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
.issue {
|
||||
scroll-margin-top: 100px;
|
||||
padding: 26px 0;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.contact {
|
||||
border: 1px solid light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-9));
|
||||
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
|
||||
.issue:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.contactTitle {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.issue h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
@@ -1,252 +1,137 @@
|
||||
import {
|
||||
Accordion,
|
||||
Anchor,
|
||||
Box,
|
||||
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 { Anchor, Box, Button, Paper, Stack, Text, Textarea, Title } from '@mantine/core';
|
||||
import { IconMail, IconSearch } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import knownIssues from '../../known-issues.json';
|
||||
import { LegalPage } from '../components/LegalPage/LegalPage';
|
||||
import { SEO } from '../components/SEO/SEO';
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
<SEO
|
||||
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/"
|
||||
keywords="Rosetta support, account recovery, messaging help, privacy help"
|
||||
keywords="Rosetta support, known issues, account recovery, messaging help"
|
||||
/>
|
||||
<LegalPage
|
||||
eyebrow="Rosetta Support"
|
||||
title="Help with your Rosetta account and messages."
|
||||
>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<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>
|
||||
<LegalPage eyebrow="Help centre" title="Support">
|
||||
<Text className={classes.intro}>
|
||||
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
|
||||
to the support service.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{TOPICS.map(({ href, icon: Icon, title, description }) => (
|
||||
<Card
|
||||
key={href}
|
||||
component="a"
|
||||
href={href}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="lg"
|
||||
className={classes.topic}
|
||||
>
|
||||
<ThemeIcon variant="light" radius="xl" size="lg">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} mt="md">
|
||||
{title}
|
||||
<Box className={classes.searchArea}>
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={18} />}
|
||||
leftSectionProps={{ style: { alignItems: 'flex-start', paddingTop: 16 } }}
|
||||
placeholder="For example: my attachment is still sending…"
|
||||
aria-label="Describe your Rosetta problem"
|
||||
minRows={6}
|
||||
autosize
|
||||
maxRows={10}
|
||||
radius="sm"
|
||||
className={classes.problemField}
|
||||
/>
|
||||
|
||||
{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 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>
|
||||
</Card>
|
||||
<Text size="sm" mt="md" lh={1.75}>
|
||||
{issue.solution}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<SupportSection
|
||||
id="account"
|
||||
title="Account and access"
|
||||
description="Rosetta accounts are accessed with a recovery phrase instead of a phone number. Keep your phrase private and stored somewhere safe."
|
||||
items={ACCOUNT_FAQS}
|
||||
/>
|
||||
<SupportSection
|
||||
id="messaging"
|
||||
title="Messaging and media"
|
||||
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."
|
||||
items={MESSAGING_FAQS}
|
||||
/>
|
||||
<SupportSection
|
||||
id="privacy"
|
||||
title="Privacy and safety"
|
||||
description="Rosetta is designed for private communication. If you have a security concern or a question about data handling, contact us directly."
|
||||
items={PRIVACY_FAQS}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Box component="section" className={classes.contact}>
|
||||
<Title order={2}>Contact support</Title>
|
||||
<Text c="dimmed" size="sm" mt="xs" lh={1.7}>
|
||||
Include your operating system, Rosetta version, approximate time of the
|
||||
problem, and reproduction steps. Never send a recovery phrase or private
|
||||
key.
|
||||
</Text>
|
||||
<Button
|
||||
component="a"
|
||||
href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request"
|
||||
leftSection={<IconMail size={17} />}
|
||||
variant="default"
|
||||
mt="lg"
|
||||
>
|
||||
support@rosetta.im
|
||||
</Button>
|
||||
</Box>
|
||||
</LegalPage>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user