Šepot — end-to-end encrypted messenger

Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
@@ -0,0 +1,401 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.dart';
/// Onboarding — 3 stránky + generování identity
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _pageController = PageController();
int _currentPage = 0;
final _nameController = TextEditingController();
List<String>? _seedPhrase;
bool _isCreating = false;
bool _seedSaved = false;
@override
void dispose() {
_pageController.dispose();
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Indikátor stránek
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: List.generate(3, (i) {
return Expanded(
child: Container(
height: 3,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
color: i <= _currentPage
? SepotColors.ember
: SepotColors.border,
),
),
);
}),
),
),
// Stránky
Expanded(
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (page) => setState(() => _currentPage = page),
children: [
_buildWelcomePage(),
_buildEncryptionPage(),
_buildIdentityPage(),
],
),
),
],
),
),
);
}
/// Stránka 1: Uvítání
Widget _buildWelcomePage() {
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SepotLogo(size: 100),
const SizedBox(height: 48),
Text(
context.l10n.onboardingWelcomeTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 28,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 16),
Text(
context.l10n.onboardingWelcomeSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 16,
height: 1.6,
color: SepotColors.textSecondary,
),
),
const Spacer(),
ElevatedButton(
onPressed: () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Text(context.l10n.onboardingStart),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => context.push('/restore'),
child: Text(context.l10n.onboardingRestoreExisting),
),
],
),
);
}
/// Stránka 2: Vysvětlení šifrování
Widget _buildEncryptionPage() {
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SepotColors.mist.withValues(alpha: 0.1),
border: Border.all(color: SepotColors.mist.withValues(alpha: 0.3)),
),
child: const Icon(
Icons.shield_rounded,
size: 40,
color: SepotColors.mist,
),
),
const SizedBox(height: 32),
Text(
context.l10n.onboardingEncryptionTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 24),
_buildFeatureRow(
Icons.lock_rounded,
context.l10n.onboardingFeatureSignalTitle,
context.l10n.onboardingFeatureSignalSubtitle,
),
const SizedBox(height: 16),
_buildFeatureRow(
Icons.fingerprint,
context.l10n.onboardingFeaturePqTitle,
context.l10n.onboardingFeaturePqSubtitle,
),
const SizedBox(height: 16),
_buildFeatureRow(
Icons.phone_android_rounded,
context.l10n.onboardingFeatureLocalKeysTitle,
context.l10n.onboardingFeatureLocalKeysSubtitle,
),
const SizedBox(height: 16),
_buildFeatureRow(
Icons.timer_outlined,
context.l10n.chatDisappearingMessages,
context.l10n.onboardingFeatureDisappearingSubtitle,
),
const Spacer(),
ElevatedButton(
onPressed: () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Text(context.l10n.commonContinue),
),
],
),
);
}
Widget _buildFeatureRow(IconData icon, String title, String subtitle) {
return Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: SepotColors.surfaceVariant,
),
child: Icon(icon, size: 22, color: SepotColors.ember),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: SepotColors.textSecondary,
),
),
],
),
),
],
);
}
/// Stránka 3: Generování identity
Widget _buildIdentityPage() {
return Padding(
padding: const EdgeInsets.all(32),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text(
context.l10n.onboardingIdentityTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 24,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.onboardingIdentitySubtitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 32),
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: context.l10n.onboardingDisplayNameLabel,
hintText: context.l10n.onboardingDisplayNameHint,
prefixIcon: const Icon(Icons.person_outline),
),
textCapitalization: TextCapitalization.words,
// Bez rebuildů by tlačítko „Vygenerovat klíče" zůstalo
// navždy disabled (jeho stav čte controller v build())
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 32),
if (_seedPhrase != null) ...[
Text(
context.l10n.settingsSeedPhraseTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 16,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.onboardingSeedPhraseWarning(_seedPhrase!.length),
style: const TextStyle(
fontSize: 13,
color: SepotColors.warning,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: SepotColors.surfaceVariant,
border: Border.all(color: SepotColors.border),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(_seedPhrase!.length, (i) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: SepotColors.surface,
border: Border.all(color: SepotColors.border),
),
child: Text(
'${i + 1}. ${_seedPhrase![i]}',
style: SepotTheme.cryptoText.copyWith(fontSize: 13),
),
);
}),
),
),
const SizedBox(height: 16),
CheckboxListTile(
value: _seedSaved,
onChanged: (v) => setState(() => _seedSaved = v ?? false),
title: Text(
context.l10n.onboardingSeedSavedConfirm,
style: const TextStyle(fontSize: 14),
),
activeColor: SepotColors.ember,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
],
const SizedBox(height: 24),
if (_seedPhrase == null)
ElevatedButton(
onPressed: _nameController.text.trim().isNotEmpty
? _generateIdentity
: null,
child: _isCreating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(context.l10n.onboardingGenerateKeys),
)
else
ElevatedButton(
onPressed: _seedSaved ? _completeOnboarding : null,
child: Text(context.l10n.commonContinue),
),
],
),
),
);
}
Future<void> _generateIdentity() async {
if (_nameController.text.trim().isEmpty) return;
setState(() => _isCreating = true);
try {
// Vygeneruj identitu
final identity = await ref
.read(identityProvider.notifier)
.createIdentity(_nameController.text.trim());
// Seed fráze = skutečná záloha identity klíče (BIP39 enkódování),
// NE nezávislá slova — jinak by obnova nikdy nefungovala
final seed = identity.mnemonic;
await ref.read(storageProvider).saveSeedPhrase(seed);
setState(() {
_seedPhrase = seed;
_isCreating = false;
});
} catch (e) {
setState(() => _isCreating = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(describeError(context.l10n, e))),
);
}
}
}
Future<void> _completeOnboarding() async {
final storage = ref.read(storageProvider);
await storage.setOnboardingComplete(true);
if (mounted) {
context.go('/pin-setup');
}
}
}
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Obnova účtu ze zálohovací fráze (24 slov). Vrátí IDENTITU (klíč →
/// stejný SEP kód a safety number), NE historii zpráv.
class RestoreScreen extends ConsumerStatefulWidget {
const RestoreScreen({super.key});
@override
ConsumerState<RestoreScreen> createState() => _RestoreScreenState();
}
class _RestoreScreenState extends ConsumerState<RestoreScreen> {
final _nameController = TextEditingController();
final _wordsController = TextEditingController();
bool _restoring = false;
String? _error;
@override
void dispose() {
_nameController.dispose();
_wordsController.dispose();
super.dispose();
}
/// Rozsekej vstup na slova (mezery, nové řádky, čárky, čísla „1." pryč).
List<String> _parseWords(String raw) {
return raw
.toLowerCase()
.replaceAll(RegExp(r'\d+[.)]?'), ' ')
.split(RegExp(r'[\s,]+'))
.where((w) => w.isNotEmpty)
.toList();
}
int get _wordCount => _parseWords(_wordsController.text).length;
Future<void> _restore() async {
final name = _nameController.text.trim();
final words = _parseWords(_wordsController.text);
if (name.isEmpty) {
setState(() => _error = context.l10n.restoreErrorNameRequired);
return;
}
if (words.length != AppConstants.seedPhraseWords) {
setState(() => _error = context.l10n.restoreErrorWordCount(
_wordCount, AppConstants.seedPhraseWords));
return;
}
if (!SepotMnemonic.isValid(words)) {
setState(() => _error = context.l10n.restoreErrorInvalidPhrase);
return;
}
setState(() {
_restoring = true;
_error = null;
});
try {
await ref
.read(identityProvider.notifier)
.recoverIdentity(words, name);
await ref.read(storageProvider).setOnboardingComplete(true);
if (mounted) context.go('/pin-setup');
} catch (e) {
if (!mounted) return;
setState(() {
_restoring = false;
_error =
context.l10n.restoreErrorFailed(describeError(context.l10n, e));
});
}
}
@override
Widget build(BuildContext context) {
final ok = _wordCount == AppConstants.seedPhraseWords &&
_nameController.text.trim().isNotEmpty;
return Scaffold(
appBar: AppBar(title: Text(context.l10n.restoreTitle)),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.restoreHeading,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 22,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
context.l10n.restoreIntro(AppConstants.seedPhraseWords),
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
height: 1.5,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 20),
// Upozornění na historii
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: SepotColors.warning.withValues(alpha: 0.1),
border: Border.all(
color: SepotColors.warning.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline,
size: 20, color: SepotColors.warning),
const SizedBox(width: 10),
Expanded(
child: Text(
context.l10n.restoreHistoryWarning,
style: TextStyle(
fontSize: 13,
color: SepotColors.textPrimary.withValues(alpha: .9)),
),
),
],
),
),
const SizedBox(height: 24),
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: context.l10n.onboardingDisplayNameLabel,
hintText: context.l10n.onboardingDisplayNameHint,
prefixIcon: const Icon(Icons.person_outline),
),
textCapitalization: TextCapitalization.words,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 20),
TextField(
controller: _wordsController,
minLines: 4,
maxLines: 8,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'[^\w\s.,)]')),
],
decoration: InputDecoration(
labelText: context.l10n.settingsSeedPhraseTitle,
hintText: context.l10n.restorePhraseHint,
alignLabelWithHint: true,
helperText: context.l10n.restoreWordCountHelper(
_wordCount, AppConstants.seedPhraseWords),
helperStyle: TextStyle(
color: _wordCount == AppConstants.seedPhraseWords
? SepotColors.sage
: SepotColors.textMuted,
),
),
onChanged: (_) => setState(() => _error = null),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!,
style: const TextStyle(
color: SepotColors.error, fontSize: 13)),
],
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: (ok && !_restoring) ? _restore : null,
child: _restoring
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(context.l10n.restoreTitle),
),
),
],
),
),
),
);
}
}