From c693e6fcb9ce3a2df2e5b5ebb79cd384b0dd7798 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 11:07:11 +0300 Subject: [PATCH] feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention --- lib/data/providers/providers.dart | 23 +- lib/presentation/routes/app_router.dart | 19 +- lib/presentation/routes/app_router.gr.dart | 103 ++++--- lib/presentation/routes/home_guard.dart | 2 +- .../screens/contact_detail_screen.dart | 57 +++- lib/presentation/screens/contacts_screen.dart | 4 + lib/presentation/screens/home_screen.dart | 86 ++++++ lib/presentation/screens/inbox_screen.dart | 100 +++++-- .../screens/message_detail_screen.dart | 20 +- .../screens/onboarding_screen.dart | 137 ++++----- lib/presentation/screens/settings_screen.dart | 36 ++- lib/presentation/theme/breakpoints.dart | 4 + .../widgets/drawer/app_drawer.dart | 35 --- .../widgets/drawer/drawer_list.dart | 63 ---- .../widgets/drawer/drawer_list_tile.dart | 42 --- .../widgets/drawer/identity_header.dart | 64 ----- .../widgets/message/message_list.dart | 6 +- .../widgets/message/message_tile.dart | 8 +- .../widgets/message/message_view.dart | 32 ++- lib/smol/client.dart | 244 +++++++++++++--- lib/smol/proto.dart | 147 +++++++--- lib/smol/store.dart | 271 ++++++++++++++---- test/e2e_test.dart | 92 +++++- test/recall_flow_test.dart | 86 +++--- test/smol_test.dart | 39 ++- test/store_test.dart | 60 +++- test/vectors.json | 2 +- 27 files changed, 1190 insertions(+), 592 deletions(-) create mode 100644 lib/presentation/screens/home_screen.dart create mode 100644 lib/presentation/theme/breakpoints.dart delete mode 100644 lib/presentation/widgets/drawer/app_drawer.dart delete mode 100644 lib/presentation/widgets/drawer/drawer_list.dart delete mode 100644 lib/presentation/widgets/drawer/drawer_list_tile.dart delete mode 100644 lib/presentation/widgets/drawer/identity_header.dart diff --git a/lib/data/providers/providers.dart b/lib/data/providers/providers.dart index c4d86d1..1f1bcaa 100644 --- a/lib/data/providers/providers.dart +++ b/lib/data/providers/providers.dart @@ -32,18 +32,14 @@ final accountProvider = Provider((ref) { return ref.watch(clientProvider).accountAddress(); }); -class Folder extends Notifier { - @override - String build() => "inbox"; - - void select(String folder) => state = folder; -} - -final folderProvider = NotifierProvider(Folder.new); - -final messagesProvider = Provider>((ref) { +/// One tab (Inbox/Sent/Requests) can be on screen — and simultaneously kept +/// alive off-screen by [TabBarView] for swiping — while another is active, +/// so each is parameterized by its own folder rather than sharing one +/// ambient "current folder" provider. +final messagesForFolderProvider = + Provider.family, String>((ref, folder) { ref.watch(revisionProvider); - return ref.watch(storeProvider).listMessages(ref.watch(folderProvider)); + return ref.watch(storeProvider).listMessages(folder); }); final unreadProvider = Provider((ref) { @@ -51,6 +47,11 @@ final unreadProvider = Provider((ref) { return ref.watch(storeProvider).unreadCount(); }); +final requestsUnreadProvider = Provider((ref) { + ref.watch(revisionProvider); + return ref.watch(storeProvider).requestsUnreadCount(); +}); + final contactsProvider = Provider>((ref) { ref.watch(revisionProvider); return ref.watch(storeProvider).allContacts(); diff --git a/lib/presentation/routes/app_router.dart b/lib/presentation/routes/app_router.dart index 1f20a4d..94dbe81 100644 --- a/lib/presentation/routes/app_router.dart +++ b/lib/presentation/routes/app_router.dart @@ -20,9 +20,14 @@ class AppRouter extends RootStackRouter { guards: [HomeGuard(ref)], ), AutoRoute( - page: InboxRoute.page, - path: "/inbox", + page: HomeRoute.page, + path: "/home", guards: [IdentityGuard(ref)], + children: [ + AutoRoute(page: InboxRoute.page, path: "inbox", initial: true), + AutoRoute(page: ContactsRoute.page, path: "contacts"), + AutoRoute(page: SettingsRoute.page, path: "settings"), + ], ), AutoRoute( page: MessageDetailRoute.page, @@ -34,20 +39,10 @@ class AppRouter extends RootStackRouter { path: "/compose", guards: [IdentityGuard(ref)], ), - AutoRoute( - page: ContactsRoute.page, - path: "/contacts", - guards: [IdentityGuard(ref)], - ), AutoRoute( page: ContactDetailRoute.page, path: "/contact", guards: [IdentityGuard(ref)], ), - AutoRoute( - page: SettingsRoute.page, - path: "/settings", - guards: [IdentityGuard(ref)], - ), ]; } diff --git a/lib/presentation/routes/app_router.gr.dart b/lib/presentation/routes/app_router.gr.dart index d354d14..0a91d9a 100644 --- a/lib/presentation/routes/app_router.gr.dart +++ b/lib/presentation/routes/app_router.gr.dart @@ -10,26 +10,27 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i8; -import 'package:flutter/material.dart' as _i9; +import 'package:auto_route/auto_route.dart' as _i9; +import 'package:flutter/material.dart' as _i10; import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1; import 'package:smol_mail/presentation/screens/contact_detail_screen.dart' as _i2; import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3; -import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i4; +import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4; +import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5; import 'package:smol_mail/presentation/screens/message_detail_screen.dart' - as _i5; -import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6; -import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7; + as _i6; +import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7; +import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8; /// generated route for /// [_i1.ComposeScreen] -class ComposeRoute extends _i8.PageRouteInfo { +class ComposeRoute extends _i9.PageRouteInfo { ComposeRoute({ - _i9.Key? key, + _i10.Key? key, String? to, String? subject, - List<_i8.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( ComposeRoute.name, args: ComposeRouteArgs(key: key, to: to, subject: subject), @@ -38,7 +39,7 @@ class ComposeRoute extends _i8.PageRouteInfo { static const String name = 'ComposeRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -56,7 +57,7 @@ class ComposeRoute extends _i8.PageRouteInfo { class ComposeRouteArgs { const ComposeRouteArgs({this.key, this.to, this.subject}); - final _i9.Key? key; + final _i10.Key? key; final String? to; @@ -80,11 +81,11 @@ class ComposeRouteArgs { /// generated route for /// [_i2.ContactDetailScreen] -class ContactDetailRoute extends _i8.PageRouteInfo { +class ContactDetailRoute extends _i9.PageRouteInfo { ContactDetailRoute({ - _i9.Key? key, + _i10.Key? key, required String address, - List<_i8.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( ContactDetailRoute.name, args: ContactDetailRouteArgs(key: key, address: address), @@ -93,7 +94,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo { static const String name = 'ContactDetailRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -105,7 +106,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo { class ContactDetailRouteArgs { const ContactDetailRouteArgs({this.key, required this.address}); - final _i9.Key? key; + final _i10.Key? key; final String address; @@ -127,13 +128,13 @@ class ContactDetailRouteArgs { /// generated route for /// [_i3.ContactsScreen] -class ContactsRoute extends _i8.PageRouteInfo { - const ContactsRoute({List<_i8.PageRouteInfo>? children}) +class ContactsRoute extends _i9.PageRouteInfo { + const ContactsRoute({List<_i9.PageRouteInfo>? children}) : super(ContactsRoute.name, initialChildren: children); static const String name = 'ContactsRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { return const _i3.ContactsScreen(); @@ -142,29 +143,45 @@ class ContactsRoute extends _i8.PageRouteInfo { } /// generated route for -/// [_i4.InboxScreen] -class InboxRoute extends _i8.PageRouteInfo { - const InboxRoute({List<_i8.PageRouteInfo>? children}) - : super(InboxRoute.name, initialChildren: children); +/// [_i4.HomeScreen] +class HomeRoute extends _i9.PageRouteInfo { + const HomeRoute({List<_i9.PageRouteInfo>? children}) + : super(HomeRoute.name, initialChildren: children); - static const String name = 'InboxRoute'; + static const String name = 'HomeRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i4.InboxScreen(); + return const _i4.HomeScreen(); }, ); } /// generated route for -/// [_i5.MessageDetailScreen] -class MessageDetailRoute extends _i8.PageRouteInfo { +/// [_i5.InboxScreen] +class InboxRoute extends _i9.PageRouteInfo { + const InboxRoute({List<_i9.PageRouteInfo>? children}) + : super(InboxRoute.name, initialChildren: children); + + static const String name = 'InboxRoute'; + + static _i9.PageInfo page = _i9.PageInfo( + name, + builder: (data) { + return const _i5.InboxScreen(); + }, + ); +} + +/// generated route for +/// [_i6.MessageDetailScreen] +class MessageDetailRoute extends _i9.PageRouteInfo { MessageDetailRoute({ - _i9.Key? key, + _i10.Key? key, required String folder, required String id, - List<_i8.PageRouteInfo>? children, + List<_i9.PageRouteInfo>? children, }) : super( MessageDetailRoute.name, args: MessageDetailRouteArgs(key: key, folder: folder, id: id), @@ -173,11 +190,11 @@ class MessageDetailRoute extends _i8.PageRouteInfo { static const String name = 'MessageDetailRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { final args = data.argsAs(); - return _i5.MessageDetailScreen( + return _i6.MessageDetailScreen( key: args.key, folder: args.folder, id: args.id, @@ -193,7 +210,7 @@ class MessageDetailRouteArgs { required this.id, }); - final _i9.Key? key; + final _i10.Key? key; final String folder; @@ -216,33 +233,33 @@ class MessageDetailRouteArgs { } /// generated route for -/// [_i6.OnboardingScreen] -class OnboardingRoute extends _i8.PageRouteInfo { - const OnboardingRoute({List<_i8.PageRouteInfo>? children}) +/// [_i7.OnboardingScreen] +class OnboardingRoute extends _i9.PageRouteInfo { + const OnboardingRoute({List<_i9.PageRouteInfo>? children}) : super(OnboardingRoute.name, initialChildren: children); static const String name = 'OnboardingRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i6.OnboardingScreen(); + return const _i7.OnboardingScreen(); }, ); } /// generated route for -/// [_i7.SettingsScreen] -class SettingsRoute extends _i8.PageRouteInfo { - const SettingsRoute({List<_i8.PageRouteInfo>? children}) +/// [_i8.SettingsScreen] +class SettingsRoute extends _i9.PageRouteInfo { + const SettingsRoute({List<_i9.PageRouteInfo>? children}) : super(SettingsRoute.name, initialChildren: children); static const String name = 'SettingsRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i9.PageInfo page = _i9.PageInfo( name, builder: (data) { - return const _i7.SettingsScreen(); + return const _i8.SettingsScreen(); }, ); } diff --git a/lib/presentation/routes/home_guard.dart b/lib/presentation/routes/home_guard.dart index 6f07b86..86ef3ad 100644 --- a/lib/presentation/routes/home_guard.dart +++ b/lib/presentation/routes/home_guard.dart @@ -15,7 +15,7 @@ class HomeGuard extends AutoRouteGuard { void onNavigation(NavigationResolver resolver, StackRouter router) { if (ref.read(identityProvider) != null && ref.read(accountProvider) != null) { - router.replace(InboxRoute()); + router.replace(HomeRoute()); } else { resolver.next(true); } diff --git a/lib/presentation/screens/contact_detail_screen.dart b/lib/presentation/screens/contact_detail_screen.dart index 88b76b5..02b961d 100644 --- a/lib/presentation/screens/contact_detail_screen.dart +++ b/lib/presentation/screens/contact_detail_screen.dart @@ -24,6 +24,34 @@ class ContactDetailScreen extends ConsumerStatefulWidget { class _ContactDetailScreenState extends ConsumerState { bool resolving = false; + bool tokenBusy = false; + + // §5.8: admitting or withdrawing a contact's accept token pushes the + // change to the server right away, since it only takes effect once the + // server holds the changed set. + Future _toggleAccepted(bool currentlyAccepted) async { + final client = ref.read(clientProvider); + setState(() => tokenBusy = true); + try { + if (currentlyAccepted) { + await client.blockContact(widget.address); + } else { + await client.acceptContact(widget.address); + } + ref.read(revisionProvider.notifier).bump(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(currentlyAccepted + ? "${widget.address} blocked; their mail now lands in requests" + : "${widget.address} accepted; their mail now lands in your main tier")), + ); + } + } on SmolError catch (err) { + if (mounted) showErrorSnackBar(context, err.message); + } finally { + if (mounted) setState(() => tokenBusy = false); + } + } Future _reResolve() async { final client = ref.read(clientProvider); @@ -54,7 +82,11 @@ class _ContactDetailScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final contact = ref.watch(storeProvider).contact(widget.address); + ref.watch(revisionProvider); + final store = ref.read(storeProvider); + final contact = store.contact(widget.address); + final accepted = store.accepted(widget.address); + final isAccepted = accepted != null && accepted.active; return Scaffold( appBar: AppBar(title: Text(widget.address)), @@ -69,6 +101,29 @@ class _ContactDetailScreenState extends ConsumerState { ? "verified key" : "key pinned on first use"), const SizedBox(height: 20), + Text( + "Accept token (SPEC.md §5.8)", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 5), + Text( + isAccepted + ? "This contact's mail lands in your main tier." + : "This contact's mail lands in requests until accepted.", + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(height: 10), + tokenBusy + ? const Center(child: CircularProgressIndicator()) + : TextButton.icon( + onPressed: () => _toggleAccepted(isAccepted), + icon: Icon(isAccepted ? Icons.block : Icons.check_circle_outline, + size: 18), + label: Text(isAccepted ? "Block" : "Accept"), + ), + const SizedBox(height: 20), + const Divider(height: 1), + const SizedBox(height: 20), _field(context, "fingerprint", fingerprint(contact.key)), _field(context, "public key", b32encode(contact.key)), Row( diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 2e39406..46c189f 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -82,6 +82,7 @@ class ContactsScreen extends ConsumerWidget { itemCount: contacts.length, itemBuilder: (ctx, i) { final (address, contact) = contacts[i]; + final accepted = ref.read(storeProvider).accepted(address); return ListTile( splashColor: Theme.of(context).extension()!.highlight, onTap: () => AutoRouter.of(context) @@ -93,12 +94,15 @@ class ContactsScreen extends ConsumerWidget { ), trailing: Wrap( spacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ _chip(context, contact.verified ? "verified" : "tofu", ok: contact.verified), if (contact.history.isNotEmpty) _chip(context, "${contact.history.length} previous", ok: false), + if (accepted != null && accepted.active) + _chip(context, "accepted", ok: true), ], ), ); diff --git a/lib/presentation/screens/home_screen.dart b/lib/presentation/screens/home_screen.dart new file mode 100644 index 0000000..8a9a697 --- /dev/null +++ b/lib/presentation/screens/home_screen.dart @@ -0,0 +1,86 @@ +import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/routes/app_router.gr.dart"; +import "package:smol_mail/presentation/theme/app_colors.dart"; +import "package:smol_mail/presentation/theme/breakpoints.dart"; +import "package:smol_mail/shared/configs/flash_mail_icons.dart"; + +class _Destination { + final Widget icon; + final String label; + + const _Destination({required this.icon, required this.label}); +} + +/// The app's only persistent navigation surface: a bottom [NavigationBar] on +/// phone-width screens, a side [NavigationRail] on desktop-width ones — no +/// hamburger drawer at either width. Mail/Contacts/Settings are switched by +/// tapping only, never by swipe, so a horizontal gesture inside a +/// destination (e.g. the Inbox/Sent/Requests tab bar) never changes section. +@RoutePage() +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final unread = ref.watch(unreadProvider) + ref.watch(requestsUnreadProvider); + final appColors = Theme.of(context).extension()!; + + final mailIcon = Icon(FlashMailIcons.inbox); + final destinations = [ + _Destination( + icon: unread > 0 ? Badge(label: Text("$unread"), child: mailIcon) : mailIcon, + label: "Mail", + ), + const _Destination(icon: Icon(Icons.contacts), label: "Contacts"), + const _Destination(icon: Icon(Icons.settings), label: "Settings"), + ]; + + return AutoTabsRouter.builder( + routes: const [InboxRoute(), ContactsRoute(), SettingsRoute()], + builder: (context, children, tabsRouter) { + final body = IndexedStack(index: tabsRouter.activeIndex, children: children); + final wide = MediaQuery.sizeOf(context).width >= kDesktopBreakpoint; + + if (!wide) { + return Scaffold( + body: body, + bottomNavigationBar: NavigationBar( + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: tabsRouter.setActiveIndex, + indicatorColor: appColors.highlight, + destinations: [ + for (final d in destinations) + NavigationDestination(icon: d.icon, label: d.label), + ], + ), + ); + } + + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: tabsRouter.setActiveIndex, + backgroundColor: appColors.cardFill, + useIndicator: true, + indicatorColor: appColors.highlight, + labelType: NavigationRailLabelType.all, + destinations: [ + for (final d in destinations) + NavigationRailDestination(icon: d.icon, label: Text(d.label)), + ], + ), + const VerticalDivider(width: 1), + Expanded(child: body), + ], + ), + ); + }, + ); + } +} diff --git a/lib/presentation/screens/inbox_screen.dart b/lib/presentation/screens/inbox_screen.dart index f22ab0f..30b4c62 100644 --- a/lib/presentation/screens/inbox_screen.dart +++ b/lib/presentation/screens/inbox_screen.dart @@ -4,18 +4,46 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart"; -import "package:smol_mail/presentation/widgets/drawer/app_drawer.dart"; import "package:smol_mail/presentation/widgets/image_banner.dart"; import "package:smol_mail/presentation/widgets/message/message_list.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/errors.dart"; +const _folders = ["inbox", "requests", "sent"]; + /// No server push in v1 (SPEC.md §13): new mail arrives when the user taps -/// fetch, which pulls everything and acknowledges what verifies. +/// fetch, which pulls everything and acknowledges what verifies. One FETCH +/// call populates both Inbox and Requests (they're tiers of the same +/// mailbox), so both — but not the purely local Sent folder — offer fetch. @RoutePage() -class InboxScreen extends ConsumerWidget { +class InboxScreen extends ConsumerStatefulWidget { const InboxScreen({super.key}); + @override + ConsumerState createState() => _InboxScreenState(); +} + +class _InboxScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _folders.length, vsync: this) + ..addListener(() { + // Fires once settled, whether the change came from a tap or a swipe; + // only the AppBar's fetch button depends on which tab is active. + if (!_tabController.indexIsChanging) setState(() {}); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + Future _fetch(BuildContext context, WidgetRef ref) async { final client = ref.read(clientProvider); try { @@ -36,47 +64,63 @@ class InboxScreen extends ConsumerWidget { } } + Widget _folderView(BuildContext context, WidgetRef ref, String folder) { + final messages = ref.watch(messagesForFolderProvider(folder)); + final canFetch = folder != "sent"; + final onRefresh = canFetch ? () => _fetch(context, ref) : () => Future.value(); + + if (messages.isEmpty) { + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView( + children: const [ + ImageBanner( + imgSrc: "assets/images/empty.png", + text: "Nothing here yet!", + ), + ], + ), + ); + } + return MessageList(folder: folder, onRefresh: onRefresh); + } + @override - Widget build(BuildContext context, WidgetRef ref) { - final messages = ref.watch(messagesProvider); - final folder = ref.watch(folderProvider); + Widget build(BuildContext context) { + final unread = ref.watch(unreadProvider); + final requestsUnread = ref.watch(requestsUnreadProvider); + final canFetch = _folders[_tabController.index] != "sent"; return Scaffold( appBar: AppBar( - title: Text(folder == "inbox" ? "Inbox" : "Sent"), + title: const Text("kirakira"), actions: [ - if (folder == "inbox") + if (canFetch) IconButton( tooltip: "Fetch", onPressed: () => _fetch(context, ref), icon: const Icon(Icons.cloud_download), ), ], + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: unread > 0 ? "Inbox ($unread)" : "Inbox"), + Tab(text: requestsUnread > 0 ? "Requests ($requestsUnread)" : "Requests"), + const Tab(text: "Sent"), + ], + ), ), - drawer: const AppDrawer(), floatingActionButton: FloatingActionButton( onPressed: () => AutoRouter.of(context).push(ComposeRoute()), child: const Icon(Icons.edit), ), - body: messages.isEmpty - ? RefreshIndicator( - onRefresh: () => folder == "inbox" - ? _fetch(context, ref) - : Future.value(), - child: ListView( - children: const [ - ImageBanner( - imgSrc: "assets/images/empty.png", - text: "Nothing here yet!", - ), - ], - ), - ) - : MessageList( - onRefresh: () => folder == "inbox" - ? _fetch(context, ref) - : Future.value(), - ), + body: TabBarView( + controller: _tabController, + children: [ + for (final folder in _folders) _folderView(context, ref, folder), + ], + ), ); } } diff --git a/lib/presentation/screens/message_detail_screen.dart b/lib/presentation/screens/message_detail_screen.dart index 4e2ffe4..4467084 100644 --- a/lib/presentation/screens/message_detail_screen.dart +++ b/lib/presentation/screens/message_detail_screen.dart @@ -10,9 +10,10 @@ import "package:smol_mail/presentation/widgets/message/message_view.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/errors.dart"; +import "package:smol_mail/smol/store.dart"; -/// Fetched mail is acknowledged off the server during fetch, so deleting here -/// removes only the local sealed copy. +/// Deleting removes the local copy, and the server's too if "leave mail on +/// server" left one there to remove (settings; SPEC.md §10). @RoutePage() class MessageDetailScreen extends ConsumerWidget { final String folder; @@ -24,8 +25,14 @@ class MessageDetailScreen extends ConsumerWidget { required this.id, }); - Future _delete(BuildContext context, WidgetRef ref) async { - await ref.read(storeProvider).deleteMessage(folder, id); + Future _delete( + BuildContext context, WidgetRef ref, MailRecord record) async { + try { + await ref.read(clientProvider).deleteMessage(folder, record); + } on SmolError catch (err) { + if (context.mounted) showErrorSnackBar(context, err.message); + return; // keep the local copy if a needed server delete failed + } ref.read(revisionProvider.notifier).bump(); if (context.mounted) { AutoRouter.of(context).pop(); @@ -109,8 +116,8 @@ class MessageDetailScreen extends ConsumerWidget { ), actions: [ IconButton( - tooltip: "Delete local copy", - onPressed: () => _delete(context, ref), + tooltip: "Delete", + onPressed: () => _delete(context, ref, record), icon: const Icon(Icons.delete), ), ], @@ -118,6 +125,7 @@ class MessageDetailScreen extends ConsumerWidget { body: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: MessageView( + folder: folder, record: record, opened: opened, onNameSender: opened.sender == null diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart index 5e9498d..38ac6e8 100644 --- a/lib/presentation/screens/onboarding_screen.dart +++ b/lib/presentation/screens/onboarding_screen.dart @@ -29,12 +29,17 @@ enum _Step { welcome, backup, restore, register } class _OnboardingScreenState extends ConsumerState { _Step step = _Step.welcome; - Uint8List? createdSeed; + Uint8List? createdMaster; bool busy = false; // The register step doubles as "pin a key to finish recalling" when a // restore's recall can't proceed without one yet — same fields, different // framing and default action, not the generic "register a new address" copy. bool recallIntent = false; + // Set alongside recallIntent when we got here from Restore rather than from + // "Already registered? Recall": there is no well-defined "register a new + // address instead" fallback for a restored master until its rotation index + // is known, so that escape hatch is hidden in this case (§2). + bool restoreIntent = false; final seedController = TextEditingController(); final restoreAddressController = TextEditingController(); @@ -62,11 +67,11 @@ class _OnboardingScreenState extends ConsumerState { } // Reaching onboarding at all means HomeGuard already found no complete - // identity+account, so a seed still sitting in the store here can only be - // an abandoned attempt from earlier in this same flow (wrong seed, failed + // identity+account, so a master still sitting in the store here can only be + // an abandoned attempt from earlier in this same flow (wrong master, failed // recall, "Back") — safe to replace rather than reject. // wipe() is fire-and-forget here, like every other store write in this - // screen (setIdentity, pinServer, ...) — Hive updates its in-memory state + // screen (setMaster, pinServer, ...) — Hive updates its in-memory state // synchronously and persists to disk in the background, so the identity // check right after is already consistent without awaiting the write. void _clearAbandonedIdentity() { @@ -80,9 +85,9 @@ class _OnboardingScreenState extends ConsumerState { _clearAbandonedIdentity(); final client = ref.read(clientProvider); try { - final fresh = client.createIdentity(); + final master = client.createIdentity(); setState(() { - createdSeed = fresh.seed; + createdMaster = master; step = _Step.backup; }); } on Exception catch (err) { @@ -94,74 +99,49 @@ class _OnboardingScreenState extends ConsumerState { setState(() => step = _Step.restore); } - // Restoring must succeed on its own even if recall fails (server not - // pinned yet, offline, typo) — recall can always be retried from the - // register step or settings afterward. - void _submitRestore() { - _clearAbandonedIdentity(); - final client = ref.read(clientProvider); - try { - client.restoreIdentity(seedController.text); - } on Exception catch (err) { - _showError(err); - return; - } + // §2: a master alone does not say which rotation index a server bound, so + // restoring resolves the address and walks indices to find it — restore + // always ends in a recall, never a bare local step. + Future _submitRestore() async { final addressText = restoreAddressController.text.trim(); if (addressText.isEmpty) { - setState(() => step = _Step.register); + _showError(const SmolError("enter the address this master was registered under")); return; } + _clearAbandonedIdentity(); // The register step has its own address field (it also needs a server // key, which restore doesn't collect) — carry over what was already // typed rather than making the user re-enter it. addressController.text = addressText; - final SmolAddress addr; + final client = ref.read(clientProvider); + setState(() => busy = true); try { - addr = parseAddress(addressText); + await client.restoreAndRecall(seedController.text, addressText); + if (!mounted) return; + ref.read(revisionProvider.notifier).bump(); + AutoRouter.of(context).replace(HomeRoute()); } on Exception catch (err) { - // A genuine typo, distinct from the merely-unpinned case below — still - // worth an error, but land on the same recall-oriented step to fix it. + if (!mounted) return; + // Most often the host just isn't pinned yet (SPEC.md §4) — the expected + // state right after a restore, not a dead end — so land on the + // recall-framed register step to collect a key and retry. _showError(err); setState(() { recallIntent = true; + restoreIntent = true; step = _Step.register; }); - return; + } finally { + if (mounted) setState(() => busy = false); } - // Recall needs the host pinned first (SPEC.md §4). That's the expected, - // common state right after a restore — not an error — so check for it - // up front instead of letting recallAccount fail and surfacing that as - // one: this address just needs a key before its first recall can proceed. - if (ref.read(storeProvider).serverPin(addr.host) == null) { - setState(() { - recallIntent = true; - step = _Step.register; - }); - return; - } - () async { - try { - await client.recallAccount(addressText); - if (!mounted) return; - ref.read(revisionProvider.notifier).bump(); - AutoRouter.of(context).replace(InboxRoute()); - } on Exception catch (err) { - if (!mounted) return; - _showError(err); - setState(() { - recallIntent = true; - step = _Step.register; - }); - } - }(); } Future _submitRegistration() async { await _submit(recall: false); } - // Restoring a seed on a new device knows the identity but not the address it - // was registered under; recall binds it without re-REGISTER. + // Recall binds a restored or already-created identity to its registered + // address without re-REGISTER. Future _submitRecall() async { await _submit(recall: true); } @@ -178,7 +158,9 @@ class _OnboardingScreenState extends ConsumerState { if (serverKey.isNotEmpty) { client.pinServer(address.host, serverKey); } - if (recall) { + if (recall && restoreIntent) { + await client.restoreAndRecall(seedController.text, address.short); + } else if (recall) { await client.recallAccount(address.short); } else { await client.registerAccount(address.short, @@ -186,7 +168,7 @@ class _OnboardingScreenState extends ConsumerState { } if (mounted) { ref.read(revisionProvider.notifier).bump(); - AutoRouter.of(context).replace(InboxRoute()); + AutoRouter.of(context).replace(HomeRoute()); } } catch (err) { _showError(err); @@ -248,20 +230,21 @@ class _OnboardingScreenState extends ConsumerState { } Widget _backup(BuildContext context) { - final seed = createdSeed!; - final seedHex = hex(seed); + final master = createdMaster!; + final masterHex = hex(master); + final publicKey = ref.read(clientProvider).identity!.publicKey; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _header(context, "Back up this seed; it is the only secret."), + _header(context, "Back up this master secret; it is the only secret."), const SizedBox(height: 20), Text( - "fingerprint:\n${fingerprint(ed25519PublicKey(seed))}", + "fingerprint:\n${fingerprint(publicKey)}", style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 20), SelectableText( - seedHex, + masterHex, style: Theme.of(context) .textTheme .bodySmall! @@ -269,14 +252,14 @@ class _OnboardingScreenState extends ConsumerState { ), TextButton( onPressed: () async { - await Clipboard.setData(ClipboardData(text: seedHex)); + await Clipboard.setData(ClipboardData(text: masterHex)); if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text("Seed copied to clipboard")), + const SnackBar(content: Text("Master secret copied to clipboard")), ); } }, - child: const Text("Copy seed"), + child: const Text("Copy master secret"), ), const SizedBox(height: 40), PrimaryButton( @@ -292,7 +275,7 @@ class _OnboardingScreenState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _header(context, "Enter the 32-byte seed as 64 hex characters."), + _header(context, "Enter the 32-byte master secret as 64 hex characters."), const SizedBox(height: 20), TextField( controller: seedController, @@ -306,7 +289,7 @@ class _OnboardingScreenState extends ConsumerState { TextField( controller: restoreAddressController, decoration: InputDecoration( - labelText: "Address (if already registered)", + labelText: "Address this master was registered under", hintText: "alice@example.org", filled: true, fillColor: Theme.of(context).extension()!.cardFill, @@ -314,13 +297,13 @@ class _OnboardingScreenState extends ConsumerState { ), const SizedBox(height: 30), PrimaryButton( - onPressed: _submitRestore, - child: const Text("Restore"), + onPressed: busy ? () {} : _submitRestore, + child: busy ? const SmallLoadingSpinner() : const Text("Restore"), ), const SizedBox(height: 5), SecondaryButton( text: "Back", - onPressed: () => setState(() => step = _Step.welcome), + onPressed: busy ? () {} : () => setState(() => step = _Step.welcome), ), const SizedBox(height: 40), ], @@ -377,14 +360,18 @@ class _OnboardingScreenState extends ConsumerState { ? const SmallLoadingSpinner() : Text(recallIntent ? "Pin and Recall" : "Pin and Register"), ), - TextButton( - onPressed: busy - ? () {} - : (recallIntent ? _submitRegistration : _submitRecall), - child: Text(recallIntent - ? "Register a new address instead" - : "Already registered? Recall"), - ), + // Restoring a master has no well-defined "register instead" fallback + // until its rotation index is resolved (§2), so that escape hatch is + // only offered from the fresh-identity path. + if (!restoreIntent) + TextButton( + onPressed: busy + ? () {} + : (recallIntent ? _submitRegistration : _submitRecall), + child: Text(recallIntent + ? "Register a new address instead" + : "Already registered? Recall"), + ), const SizedBox(height: 80), ], ); diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 4dc1642..d9562d0 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -88,8 +88,9 @@ class _SettingsScreenState extends ConsumerState { if (mounted) { // The export payload is sealed now (v2), so the counts for this // notice come straight from the store rather than the ciphertext. - final messages = - store.listMessages("inbox").length + store.listMessages("sent").length; + final messages = store.listMessages("inbox").length + + store.listMessages("requests").length + + store.listMessages("sent").length; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("exported $messages messages, " "${store.allContacts().length} contacts, " @@ -207,7 +208,7 @@ class _SettingsScreenState extends ConsumerState { final account = ref.watch(accountProvider); if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings"))); - final seedHex = hex(me.seed); + final masterHex = hex(store.master()!); final share = account?.uri(me.publicKey); final pins = store.allPins(); @@ -249,12 +250,12 @@ class _SettingsScreenState extends ConsumerState { context, borderColor: Theme.of(context).colorScheme.error, children: [ - Text("seed", style: Theme.of(context).textTheme.labelSmall), + Text("master secret", style: Theme.of(context).textTheme.labelSmall), Row( children: [ Expanded( child: SelectableText( - seedShown ? seedHex : "•" * 64, + seedShown ? masterHex : "•" * 64, style: Theme.of(context).textTheme.bodySmall, ), ), @@ -266,12 +267,12 @@ class _SettingsScreenState extends ConsumerState { IconButton( icon: const Icon(Icons.copy, size: 18), onPressed: () => - Clipboard.setData(ClipboardData(text: seedHex)), + Clipboard.setData(ClipboardData(text: masterHex)), ), ], ), Text( - "back this seed up; it is the only secret", + "back this master secret up; it is the only secret", style: Theme.of(context) .textTheme .labelSmall! @@ -279,12 +280,31 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(height: 10), Text( - "retired keys: ${store.identities().length - 1} (kept to read old mail)", + "rotations: ${store.rotations()} (superseded keys kept to read old mail)", + style: Theme.of(context).textTheme.labelSmall), + const SizedBox(height: 4), + Text( + "accepted correspondents: ${store.allAccepted().where((e) => e.$2.active).length}" + "${store.syncOk() ? "" : " (not yet pushed to the server — accept them again)"}", style: Theme.of(context).textTheme.labelSmall), ], ), const SizedBox(height: 30), const Divider(height: 1), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text("Leave mail on server"), + subtitle: const Text( + "Fetch keeps a copy on the server instead of deleting it. " + "Delete a message here to remove it from both."), + value: store.leaveOnServer(), + onChanged: (value) { + store.setLeaveOnServer(value); + setState(() {}); + }, + ), + const SizedBox(height: 10), + const Divider(height: 1), const SizedBox(height: 20), Text("pinned servers", style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 10), diff --git a/lib/presentation/theme/breakpoints.dart b/lib/presentation/theme/breakpoints.dart new file mode 100644 index 0000000..dc2f7bc --- /dev/null +++ b/lib/presentation/theme/breakpoints.dart @@ -0,0 +1,4 @@ +/// Below this logical width the home shell shows a bottom [NavigationBar] +/// (phone); at or above it, a side [NavigationRail] (desktop). Matches +/// Material 3's compact/medium width class boundary. +const double kDesktopBreakpoint = 600; diff --git a/lib/presentation/widgets/drawer/app_drawer.dart b/lib/presentation/widgets/drawer/app_drawer.dart deleted file mode 100644 index 56a1f04..0000000 --- a/lib/presentation/widgets/drawer/app_drawer.dart +++ /dev/null @@ -1,35 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flutter/material.dart"; - -import "package:smol_mail/data/providers/providers.dart"; -import "package:smol_mail/smol/proto.dart"; -import "package:smol_mail/presentation/widgets/drawer/identity_header.dart"; -import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart"; -import "package:smol_mail/presentation/theme/app_colors.dart"; - -class AppDrawer extends ConsumerWidget { - const AppDrawer({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final identity = ref.watch(identityProvider); - final account = ref.watch(accountProvider); - final address = account?.short ?? ""; - final fp = identity == null ? "" : fingerprint(identity.publicKey); - - return Drawer( - backgroundColor: Theme.of(context).extension()!.cardFill, - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IdentityHeader(address: address, fingerprint: fp), - const SizedBox(height: 50), - const DrawerList(), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/drawer_list.dart b/lib/presentation/widgets/drawer/drawer_list.dart deleted file mode 100644 index 19b5c96..0000000 --- a/lib/presentation/widgets/drawer/drawer_list.dart +++ /dev/null @@ -1,63 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -import "package:smol_mail/data/providers/providers.dart"; -import "package:smol_mail/presentation/routes/app_router.gr.dart"; -import "package:auto_route/auto_route.dart"; -import "package:smol_mail/shared/configs/flash_mail_icons.dart"; -import "package:smol_mail/presentation/widgets/drawer/drawer_list_tile.dart"; - -class DrawerList extends ConsumerWidget { - const DrawerList({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final unread = ref.watch(unreadProvider); - - return Padding( - padding: const EdgeInsets.only(left: 25), - child: Column( - children: [ - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: FlashMailIcons.inbox, - title: "Inbox${unread > 0 ? " ($unread)" : ""}", - onTap: () { - ref.read(folderProvider.notifier).select("inbox"); - ref.read(revisionProvider.notifier).bump(); - Navigator.of(context).pop(); - }, - ), - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: Icons.outgoing_mail, - title: "Sent", - onTap: () { - ref.read(folderProvider.notifier).select("sent"); - ref.read(revisionProvider.notifier).bump(); - Navigator.of(context).pop(); - }, - ), - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: Icons.contacts, - title: "Contacts", - onTap: () { - Navigator.of(context).pop(); - AutoRouter.of(context).push(ContactsRoute()); - }, - ), - DrawerListTile( - color: Theme.of(context).primaryColor, - icon: Icons.settings, - title: "Settings", - onTap: () { - Navigator.of(context).pop(); - AutoRouter.of(context).push(SettingsRoute()); - }, - ), - ], - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/drawer_list_tile.dart b/lib/presentation/widgets/drawer/drawer_list_tile.dart deleted file mode 100644 index fb67c08..0000000 --- a/lib/presentation/widgets/drawer/drawer_list_tile.dart +++ /dev/null @@ -1,42 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:smol_mail/presentation/theme/app_colors.dart"; - -class DrawerListTile extends StatelessWidget { - final Color color; - final IconData icon; - final String title; - final void Function() onTap; - - const DrawerListTile({ - super.key, - required this.color, - required this.icon, - required this.title, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final appColors = Theme.of(context).extension()!; - return Theme( - data: Theme.of(context).copyWith( - splashColor: appColors.highlight, - highlightColor: appColors.cardFill, - ), - child: ListTile( - onTap: onTap, - leading: Icon(icon, color: color, size: 18), - title: Text( - title, - style: TextStyle( - fontFamily: "Inter", - color: color, - fontSize: 18, - fontWeight: FontWeight.w900, - ), - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/drawer/identity_header.dart b/lib/presentation/widgets/drawer/identity_header.dart deleted file mode 100644 index 3128c49..0000000 --- a/lib/presentation/widgets/drawer/identity_header.dart +++ /dev/null @@ -1,64 +0,0 @@ -import "package:flutter/material.dart"; - -import "package:smol_mail/shared/utils/format.dart"; - -/// Identity summary in the drawer: the address is what gets shared, the -/// fingerprint is what gets verified. -class IdentityHeader extends StatelessWidget { - final String address; - final String fingerprint; - - const IdentityHeader({ - super.key, - required this.address, - required this.fingerprint, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - radius: 35, - child: Text( - address.isEmpty ? "?" : address[0].toUpperCase(), - style: - Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30), - ), - ), - const SizedBox(height: 20), - InkWell( - onTap: () => copyText(context, address), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - address.isEmpty ? "(not registered)" : address, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - ), - ), - ), - ), - const SizedBox(height: 5), - InkWell( - onTap: () => copyText(context, fingerprint), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - "fp: $fingerprint", - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/presentation/widgets/message/message_list.dart b/lib/presentation/widgets/message/message_list.dart index 21b457b..fe32b71 100644 --- a/lib/presentation/widgets/message/message_list.dart +++ b/lib/presentation/widgets/message/message_list.dart @@ -5,21 +5,23 @@ import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/widgets/message/message_tile.dart"; class MessageList extends ConsumerWidget { + final String folder; final Future Function() onRefresh; const MessageList({ super.key, + required this.folder, required this.onRefresh, }); @override Widget build(BuildContext context, WidgetRef ref) { - final messages = ref.watch(messagesProvider); + final messages = ref.watch(messagesForFolderProvider(folder)); return RefreshIndicator( onRefresh: onRefresh, child: ListView.builder( - itemBuilder: (ctx, i) => MessageTile(record: messages[i]), + itemBuilder: (ctx, i) => MessageTile(folder: folder, record: messages[i]), itemCount: messages.length, ), ); diff --git a/lib/presentation/widgets/message/message_tile.dart b/lib/presentation/widgets/message/message_tile.dart index e2c854c..f2fb1a5 100644 --- a/lib/presentation/widgets/message/message_tile.dart +++ b/lib/presentation/widgets/message/message_tile.dart @@ -11,13 +11,13 @@ import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; class MessageTile extends ConsumerWidget { + final String folder; final MailRecord record; - const MessageTile({super.key, required this.record}); + const MessageTile({super.key, required this.folder, required this.record}); @override Widget build(BuildContext context, WidgetRef ref) { - final folder = ref.watch(folderProvider); final store = ref.watch(storeProvider); final opened = ref.read(clientProvider).describe(record); final appColors = Theme.of(context).extension()!; @@ -31,7 +31,7 @@ class MessageTile extends ConsumerWidget { } else { who = "?"; } - final unread = folder == "inbox" && !store.isRead(record.id); + final unread = folder != "sent" && !store.isRead(record.id); final errorText = opened.error != null ? "" : null; final subject = errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject); @@ -44,7 +44,7 @@ class MessageTile extends ConsumerWidget { splashColor: appColors.highlight, focusColor: appColors.cardFill, onTap: () { - if (folder == "inbox") { + if (folder != "sent") { store.markRead(record.id); ref.read(revisionProvider.notifier).bump(); } diff --git a/lib/presentation/widgets/message/message_view.dart b/lib/presentation/widgets/message/message_view.dart index cb6853a..e52c2e9 100644 --- a/lib/presentation/widgets/message/message_view.dart +++ b/lib/presentation/widgets/message/message_view.dart @@ -15,12 +15,14 @@ import "package:smol_mail/smol/store.dart"; /// The opened message: trust row (fingerprint and how the sender's key is /// known), frontmatter fields, and the plain body — smol mail has no HTML. class MessageView extends ConsumerWidget { + final String folder; final MailRecord record; final OpenedRecord opened; final void Function(Uint8List senderKey)? onNameSender; const MessageView({ super.key, + required this.folder, required this.record, required this.opened, this.onNameSender, @@ -38,18 +40,19 @@ class MessageView extends ConsumerWidget { ); } - final folder = ref.watch(folderProvider); final store = ref.watch(storeProvider); final client = ref.read(clientProvider); // Sent copies carry no signature from a third party; the trust row then // just says what they are. final isSent = folder == "sent"; + final isRequest = folder == "requests"; final senderKey = opened.sender; final known = senderKey == null ? null : store.addressForKey(senderKey); final contact = known == null ? null : store.contact(known); final verifiedSender = contact?.verified ?? false; final replyTo = isSent ? null : client.replyAddress(opened); + final accepted = known == null ? null : store.accepted(known); final subject = opened.subject.isEmpty ? "(no subject)" : opened.subject; @@ -69,6 +72,10 @@ class MessageView extends ConsumerWidget { : "key bound to no address", alert: known == null, ), + if (isRequest) ...[ + const SizedBox(height: 10), + _badge(context, "arrived without an accept token (SPEC.md §5.8)", alert: true), + ], if (!isSent && senderKey != null) ...[ const SizedBox(height: 10), _keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"), @@ -78,7 +85,7 @@ class MessageView extends ConsumerWidget { isSent ? (record.recipient ?? "") : (known ?? "unknown sender")), _field(context, "Date", formatTime(opened.time)), for (final entry in opened.fields.entries) - if (entry.key != "Subject" && entry.key != "Reply-To") + if (entry.key != "subject" && entry.key != "reply-to" && entry.key != "accept") _field(context, entry.key, entry.value), if (!isSent && replyTo != null) Row( @@ -114,6 +121,27 @@ class MessageView extends ConsumerWidget { child: const Text("Name this sender"), ), ), + if (isRequest && known != null && !(accepted?.active ?? false)) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + icon: const Icon(Icons.check_circle_outline, size: 18), + label: const Text("Accept sender"), + onPressed: () async { + try { + await client.acceptContact(known); + ref.read(revisionProvider.notifier).bump(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("$known accepted")), + ); + } + } catch (err) { + if (context.mounted) showErrorSnackBar(context, err.toString()); + } + }, + ), + ), const SizedBox(height: 10), const Divider(height: 1), const SizedBox(height: 20), diff --git a/lib/smol/client.dart b/lib/smol/client.dart index ffa283f..9390108 100644 --- a/lib/smol/client.dart +++ b/lib/smol/client.dart @@ -36,7 +36,7 @@ class OpenedRecord { const OpenedRecord(this.id, {this.sender, this.time, this.fields = const {}, this.body = "", this.error}); - String get subject => error == null ? (fields["Subject"] ?? "") : ""; + String get subject => error == null ? (fields["subject"] ?? "") : ""; } class SmolClient { @@ -58,6 +58,8 @@ class SmolClient { SmolIdentity? get identity => store.identity(); + Uint8List? get master => store.master(); + SmolAddress? accountAddress() { final account = store.account(); if (account == null) return null; @@ -91,25 +93,51 @@ class SmolClient { // --- identity setup ------------------------------------------------------------ - SmolIdentity createIdentity() { - final fresh = newIdentity(); - store.setIdentity(fresh.seed); + /// A fresh master secret at rotation index 0. Only this local step; nothing + /// is sent until [registerAccount]. + Uint8List createIdentity() { + final fresh = randomBytes(keyLen); + store.setMaster(fresh); return fresh; } - SmolIdentity restoreIdentity(String seedHex) { - Uint8List seed; + /// §2: a master alone does not say which rotation index a server has bound, + /// so restoring resolves the address and walks indices 0..[maxChain] until + /// one derives the key RESOLVE returned. Also binds "account" locally, like + /// [recallAccount] — restoring on a new device knows the identity but not + /// the address it was registered under. + Future restoreAndRecall(String masterHex, String addressText) async { + Uint8List master; try { - seed = unhex(seedHex.trim()); + master = unhex(masterHex.trim()); } on Exception { - throw const SmolError("seed must be 64 hex characters"); + throw const SmolError("master must be 64 hex characters"); } - if (seed.length != keyLen) { - throw SmolError("seed is ${seed.length} bytes, expected $keyLen"); + if (master.length != keyLen) { + throw SmolError("master is ${master.length} bytes, expected $keyLen"); } - final restored = identityFromSeed(seed); - store.setIdentity(seed); - return restored; + final addr = parseAddress(addressText); + final opened = await connect(addr, requirePin: true); + Uint8List current; + try { + current = (await resolveOp(opened.session, addr.user)).identity; + } finally { + opened.session.wire.close(); + } + int? found; + for (var n = 0; n <= maxChain; n++) { + if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) { + found = n; + break; + } + } + if (found == null) { + throw SmolError("the key bound to ${addr.short} is not derived from " + "this master within $maxChain rotations"); + } + store.restoreMaster(master, found); + store.setAccount(addr); + return addr; } void pinServer(String host, String keyB32) { @@ -126,7 +154,7 @@ class SmolClient { final addr = parseAddress(addressText); final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, addr.user, me, + await registerOp(opened.session, opened.serverStatic, addr.user, me, RegisterOptions(token: token)); } finally { opened.session.wire.close(); @@ -134,7 +162,7 @@ class SmolClient { store.setAccount(addr); } - /// Restoring a seed brings back the identity, not the memory of what + /// Restoring a master brings back the identity, not the memory of what /// address a *different device* registered it under — "account" is /// local-only state, never asked of the server. This binds it without /// REGISTER: RESOLVE the address and require it name this exact key, so a @@ -162,46 +190,178 @@ class SmolClient { Future fetch() async { final me = identity; + final master = this.master; final addr = accountAddress(); - if (me == null) throw const SmolError("no identity yet"); + if (me == null || master == null) throw const SmolError("no identity yet"); if (addr == null) { throw const SmolError("not registered; register an address first"); } var stored = 0; final rejected = []; + // §10: acknowledging (deleting) is the default; "leave mail on server" + // pages forward by cursor instead, so already-fetched mail is never + // re-downloaded even though it isn't deleted (store.storeIfNew also + // dedupes, as a second line of defense). + final leaveOnServer = store.leaveOnServer(); + var (afterTime, afterId) = store.cursor(); final opened = await connect(addr, requirePin: true); try { - await authenticate(opened.session, opened.handshakeHash, addr.user, me); + final (sync, tokens) = store.tokenSet(master); + await authenticate(opened.session, opened.handshakeHash, addr.user, me, + sync: sync, tokens: tokens); while (true) { - final records = await fetchOp(opened.session); + final records = await fetchOp(opened.session, afterTime, afterId); if (records.isEmpty) break; final acked = []; for (final record in records) { + afterTime = record.receivedAt; + afterId = record.id; + OpenedMessage msg; try { if (!timingSafeEqual(messageId(record.envelope), record.id)) { throw const SmolError("id does not match the envelope"); } - unseal(store.identities(), record.envelope); + msg = unseal(store.identities(), record.envelope); } on SmolError catch (err) { // Left on the server rather than destroyed, so a client-side bug // cannot lose mail. rejected.add("${hex(record.id)}: ${err.message}"); continue; } - final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope, - receivedAt: record.receivedAt)); - if (fresh != null) stored++; + final fresh = await store.storeIfNew( + "inbox", + MailRecord(hex(record.id), record.envelope, + receivedAt: record.receivedAt, + tier: record.isRequest ? tierRequests : tierMain, + keptOnServer: leaveOnServer)); + if (fresh != null) { + stored++; + _learnToken(msg); + } acked.add(record.id); } - if (acked.isEmpty) break; - await deleteOp(opened.session, acked); + if (leaveOnServer) { + // Persisted per batch, so an interrupted fetch resumes here rather + // than re-paging from the start next time. + store.setCursor(afterTime, afterId); + } else if (acked.isNotEmpty) { + await deleteOp(opened.session, acked); + } } } finally { opened.session.wire.close(); } + if (!leaveOnServer) { + // Everything acknowledged is deleted, so the next fetch starts fresh; a + // record left on the server (rejected above) simply resurfaces then. + store.setCursor(0, Uint8List(idLen)); + } return FetchSummary(stored, rejected); } + // --- delete ------------------------------------------------------------------ + + /// Deletes a message locally, and from the server too if it might still be + /// sitting there (only possible when "leave mail on server" was on when it + /// was fetched — §10). Sent copies are local-only; there is nothing + /// server-side to remove for them (§5.6). Throws, leaving the local copy in + /// place, if a needed server-side delete fails — otherwise a message could + /// look gone locally while silently persisting on the server. + Future deleteMessage(String folder, MailRecord record) async { + if (folder != "sent" && record.keptOnServer) { + final me = identity; + final addr = accountAddress(); + if (me == null || addr == null) { + throw const SmolError( + "not registered; cannot reach the server to delete this message"); + } + final opened = await connect(addr, requirePin: true); + try { + await authenticate(opened.session, opened.handshakeHash, addr.user, me, + sync: 0, tokens: const []); + await deleteOp(opened.session, [unhex(record.id)]); + } finally { + opened.session.wire.close(); + } + } + await store.deleteMessage(folder, record.id); + } + + // §5.8: an Accept field is bound to the signer of the message that carried + // it, which unseal() has already verified. + void _learnToken(OpenedMessage msg) { + final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true)); + final raw = parsed.fields["accept"]; + if (raw == null) return; + Uint8List token; + try { + token = b32decode(raw); + } on SmolError { + return; + } + if (token.length != tokenLen) return; + final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]); + if (address == null) return; // no address to send to, so no use for a token + store.learnToken(address, token); + } + + /// The address we know a signer by: a contact, or the Reply-To it signed + /// for itself. Naming a mailbox is not trusting a key, so nothing is + /// pinned here (§5.7, §8). + String? _addressOfSigner(Uint8List sender, String? replyTo) { + final known = store.addressForKey(sender); + if (known != null) return known; + if (replyTo == null) return null; + try { + final parsed = parseAddress(replyTo); + if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) { + return parsed.short; + } + } on SmolError { + // malformed claim: no address to learn a token under + } + return null; + } + + // --- accept tokens (§5.8) -------------------------------------------------------- + + /// Admit a contact to the main tier; their token travels in our next + /// message to them. Pushes the change to the server right away, since an + /// accept or a block only takes effect once it holds the changed set. + Future acceptContact(String address) async { + final key = store.contact(address)?.key; + if (key == null) throw SmolError("no key for $address yet"); + store.accept(address, key); + store.setSyncOk(true); + return _pushTokens(); + } + + /// Withdraw a contact's accept token; their mail lands in requests from + /// their next message on. + Future blockContact(String address) async { + store.block(address); + return _pushTokens(); + } + + Future _pushTokens() async { + final me = identity; + final master = this.master; + final addr = accountAddress(); + if (me == null || master == null) throw const SmolError("no identity yet"); + if (addr == null) { + _warn("not registered; the set will be pushed with your first fetch"); + return 0; + } + final opened = await connect(addr, requirePin: true); + try { + final (sync, tokens) = store.tokenSet(master); + return await authenticate(opened.session, opened.handshakeHash, addr.user, me, + sync: sync, tokens: tokens); + } finally { + opened.session.wire.close(); + } + } + // --- compose and send ----------------------------------------------------------- // Prefer a key we already trust; fall back to RESOLVE with trust on first @@ -227,7 +387,8 @@ class SmolClient { Future send(String toText, String subject, String body, {String? replyTo, bool anonymous = false}) async { final me = identity; - if (me == null) throw const SmolError("no identity yet"); + final master = this.master; + if (me == null || master == null) throw const SmolError("no identity yet"); final addr = parseAddress(toText); final recipient = await resolveRecipient(addr); final account = accountAddress(); @@ -237,12 +398,21 @@ class SmolClient { if (account != null && !anonymous) { fields["Reply-To"] = account.uri(me.publicKey); } + // §5.8: hand an accepted correspondent the token for our own mailbox, so + // a first reply from them reaches our main tier. + final accepted = store.accepted(addr.short); + if (accepted != null && accepted.active) { + fields["Accept"] = b32encode(tokenFor(master, accepted.identity)); + } final bodyBytes = utf8Bytes(buildFrontmatter( fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); final envelope = seal(me, recipient, bodyBytes); + // §5.8: our token for their mailbox, if they have given us one. + final held = store.tokenFrom(addr.short); + final mac = held == null ? null : acceptMac(held, messageId(envelope)); final opened = await connect(addr, requirePin: false); try { - await sendOp(opened.session, envelope); + await sendOp(opened.session, envelope, mac: mac); } finally { opened.session.wire.close(); } @@ -293,7 +463,7 @@ class SmolClient { /// full smol:// URI whose key matches the signer (§5.7); anything else is /// ordinary text. SmolAddress? replyAddress(OpenedRecord opened) { - final claim = opened.fields["Reply-To"]; + final claim = opened.fields["reply-to"]; if (claim == null || opened.sender == null) return null; try { final parsed = parseAddress(claim); @@ -370,7 +540,7 @@ class SmolClient { if (timingSafeEqual(known.key, resolved.identity)) { return RefreshOutcome("${addr.short}: key unchanged", false); } - if (walkChain(known.key, resolved.identity, resolved.chain)) { + if (walkChain(addr.user, known.key, resolved.identity, resolved.chain)) { store.saveContact(addr.short, resolved.identity, known.verified); return RefreshOutcome( "${addr.short} rotated its key; a signed chain confirms it.\n" @@ -395,25 +565,27 @@ class SmolClient { // --- rotation ----------------------------------------------------------------- - // §7: rotate to a fresh seed and rebind the account with a signed - // certificate. The old seed is kept by the store, since mail sealed to it - // stays readable with nothing else. + // §7: rotate to the next index's derived key and rebind the account with a + // signed certificate. The superseded key stays derivable from the master, + // since mail sealed to it stays readable with nothing else. Future rotateIdentity() async { final me = identity; + final master = this.master; final addr = accountAddress(); - if (me == null || addr == null) { + if (me == null || master == null || addr == null) { throw const SmolError("rotate needs a registered account"); } - final fresh = newIdentity(); - final cert = makeCert(me, fresh.seed); + final freshSeed = identitySeed(master, store.rotations() + 1); + final fresh = identityFromSeed(freshSeed); + final cert = makeCert(addr.user, me, freshSeed); final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, addr.user, fresh, + await registerOp(opened.session, opened.serverStatic, addr.user, fresh, RegisterOptions(cert: cert)); } finally { opened.session.wire.close(); } - store.rotateIdentity(fresh.seed); + store.advanceRotation(); _openedCache.clear(); return fresh; } diff --git a/lib/smol/proto.dart b/lib/smol/proto.dart index 881963f..c253723 100644 --- a/lib/smol/proto.dart +++ b/lib/smol/proto.dart @@ -1,6 +1,6 @@ -// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and -// signed envelopes, body frontmatter, key rotation, and the framed request -// and response bodies of the five operations. +// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed +// and signed envelopes, body frontmatter, key rotation, accept tokens, and +// the framed request and response bodies of the five operations. import "dart:math"; import "dart:typed_data"; @@ -10,9 +10,11 @@ import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/noise.dart"; const defaultPort = 1961; -const keyLen = 32, sigLen = 64, certLen = 136, idLen = 16; +const keyLen = 32, sigLen = 64, certLen = 200, idLen = 32, tokenLen = 32; const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024; const envelopeHeader = 69, payloadHeader = 45, maxChain = 16; +const maxSkew = 86400; // §5.3: how far ahead of our clock a payload may be dated +const flagRequests = 0x01; // §6.1: set when a FETCH record missed an accept token const _frontmatterMax = 4096, _frontmatterKeys = 64; const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03, @@ -32,6 +34,10 @@ final _label = ( msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"), rotate: utf8Bytes("smolmail/1 rotate"), + identity: utf8Bytes("smolmail/1 identity"), + accept: utf8Bytes("smolmail/1 accept"), + mac: utf8Bytes("smolmail/1 mac"), + register: utf8Bytes("smolmail/1 register"), ); // --- encoding helpers --------------------------------------------------------- @@ -139,11 +145,44 @@ SmolIdentity identityFromSeed(Uint8List seed) { SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen)); +// §2: the only secret a user holds. Everything else — every rotation index's +// signing seed, and the accept key — is derived from it with HKDF. +Uint8List identitySeed(Uint8List master, int index) => + hkdfSha256(master, Uint8List(0), concat([_label.identity, u32be(index)])); + +Uint8List acceptKeyFor(Uint8List master) => + hkdfSha256(master, Uint8List(0), _label.accept); + +// §5.8: the token this account issues to one correspondent, independent of +// the rotation index so it survives the owner's key rotation. +Uint8List tokenFor(Uint8List master, Uint8List correspondentIdentity) => + hmacSha256(acceptKeyFor(master), correspondentIdentity); + +// §5.8: what a sender attaches to SEND to reach the recipient's main tier. +Uint8List acceptMac(Uint8List token, Uint8List id) => + hmacSha256(token, concat([_label.mac, id])); + // --- addressing (§3) ----------------------------------------------------------- final _address = RegExp(r"^(?[a-z0-9._-]{1,63})@(?[^/:]+)(?::(?\d+))?$"); +const _separators = "._-"; + +// §3: alphanumeric at both ends, never two separators in a row. +bool validUsername(String name) { + if (name.isEmpty) return false; + if (_separators.contains(name[0]) || _separators.contains(name[name.length - 1])) { + return false; + } + for (var i = 0; i < name.length - 1; i++) { + if (_separators.contains(name[i]) && _separators.contains(name[i + 1])) { + return false; + } + } + return true; +} + class SmolAddress { final String user; final String host; @@ -179,8 +218,9 @@ SmolAddress parseAddress(String text) { if (m == null) throw SmolError("'$text' is not a valid address"); final user = m.namedGroup("user")!; final host = m.namedGroup("host")!; - if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) { - throw SmolError("$user may not begin or end with a separator"); + if (!validUsername(user)) { + throw SmolError("$user must begin and end with a letter or digit " + "and may not contain two separators in a row"); } final portText = m.namedGroup("port"); final port = portText != null ? int.parse(portText) : defaultPort; @@ -189,8 +229,9 @@ SmolAddress parseAddress(String text) { // --- message format (§5) ------------------------------------------------------- -Uint8List messageId(List envelope) => - sha256(concat([_label.id, envelope])).sublist(0, idLen); +// §5.4: derived from the envelope so no sender can choose it; used whole, +// nothing truncates it. +Uint8List messageId(List envelope) => sha256(concat([_label.id, envelope])); class OpenedMessage { final Uint8List sender; @@ -288,6 +329,9 @@ OpenedMessage unseal(List identities, Uint8List envelope) { signature)) { throw const SmolError("signature does not verify"); } + if (when > nowSeconds() + maxSkew) { + throw const SmolError("payload is dated in the future"); + } return OpenedMessage(sender, when, body, messageId(envelope)); } @@ -304,7 +348,8 @@ class Frontmatter { // A flat `Key: value` block, deliberately not YAML. Any malformed line // invalidates the whole block, which is then returned as ordinary body text: -// frontmatter fails closed toward display, never toward silent discard. +// frontmatter fails closed toward display, never toward silent discard. Keys +// are compared case-insensitively (§5.5), so they are kept lowercased. Frontmatter parseFrontmatter(String text) { if (!text.startsWith("---\n")) return Frontmatter(const {}, text); final lines = text.split("\n"); @@ -327,7 +372,7 @@ Frontmatter parseFrontmatter(String text) { return Frontmatter(const {}, text); } // first occurrence wins - fields.putIfAbsent(head, () => line.substring(colon + 1).trim()); + fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim()); } return Frontmatter(fields, rest); } @@ -343,35 +388,45 @@ String buildFrontmatter(Map fields, String body) { // --- key rotation (§7) --------------------------------------------------------- -Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { - final newPub = ed25519PublicKey(newSeed); +// §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both +// keys sign, so the old key alone cannot hand the username to a key nobody +// controls; the username is covered but not carried, so a verifier always +// supplies the one it is checking. +Uint8List makeCert( + String username, SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { + final newIdentity = identityFromSeed(newSeed); final time = i64be(BigInt.from(when ?? nowSeconds())); + final signed = concat( + [_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]); return concat([ oldIdentity.publicKey, - newPub, + newIdentity.publicKey, time, - ed25519Sign(oldIdentity.seed, - concat([_label.rotate, oldIdentity.publicKey, newPub, time])), + ed25519Sign(oldIdentity.seed, signed), + ed25519Sign(newIdentity.seed, signed), ]); } // Accept a key change only when a signed chain leads from the key we hold to -// the one the server now returns (§7). -bool walkChain(Uint8List pinned, Uint8List current, List chain) { +// the one the server now returns, both keys signing each link (§7). +bool walkChain( + String username, Uint8List pinned, Uint8List current, List chain) { if (timingSafeEqual(pinned, current)) return true; if (chain.isEmpty || chain.length > maxChain) return false; var key = pinned; var started = false; for (final cert in chain) { final old = cert.sublist(0, 32), next = cert.sublist(32, 64); - final when = cert.sublist(64, 72), sig = cert.sublist(72); + final when = cert.sublist(64, 72); + final sigOld = cert.sublist(72, 136), sigNew = cert.sublist(136, 200); if (!started) { if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold started = true; } else if (!timingSafeEqual(old, key)) { return false; // the chain is not continuous } - if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) { + final signed = concat([_label.rotate, utf8Bytes(username), old, next, when]); + if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) { return false; } key = next; @@ -482,18 +537,27 @@ void expectOk(int status, String what) { } // §4 session authentication: sign the handshake hash, which binds the -// signature to this session's server ephemeral and cannot be replayed. -Future authenticate( - Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async { +// signature to this session's server ephemeral and cannot be replayed, and +// push the accept token set (§5.8). `sync = 0` leaves the server's stored set +// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly. +// Returns the number of accept tokens the server now holds. +Future authenticate(Session session, Uint8List handshakeHash, String username, + SmolIdentity identity, {required int sync, List tokens = const []}) async { final name = utf8Bytes(username); if (name.length > 255) throw const SmolError("username too long"); + if (tokens.length > 0xffff) throw const SmolError("too many accept tokens for one AUTH"); final body = concat([ Uint8List.fromList([name.length]), name, identity.publicKey, ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])), + Uint8List.fromList([sync]), + u16be(tokens.length), + ...tokens, ]); - expectOk((await session.call(opAuth, body)).status, "authentication"); + final response = await session.call(opAuth, body); + expectOk(response.status, "authentication"); + return Reader(response.body).u16(); } class Resolved { @@ -516,8 +580,14 @@ Future resolveOp(Session session, String user) async { List.generate(r.u8(), (_) => r.take(certLen))); } -Future sendOp(Session session, Uint8List envelope) async { - final response = await session.call(opSend, envelope); +// §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes. +Future sendOp(Session session, Uint8List envelope, {Uint8List? mac}) async { + final macBytes = mac ?? Uint8List(0); + if (macBytes.isNotEmpty && macBytes.length != tokenLen) { + throw const SmolError("accept MAC must be $tokenLen bytes"); + } + final body = concat([Uint8List.fromList([macBytes.length]), macBytes, envelope]); + final response = await session.call(opSend, body); expectOk(response.status, "sending"); return response.body.length == idLen ? response.body @@ -527,19 +597,27 @@ Future sendOp(Session session, Uint8List envelope) async { class FetchedRecord { final Uint8List id; final int receivedAt; + final int flags; final Uint8List envelope; - const FetchedRecord(this.id, this.receivedAt, this.envelope); + const FetchedRecord(this.id, this.receivedAt, this.flags, this.envelope); + + // §6.1: bit 0 is set when the message arrived without a matching accept token. + bool get isRequest => flags & flagRequests != 0; } -Future> fetchOp(Session session) async { - final response = await session.call(opFetch); +// §6.1: pages forward from a cursor; an all-zero id starts at the beginning. +Future> fetchOp( + Session session, int afterReceivedAt, Uint8List afterId) async { + final body = concat([i64be(BigInt.from(afterReceivedAt)), afterId]); + final response = await session.call(opFetch, body); expectOk(response.status, "fetching"); final r = Reader(response.body); return List.generate(r.u16(), (_) { final id = r.take(idLen); final receivedAt = r.i64(); - return FetchedRecord(id, receivedAt, r.take(r.u32())); + final flags = r.u8(); + return FetchedRecord(id, receivedAt, flags, r.take(r.u32())); }); } @@ -558,9 +636,13 @@ class RegisterOptions { const RegisterOptions({this.token = "", this.cert}); } -Future registerOp( - Session session, String username, SmolIdentity identity, - [RegisterOptions opts = const RegisterOptions()]) async { +// §6.1: the signature is proof of possession, bound to the server that will +// store the binding so it cannot be replayed to another server. +Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) => + concat([_label.register, serverStatic, utf8Bytes(username), identity]); + +Future registerOp(Session session, Uint8List serverStatic, String username, + SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async { final name = utf8Bytes(username); final tokenBytes = utf8Bytes(opts.token); final cert = opts.cert ?? Uint8List(0); @@ -571,6 +653,7 @@ Future registerOp( Uint8List.fromList([name.length]), name, identity.publicKey, + ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)), Uint8List.fromList([tokenBytes.length]), tokenBytes, Uint8List.fromList([cert.length]), diff --git a/lib/smol/store.dart b/lib/smol/store.dart index 3813aef..d80391e 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -1,7 +1,7 @@ // Device state: identity, pins, contacts and read markers in one JSON blob; // sealed envelopes in a second Hive box, opened only on demand, so nothing at -// rest is plaintext (the seed excepted — the device's app storage is the trust -// boundary, like gsmol's browser profile). +// rest is plaintext (the master secret excepted — the device's app storage is +// the trust boundary, like gsmol's browser profile). import "dart:convert"; import "dart:typed_data"; @@ -42,14 +42,36 @@ class StoredContact { } class MailRecord { - final String id; // hex of the 16-byte message id + final String id; // hex of the 32-byte message id final Uint8List envelope; final int? receivedAt; final String? recipient; // sent copies only final int? sentAt; + final int tier; // §5.8: tierMain or tierRequests; meaningless for sent copies + + /// Whether "leave mail on server" was on when this was fetched, so a + /// manual delete still has a server-side copy to remove. Always false for + /// sent copies, which never had one (§5.6). + final bool keptOnServer; const MailRecord(this.id, this.envelope, - {this.receivedAt, this.recipient, this.sentAt}); + {this.receivedAt, + this.recipient, + this.sentAt, + this.tier = tierMain, + this.keptOnServer = false}); +} + +const tierMain = 0, tierRequests = 1; + +/// A correspondent admitted to this mailbox's main tier (§5.8). The identity +/// is frozen at acceptance because the token is derived from it: a contact's +/// later rotation must not change the token they already hold. +class AcceptedContact { + final Uint8List identity; + final bool active; + + const AcceptedContact(this.identity, this.active); } class ImportSummary { @@ -87,49 +109,66 @@ class SmolStore { _state.put(_stateKey, next); } - // --- identity -------------------------------------------------------------- + // --- identity (§2) ----------------------------------------------------------- - Uint8List? seed() { - final raw = _load()["seed"]; + /// The 32-byte master secret, or null before the user creates or restores + /// one. Every signing key is derived from it plus the rotation index. + Uint8List? master() { + final raw = _load()["master"]; return raw == null ? null : unhex(raw as String); } + /// The rotation index (§7) of the identity currently in use. + int rotations() => (_load()["rotations"] as int?) ?? 0; + /// The active identity, or null before the user creates or restores one. SmolIdentity? identity() { - final s = seed(); - return s == null ? null : identityFromSeed(s); + final m = master(); + return m == null ? null : identityFromSeed(identitySeed(m, rotations())); } - void setIdentity(Uint8List newSeed) { - if (seed() != null) { - throw const SmolIdentityExistsException(); + /// Whether the accepted-correspondent set held here may replace the + /// server's on the next AUTH — false right after a restore from the master + /// alone, whose empty set must not erase the server's (§4). + bool syncOk() => (_load()["syncOk"] as bool?) ?? true; + + void setSyncOk(bool ok) => _update((state) => state..["syncOk"] = ok); + + void _bindMaster(Uint8List newMaster, int rotations, bool syncOk) { + if (master() != null) throw const SmolIdentityExistsException(); + _update((state) => state + ..["master"] = hex(newMaster) + ..["rotations"] = rotations + ..["syncOk"] = syncOk); + setCursor(0, Uint8List(idLen)); + } + + /// A fresh identity: rotation index 0, and an empty accepted set is + /// already complete, so it may sync. + void setMaster(Uint8List newMaster) => _bindMaster(newMaster, 0, true); + + /// §2: recovering a master alone does not recover which correspondents were + /// accepted, so that set must not overwrite the server's until rebuilt. + void restoreMaster(Uint8List newMaster, int rotationIndex) => + _bindMaster(newMaster, rotationIndex, false); + + // Rotation (§7): only the index advances; the superseded key stays + // derivable from the master, so nothing has to be archived. + void advanceRotation() { + final current = rotations(); + if (master() == null) throw const SmolNoIdentityException(); + if (current >= maxChain) { + throw SmolError("the rotation chain is full at $maxChain links"); } - _update((state) => state..["seed"] = hex(newSeed)); + _update((state) => state..["rotations"] = current + 1); } - // Rotation (§7): the old seed is retained, since mail sealed to a - // superseded key is readable with nothing else. - void rotateIdentity(Uint8List newSeed) { - final old = seed(); - if (old == null) throw const SmolNoIdentityException(); - _update((state) { - final retired = (state["retired"] as List? ?? []) - ..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch}); - state["retired"] = retired; - state["seed"] = hex(newSeed); - return state; - }); - } - - /// §7: seeds rotated away from are retained, since mail sealed to a - /// superseded key is readable with nothing else. + /// §7: every key rotated away from is re-derivable from the master, since + /// mail sealed to a superseded key is readable with nothing else. List identities() { - final s = seed(); - if (s == null) return const []; - final retired = (_load()["retired"] as List? ?? const []) - .whereType() - .map((entry) => identityFromSeed(unhex(entry["seed"] as String))); - return [identityFromSeed(s), ...retired]; + final m = master(); + if (m == null) return const []; + return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))]; } // --- account and server pins ------------------------------------------------- @@ -176,6 +215,31 @@ class SmolStore { return [for (final e in servers.entries) (e.key, b32decode(e.value))]; } + // --- FETCH behavior -------------------------------------------------------- + + /// When true, FETCH does not acknowledge (delete) what it retrieves — + /// mail stays on the server until explicitly deleted. Defaults to the + /// original behavior: fetched mail is acknowledged immediately. + bool leaveOnServer() => (_load()["leaveOnServer"] as bool?) ?? false; + + void setLeaveOnServer(bool value) => + _update((state) => state..["leaveOnServer"] = value); + + // --- FETCH cursor (§6.1) ------------------------------------------------------- + + (int, Uint8List) cursor() { + final state = _load(); + final afterId = state["afterId"] as String?; + return ( + (state["afterTime"] as int?) ?? 0, + afterId == null ? Uint8List(idLen) : unhex(afterId), + ); + } + + void setCursor(int afterTime, Uint8List afterId) => _update((state) => state + ..["afterTime"] = afterTime + ..["afterId"] = hex(afterId)); + // --- contacts ------------------------------------------------------------------ StoredContact? contact(String address) { @@ -233,6 +297,82 @@ class SmolStore { return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)]; } + // --- accept tokens (§5.8) -------------------------------------------------------- + + AcceptedContact? accepted(String address) { + final a = ((_load()["accepted"] as Map?) ?? {})[address]; + if (a is! Map) return null; + return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool); + } + + /// Admit a contact to the main tier. The identity is frozen at acceptance — + /// re-accepting after a block must not change which key the token is + /// derived from (§5.8). + void accept(String address, Uint8List identity) { + _update((state) { + final accepted = (state["accepted"] as Map? ?? {}).cast(); + final previous = accepted[address]; + accepted[address] = { + "identity": previous?["identity"] ?? b32encode(identity), + "active": true, + "addedAt": previous?["addedAt"] ?? DateTime.now().millisecondsSinceEpoch, + }; + state["accepted"] = accepted; + return state; + }); + } + + /// Withdraw a contact's accept token; their mail lands in the requests tier + /// from their next message on. Throws if the contact was never accepted. + void block(String address) { + final accepted = (_load()["accepted"] as Map? ?? {}).cast(); + if (!accepted.containsKey(address)) { + throw SmolError("$address was never accepted"); + } + _update((state) { + final accepted = (state["accepted"] as Map? ?? {}).cast(); + accepted[address] = {...accepted[address]!, "active": false}; + state["accepted"] = accepted; + return state; + }); + } + + List<(String, AcceptedContact)> allAccepted() { + final accepted = ((_load()["accepted"] as Map?) ?? {}).cast(); + return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)]; + } + + /// §4: the tokens to push with AUTH, and whether to push at all. A client + /// that cannot vouch for its own set — one restored from the master alone — + /// must not replace the server's with an incomplete one. + (int, List) tokenSet(Uint8List master) { + if (!syncOk()) return (0, const []); + final active = allAccepted().where((e) => e.$2.active).toList() + ..sort((a, b) => a.$1.compareTo(b.$1)); + return (1, [for (final e in active) tokenFor(master, e.$2.identity)]); + } + + /// A token received from a correspondent, filed under the address that + /// issued it: an address outlives the keys behind it, so the token keeps + /// working across the issuer's rotations (§5.8). + Uint8List? tokenFrom(String address) { + final raw = ((_load()["tokens"] as Map?) ?? {})[address]; + if (raw is! Map) return null; + return b32decode(raw["token"] as String); + } + + void learnToken(String address, Uint8List token) { + _update((state) { + final tokens = (state["tokens"] as Map? ?? {}).cast(); + tokens[address] = { + "token": b32encode(token), + "seenAt": DateTime.now().millisecondsSinceEpoch, + }; + state["tokens"] = tokens; + return state; + }); + } + // --- read markers --------------------------------------------------------------- void markRead(String idHex) { @@ -255,6 +395,8 @@ class SmolStore { "receivedAt": record.receivedAt, "recipient": record.recipient, "sentAt": record.sentAt, + "tier": record.tier, + "keptOnServer": record.keptOnServer, }; MailRecord _mapToRecord(Map map) => MailRecord( @@ -263,10 +405,18 @@ class SmolStore { receivedAt: map["receivedAt"] as int?, recipient: map["recipient"] as String?, sentAt: map["sentAt"] as int?, + tier: (map["tier"] as int?) ?? tierMain, + keptOnServer: (map["keptOnServer"] as bool?) ?? false, ); static String mailKey(String folder, String id) => "$folder/$id"; + // "requests" is a view over the same physical "inbox" records, filtered by + // tier (§5.8) — not a separate folder, so a message keeps one identity + // regardless of which tier it arrived in. + static String _physicalFolder(String folder) => + folder == "requests" ? "inbox" : folder; + Future storeMessage(String folder, MailRecord record) => _mail.put(mailKey(folder, record.id), _recordToMap(record)); @@ -279,12 +429,17 @@ class SmolStore { } List listMessages(String folder) { - final prefix = "$folder/"; + final physical = _physicalFolder(folder); + final prefix = "$physical/"; + final wantTier = folder == "requests" ? tierRequests : tierMain; final rows = []; for (final key in _mail.keys.cast()) { if (!key.startsWith(prefix)) continue; final row = _mail.get(key); - if (row is Map) rows.add(_mapToRecord(row)); + if (row is! Map) continue; + final record = _mapToRecord(row); + if (physical == "inbox" && record.tier != wantTier) continue; + rows.add(record); } rows.sort((a, b) => (b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0)); @@ -292,29 +447,29 @@ class SmolStore { } MailRecord? getMessage(String folder, String id) { - final row = _mail.get(mailKey(folder, id)); + final row = _mail.get(mailKey(_physicalFolder(folder), id)); return row is Map ? _mapToRecord(row) : null; } Future deleteMessage(String folder, String id) => - _mail.delete(mailKey(folder, id)); + _mail.delete(mailKey(_physicalFolder(folder), id)); // --- export / import: mail, contacts, pins — never the seed -------------------- /// Label kept as gsmol wrote it originally; the export format version /// (gsmolExport) is what actually changed between v1 and v2. static final _exportLabel = utf8Bytes("gsmol/1 export"); - Uint8List _exportKey(Uint8List seed) => - hkdfSha256(seed, Uint8List(0), _exportLabel, 32); + Uint8List _exportKey(Uint8List master) => + hkdfSha256(master, Uint8List(0), _exportLabel, 32); /// v2 matches gsmol's own current export: the whole payload — mail, - /// contacts, pins — is sealed to a key derived from the identity's seed, so - /// a backup file is only readable by whoever holds that seed. Deliberately - /// excludes the seed itself: it has its own reveal-and-copy flow in - /// settings, meant for a password manager, not a shareable file. + /// contacts, pins — is sealed to a key derived from the identity's master, + /// so a backup file is only readable by whoever holds that master. + /// Deliberately excludes the master itself: it has its own reveal-and-copy + /// flow in settings, meant for a password manager, not a shareable file. Map exportData() { - final seed = this.seed(); - if (seed == null) throw const SmolError("no identity yet"); + final master = this.master(); + if (master == null) throw const SmolError("no identity yet"); final state = _load(); final contacts = ((state["contacts"] as Map?) ?? {}).cast(); final payload = { @@ -329,11 +484,13 @@ class SmolStore { } }, "inbox": [ - for (final row in listMessages("inbox")) + for (final row in [...listMessages("inbox"), ...listMessages("requests")]) { "id": row.id, "receivedAt": row.receivedAt, "envelope": base64Encode(row.envelope), + "tier": row.tier, + "keptOnServer": row.keptOnServer, } ], "sent": [ @@ -348,7 +505,7 @@ class SmolStore { }; final nonce = randomBytes(12); final ciphertext = aeadEncrypt( - _exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); + _exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); return { "gsmolExport": 2, "exportedAt": DateTime.now().millisecondsSinceEpoch, @@ -364,13 +521,13 @@ class SmolStore { Future importData(Map data) async { Map payload; if (data["gsmolExport"] == 2) { - final seed = this.seed(); - if (seed == null) { + final master = this.master(); + if (master == null) { throw const SmolError("no identity yet — restore it before importing"); } try { final plaintext = aeadDecrypt( - _exportKey(seed), + _exportKey(master), base64Decode(data["nonce"] as String), base64Decode(data["ciphertext"] as String), Uint8List(0), @@ -447,6 +604,8 @@ class SmolStore { receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null, recipient: folder == "sent" ? map["recipient"] as String? : null, sentAt: folder == "sent" ? map["sentAt"] as int? : null, + tier: (map["tier"] as int?) ?? tierMain, + keptOnServer: (map["keptOnServer"] as bool?) ?? false, ); if (await storeIfNew(folder, record) != null) summary.mailAdded++; } on Exception { @@ -481,6 +640,14 @@ class SmolStore { } return count; } + + int requestsUnreadCount() { + var count = 0; + for (final row in listMessages("requests")) { + if (!isRead(row.id)) count++; + } + return count; + } } /// The store throws these typed errors so the UI can tell "no identity yet" diff --git a/test/e2e_test.dart b/test/e2e_test.dart index 4db7129..f137bfe 100644 --- a/test/e2e_test.dart +++ b/test/e2e_test.dart @@ -1,13 +1,15 @@ // End-to-end against a live reference server: register an address on a -// locally running smolmaild, send a sealed message to ourselves, fetch it back, -// and check the server is drained afterwards. Skips when nothing listens on -// 127.0.0.1:1961, so `devbox run test` does not depend on a server. +// locally running smolmaild, send sealed messages to ourselves, fetch them +// back, exercise the accept-token round trip (§5.8) between the requests and +// main tiers, and check the server is drained afterwards. Skips when nothing +// listens on 127.0.0.1:1961, so `devbox run test` does not depend on a server. // // To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key && // uv run smolmaild.py serve --key server.key --db mail.db) // then `devbox run test`. import "dart:io"; +import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; @@ -47,7 +49,8 @@ void main() { final warnings = []; client.onWarning = warnings.add; - final me = client.createIdentity(); + final master = client.createIdentity(); + final me = client.identity!; final user = "e2e${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); final learned = await client.connect(address, requirePin: false); @@ -67,13 +70,16 @@ void main() { expect(summary.stored, 2); expect(summary.rejected, isEmpty); - final inbox = store.listMessages("inbox"); - expect(inbox.length, 2); + // §5.8: we have not accepted ourselves as a correspondent yet, so this + // unsolicited self-mail lands in the requests tier, not the main one. + expect(store.listMessages("inbox"), isEmpty); + final requests = store.listMessages("requests"); + expect(requests.length, 2); // receivedAt has second granularity, so the order of the two is not // guaranteed; assert on the pair, then open the one we care about. - final subjects = inbox.map((m) => client.describe(m).subject).toSet(); + final subjects = requests.map((m) => client.describe(m).subject).toSet(); expect(subjects, {"hello e2e", "second"}); - final hello = inbox.firstWhere( + final hello = requests.firstWhere( (m) => client.describe(m).subject == "hello e2e"); final opened = client.describe(hello); expect(opened.error, isNull); @@ -84,9 +90,27 @@ void main() { final again = await client.fetch(); expect(again.stored, 0); + // Accept ourselves as a correspondent (§5.8): the change is pushed to the + // server right away. This next message carries our own Accept field, but + // no MAC yet — we cannot know our own token before receiving and parsing + // a message that carries it — so it still lands in requests. + await client.acceptContact(address.short); + await client.send(address.short, "third", "still unsolicited"); + expect((await client.fetch()).stored, 1); + expect(store.listMessages("requests").length, 3); + expect(store.listMessages("inbox"), isEmpty); + + // Having now learned our own token from that message's Accept field, the + // next one carries a matching MAC and reaches the main tier. + await client.send(address.short, "fourth", "now accepted"); + expect((await client.fetch()).stored, 1); + final mainTier = store.listMessages("inbox"); + expect(mainTier.length, 1); + expect(client.describe(mainTier.single).subject, "fourth"); + // The sent copy is sealed to ourselves and readable (§5.6). final sent = store.listMessages("sent"); - expect(sent.length, 2); + expect(sent.length, 4); expect(client.describe(sent.first).error, isNull); // A restored seed can rebind the address without REGISTER; the address @@ -96,13 +120,13 @@ void main() { expect( () => client.recallAccount("nobody@$host"), throwsA(isA())); - // Restore on a second device: same seed, fresh store, no pin. Unpinned + // Restore on a second device: same master, fresh store, no pin. Unpinned // recall is refused; re-registering a taken name is refused; recall with // the operator-supplied key then binds the account without REGISTER. final restored = await SmolStore.open( stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail"); final secondDevice = SmolClient(restored); - restored.setIdentity(me.seed); + restored.setMaster(master); expect( secondDevice.recallAccount(address.short), throwsA(isA())); restored.pinServer(host, learned.serverStatic); @@ -118,4 +142,50 @@ void main() { expect(outcome.message, contains("key unchanged")); expect(store.contact(address.short)!.history, isEmpty); }, timeout: const Timeout(Duration(minutes: 2))); + + test("leave mail on server keeps mail until deleted, with dedupe on refetch", + () async { + if (!await serverUp()) { + markTestSkipped("no smolmaild on $host:$port"); + return; + } + final dir = await Directory.systemTemp.createTemp("smol-e2e-keep"); + Hive.init(dir.path); + final store = + await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail"); + final client = SmolClient(store); + + client.createIdentity(); + final user = "e2ekeep${hex(randomBytes(4))}"; + final address = parseAddress("$user@$host"); + final learned = await client.connect(address, requirePin: false); + store.pinServer(host, learned.serverStatic); + learned.session.wire.close(); + await client.registerAccount(address.short); + + store.setLeaveOnServer(true); + await client.send(address.short, "kept", "stays on the server until deleted"); + + final first = await client.fetch(); + expect(first.stored, 1); + final record = store.listMessages("requests").single; + expect(record.keptOnServer, isTrue); + expect(client.describe(record).subject, "kept"); + + // Re-fetching from scratch must not duplicate it locally (storeIfNew's + // dedupe), even though the server still has it (nothing was deleted). + store.setCursor(0, Uint8List(idLen)); + final second = await client.fetch(); + expect(second.stored, 0); + expect(store.listMessages("requests").length, 1); + + // Deleting removes it from the server too: a further full re-page after + // deletion must come back empty rather than resurrecting it. + await client.deleteMessage("requests", record); + expect(store.listMessages("requests"), isEmpty); + store.setCursor(0, Uint8List(idLen)); + final third = await client.fetch(); + expect(third.stored, 0); + expect(store.listMessages("requests"), isEmpty); + }, timeout: const Timeout(Duration(minutes: 2))); } diff --git a/test/recall_flow_test.dart b/test/recall_flow_test.dart index 7f48832..147ebbc 100644 --- a/test/recall_flow_test.dart +++ b/test/recall_flow_test.dart @@ -1,4 +1,4 @@ -// Regression tests for the onboarding recall flows: restoring a seed and +// Regression tests for the onboarding recall flows: restoring a master and // recalling the registered address must land the user in the inbox. The // client's network operations are stubbed; what is under test is the UI and // router flow itself. @@ -14,14 +14,28 @@ import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/app_widget.dart"; import "package:smol_mail/smol/client.dart"; import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; -/// A [SmolClient] whose recall completes instantly, so the test exercises -/// the flow rather than the network. +/// A [SmolClient] whose network operations complete instantly, so the test +/// exercises the flow rather than the network. [restoreAndRecall] still +/// enforces the pin gate (SPEC.md §4), since that gate is exactly what the +/// restore flow's UX is regression-tested against. class StubClient extends SmolClient { StubClient(super.store); + @override + Future restoreAndRecall(String masterHex, String addressText) async { + final addr = parseAddress(addressText); + if (store.serverPin(addr.host) == null) { + throw SmolError("no pinned key for ${addr.host}"); + } + store.restoreMaster(unhex(masterHex.trim()), 0); + store.setAccount(addr); + return addr; + } + @override Future recallAccount(String addressText) async { final addr = parseAddress(addressText); @@ -58,7 +72,7 @@ void main() { "restore with an address, but no pin yet, asks for the server key " "and then recalls into the inbox", (tester) async { final store = storeA; - final seed = randomBytes(32); + final master = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); @@ -68,7 +82,7 @@ void main() { var fields = find.byType(TextField); expect(fields, findsNWidgets(2)); - await tester.enterText(fields.at(0), hex(seed)); + await tester.enterText(fields.at(0), hex(master)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); @@ -80,6 +94,9 @@ void main() { expect(find.text("Pin this server's public key to finish restoring your address."), findsOneWidget); expect(find.text("Invite token (optional)"), findsNothing); + // Restoring has no well-defined "register a new address instead" escape + // hatch until the rotation index is known (§2). + expect(find.text("Register a new address instead"), findsNothing); fields = find.byType(TextField); expect(fields, findsNWidgets(2)); // address (carried over), server key await tester.enterText(fields.at(1), b32encode(randomBytes(32))); @@ -87,84 +104,71 @@ void main() { await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); - expect(store.seed(), seed); + expect(store.master(), master); expect(store.account()!.user, "randogoth"); }); - testWidgets("restore without an address recalls from the register step", + testWidgets("restore requires an address before it will submit", (tester) async { final store = storeB; - final seed = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); - var fields = find.byType(TextField); - await tester.enterText(fields.at(0), hex(seed)); + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(randomBytes(32))); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); - // The register step: address, server key, optional invite token. - fields = find.byType(TextField); - expect(fields, findsNWidgets(3)); - await tester.enterText(fields.at(0), "randogoth@smol.place"); - await tester.enterText(fields.at(1), b32encode(randomBytes(32))); - await tester.tap(find.text("Already registered? Recall")); - await tester.pumpAndSettle(); - - expect(find.text("Inbox"), findsOneWidget); - expect(store.account()!.user, "randogoth"); + // Nothing was submitted, so nothing was persisted; still on this step. + expect(find.text("Restore"), findsOneWidget); + expect(find.text("Inbox"), findsNothing); + expect(store.master(), isNull); }); testWidgets( - "restoring again after an incomplete attempt replaces the identity " + "restoring after an abandoned Create Identity attempt replaces it " "instead of refusing it", (tester) async { final store = storeC; - final abandonedSeed = randomBytes(32); - final realSeed = randomBytes(32); + final realMaster = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); - // First attempt: restore a seed but never finish registering — lands on - // the register step, identity set, no account bound. - await tester.tap(find.text("Restore From Seed")); + // First attempt: create a fresh identity but never finish registering — + // lands on the backup step, master set, no account bound. + await tester.tap(find.text("Create Identity")); await tester.pumpAndSettle(); - var fields = find.byType(TextField); - await tester.enterText(fields.at(0), hex(abandonedSeed)); - await tester.tap(find.text("Restore")); - await tester.pumpAndSettle(); - expect(store.seed(), abandonedSeed); + expect(store.master(), isNotNull); expect(store.account(), isNull); // Simulate returning to onboarding later (e.g. a cold restart). Pumping // app(store) directly would just rebuild the existing OnboardingScreen - // state in place (still parked on the register step) rather than really - // restarting, so tear the tree down first to force a fresh app state — - // HomeGuard then sends an identity-without-account back to welcome. + // state in place rather than really restarting, so tear the tree down + // first to force a fresh app state — HomeGuard then sends an + // identity-without-account back to welcome. await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); - // Restoring a different seed must not throw "identity already exists". + // Restoring a different master must not throw "identity already exists". await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); - fields = find.byType(TextField); - await tester.enterText(fields.at(0), hex(realSeed)); + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), hex(realMaster)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // Lands on the recall-framed register step (no pin yet); pin it and finish. - fields = find.byType(TextField); - expect(fields, findsNWidgets(2)); - await tester.enterText(fields.at(1), b32encode(randomBytes(32))); + expect(find.byType(TextField), findsNWidgets(2)); + await tester.enterText(find.byType(TextField).at(1), b32encode(randomBytes(32))); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); - expect(store.seed(), realSeed); + expect(store.master(), realMaster); }); } diff --git a/test/smol_test.dart b/test/smol_test.dart index be77744..12ab9e7 100644 --- a/test/smol_test.dart +++ b/test/smol_test.dart @@ -163,24 +163,28 @@ void main() { }); test("frontmatter parses and fails closed (§5.5)", () { - const spec = - "---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here."; + const inReplyTo = + "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; + final spec = + "---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\nX-Mood: cautiously optimistic\n---\nBody text starts here."; final parsed = parseFrontmatter(spec); - expect(parsed.fields["Subject"], "Re: the thing"); - expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d"); + expect(parsed.fields["subject"], "Re: the thing"); + expect(parsed.fields["in-reply-to"], inReplyTo); expect(parsed.body, "Body text starts here."); // a malformed line invalidates the whole block, which fails closed toward display expect(parseFrontmatter("---\nno colon here\n---\nrest").body, "---\nno colon here\n---\nrest"); expect(parseFrontmatter("---\nSubject: x\nno end").body, "---\nSubject: x\nno end"); - expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["A"], "1"); + expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["a"], "1"); + // keys compare case-insensitively; the first occurrence wins (§5.5) + expect(parseFrontmatter("---\nSubject: x\nsubject: y\n---\ntext").fields["subject"], "x"); expect(buildFrontmatter(const {}, "---\nactual body"), "---\n---\n---\nactual body"); expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere"); expect(buildFrontmatter(const {}, "plain"), "plain"); expect( - parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"], + parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["subject"], isNull); }); @@ -195,6 +199,9 @@ void main() { expect(parsed.identity, key); expect(parsed.user, "bob"); expect(() => parseAddress("-bob@h"), throwsA(isA())); + // §3: never two separators in a row + expect(() => parseAddress("a..b@h"), throwsA(isA())); + expect(parseAddress("a.b_c@h").user, "a.b_c"); // §3: fingerprints are the first 20 base32 characters in groups of four final b32 = b32encode(key); expect( @@ -210,27 +217,31 @@ void main() { }); test("rotation chains validate, break and oversize per §7", () { + const username = "alice"; final old = identityFromSeed(randomBytes(32)); final mid = randomBytes(32); final fresh = randomBytes(32); final when = nowSeconds(); final chain = [ - makeCert(old, mid, when), - makeCert(identityFromSeed(mid), fresh, when), + makeCert(username, old, mid, when), + makeCert(username, identityFromSeed(mid), fresh, when), ]; - expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue); - expect(walkChain(old.publicKey, old.publicKey, []), isTrue); + expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue); + expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue); expect( - walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), + walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), isFalse); final forged = List.from(chain); - forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when); + forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when); expect( - walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse); + walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse); expect( - walkChain(old.publicKey, ed25519PublicKey(fresh), + walkChain(username, old.publicKey, ed25519PublicKey(fresh), List.filled(17, chain[0])), isFalse); + // a chain signed for a different username must not validate (§7) + expect( + walkChain("bob", old.publicKey, ed25519PublicKey(fresh), chain), isFalse); expect(chain[0].length, certLen); }); diff --git a/test/store_test.dart b/test/store_test.dart index bf8ea19..7d6abe8 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -63,11 +63,11 @@ void main() { expect(saved.key, identity.publicKey); }); - test("export never contains the seed and round-trips through import", + test("export never contains the master secret and round-trips through import", () async { final a = await freshStore("export"); final b = await freshStore("round-trip"); - a.setIdentity(randomBytes(32)); + a.setMaster(randomBytes(32)); a.pinServer("example.org", randomBytes(32)); a.saveContact("alice@example.org", randomBytes(32), true); a.saveContact("alice@example.org", randomBytes(32), false); // history grows @@ -77,12 +77,12 @@ void main() { MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); final data = a.exportData(); - expect(data["gsmolExport"], 2); // sealed to the identity's seed, like gsmol - expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse); + expect(data["gsmolExport"], 2); // sealed to the identity's master, like gsmol + expect(jsonEncode(data).contains(hex(a.master()!)), isFalse); - // v2 is sealed to the exporting identity's seed — a restore-on-new-device + // v2 is sealed to the exporting identity's master — a restore-on-new-device // scenario, not a transfer to someone else's identity (see the test below). - b.setIdentity(a.seed()!); + b.setMaster(a.master()!); final summary = await b.importData(data); expect(summary.mailAdded, 2); expect(summary.pinsAdded, 1); @@ -96,14 +96,58 @@ void main() { test("v2 import refuses a different identity's export", () async { final a = await freshStore("export-wrong-identity"); final c = await freshStore("round-trip-wrong-identity"); - a.setIdentity(randomBytes(32)); + a.setMaster(randomBytes(32)); a.pinServer("example.org", randomBytes(32)); final data = a.exportData(); - c.setIdentity(randomBytes(32)); // a different seed than a's + c.setMaster(randomBytes(32)); // a different master than a's expect(() => c.importData(data), throwsA(isA())); }); + test("accept tokens: accept/block gate the sync set, tiers split the inbox view", + () async { + final store = await freshStore("accept-tokens"); + final master = randomBytes(32); + store.setMaster(master); + final alice = randomBytes(32); + store.saveContact("alice@example.org", alice, true); + + // No one accepted yet: an empty set is already complete, so it may sync. + var (sync, tokens) = store.tokenSet(master); + expect(sync, 1); + expect(tokens, isEmpty); + + store.accept("alice@example.org", alice); + (sync, tokens) = store.tokenSet(master); + expect(sync, 1); + expect(tokens, [tokenFor(master, alice)]); + expect(store.accepted("alice@example.org")!.active, isTrue); + + store.block("alice@example.org"); + expect(store.accepted("alice@example.org")!.active, isFalse); + expect(store.tokenSet(master).$2, isEmpty); + // Re-accepting keeps the identity frozen at the original acceptance, + // so the token a correspondent already holds keeps working. + store.accept("alice@example.org", randomBytes(32)); + expect(store.accepted("alice@example.org")!.identity, alice); + + expect(() => store.block("bob@example.org"), throwsA(isA())); + + // A restored master must not silently replace the server's set. + store.setSyncOk(false); + (sync, tokens) = store.tokenSet(master); + expect(sync, 0); + expect(tokens, isEmpty); + + await store.storeIfNew( + "inbox", MailRecord("aa", randomBytes(64), receivedAt: 1, tier: tierMain)); + await store.storeIfNew("inbox", + MailRecord("bb", randomBytes(64), receivedAt: 2, tier: tierRequests)); + expect(store.listMessages("inbox").map((r) => r.id), ["aa"]); + expect(store.listMessages("requests").map((r) => r.id), ["bb"]); + expect(store.getMessage("requests", "bb"), isNotNull); + }); + test("v1 legacy export still imports without an identity", () async { final store = await freshStore("import-legacy-v1"); final summary = await store.importData({ diff --git a/test/vectors.json b/test/vectors.json index 61fb897..7acb656 100644 --- a/test/vectors.json +++ b/test/vectors.json @@ -190,7 +190,7 @@ "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", "time": 1730000000, "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", - "id": "b05d15a2ab5293164eda564de02a6901", + "id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553", "unpadded_plaintext_len": 143 }, "noise": {