feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention
This commit is contained in:
parent
eaaa3f2ede
commit
c693e6fcb9
27 changed files with 1190 additions and 592 deletions
|
|
@ -32,18 +32,14 @@ final accountProvider = Provider<SmolAddress?>((ref) {
|
||||||
return ref.watch(clientProvider).accountAddress();
|
return ref.watch(clientProvider).accountAddress();
|
||||||
});
|
});
|
||||||
|
|
||||||
class Folder extends Notifier<String> {
|
/// One tab (Inbox/Sent/Requests) can be on screen — and simultaneously kept
|
||||||
@override
|
/// alive off-screen by [TabBarView] for swiping — while another is active,
|
||||||
String build() => "inbox";
|
/// so each is parameterized by its own folder rather than sharing one
|
||||||
|
/// ambient "current folder" provider.
|
||||||
void select(String folder) => state = folder;
|
final messagesForFolderProvider =
|
||||||
}
|
Provider.family<List<MailRecord>, String>((ref, folder) {
|
||||||
|
|
||||||
final folderProvider = NotifierProvider<Folder, String>(Folder.new);
|
|
||||||
|
|
||||||
final messagesProvider = Provider<List<MailRecord>>((ref) {
|
|
||||||
ref.watch(revisionProvider);
|
ref.watch(revisionProvider);
|
||||||
return ref.watch(storeProvider).listMessages(ref.watch(folderProvider));
|
return ref.watch(storeProvider).listMessages(folder);
|
||||||
});
|
});
|
||||||
|
|
||||||
final unreadProvider = Provider<int>((ref) {
|
final unreadProvider = Provider<int>((ref) {
|
||||||
|
|
@ -51,6 +47,11 @@ final unreadProvider = Provider<int>((ref) {
|
||||||
return ref.watch(storeProvider).unreadCount();
|
return ref.watch(storeProvider).unreadCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final requestsUnreadProvider = Provider<int>((ref) {
|
||||||
|
ref.watch(revisionProvider);
|
||||||
|
return ref.watch(storeProvider).requestsUnreadCount();
|
||||||
|
});
|
||||||
|
|
||||||
final contactsProvider = Provider<List<(String, StoredContact)>>((ref) {
|
final contactsProvider = Provider<List<(String, StoredContact)>>((ref) {
|
||||||
ref.watch(revisionProvider);
|
ref.watch(revisionProvider);
|
||||||
return ref.watch(storeProvider).allContacts();
|
return ref.watch(storeProvider).allContacts();
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,14 @@ class AppRouter extends RootStackRouter {
|
||||||
guards: [HomeGuard(ref)],
|
guards: [HomeGuard(ref)],
|
||||||
),
|
),
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: InboxRoute.page,
|
page: HomeRoute.page,
|
||||||
path: "/inbox",
|
path: "/home",
|
||||||
guards: [IdentityGuard(ref)],
|
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(
|
AutoRoute(
|
||||||
page: MessageDetailRoute.page,
|
page: MessageDetailRoute.page,
|
||||||
|
|
@ -34,20 +39,10 @@ class AppRouter extends RootStackRouter {
|
||||||
path: "/compose",
|
path: "/compose",
|
||||||
guards: [IdentityGuard(ref)],
|
guards: [IdentityGuard(ref)],
|
||||||
),
|
),
|
||||||
AutoRoute(
|
|
||||||
page: ContactsRoute.page,
|
|
||||||
path: "/contacts",
|
|
||||||
guards: [IdentityGuard(ref)],
|
|
||||||
),
|
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: ContactDetailRoute.page,
|
page: ContactDetailRoute.page,
|
||||||
path: "/contact",
|
path: "/contact",
|
||||||
guards: [IdentityGuard(ref)],
|
guards: [IdentityGuard(ref)],
|
||||||
),
|
),
|
||||||
AutoRoute(
|
|
||||||
page: SettingsRoute.page,
|
|
||||||
path: "/settings",
|
|
||||||
guards: [IdentityGuard(ref)],
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,27 @@
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart' as _i8;
|
import 'package:auto_route/auto_route.dart' as _i9;
|
||||||
import 'package:flutter/material.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/compose_screen.dart' as _i1;
|
||||||
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
|
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
|
||||||
as _i2;
|
as _i2;
|
||||||
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
|
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'
|
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
|
||||||
as _i5;
|
as _i6;
|
||||||
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6;
|
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7;
|
||||||
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7;
|
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8;
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i1.ComposeScreen]
|
/// [_i1.ComposeScreen]
|
||||||
class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
|
class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
|
||||||
ComposeRoute({
|
ComposeRoute({
|
||||||
_i9.Key? key,
|
_i10.Key? key,
|
||||||
String? to,
|
String? to,
|
||||||
String? subject,
|
String? subject,
|
||||||
List<_i8.PageRouteInfo>? children,
|
List<_i9.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ComposeRoute.name,
|
ComposeRoute.name,
|
||||||
args: ComposeRouteArgs(key: key, to: to, subject: subject),
|
args: ComposeRouteArgs(key: key, to: to, subject: subject),
|
||||||
|
|
@ -38,7 +39,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
|
||||||
|
|
||||||
static const String name = 'ComposeRoute';
|
static const String name = 'ComposeRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i9.PageInfo page = _i9.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ComposeRouteArgs>(
|
final args = data.argsAs<ComposeRouteArgs>(
|
||||||
|
|
@ -56,7 +57,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
|
||||||
class ComposeRouteArgs {
|
class ComposeRouteArgs {
|
||||||
const ComposeRouteArgs({this.key, this.to, this.subject});
|
const ComposeRouteArgs({this.key, this.to, this.subject});
|
||||||
|
|
||||||
final _i9.Key? key;
|
final _i10.Key? key;
|
||||||
|
|
||||||
final String? to;
|
final String? to;
|
||||||
|
|
||||||
|
|
@ -80,11 +81,11 @@ class ComposeRouteArgs {
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i2.ContactDetailScreen]
|
/// [_i2.ContactDetailScreen]
|
||||||
class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
|
class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
|
||||||
ContactDetailRoute({
|
ContactDetailRoute({
|
||||||
_i9.Key? key,
|
_i10.Key? key,
|
||||||
required String address,
|
required String address,
|
||||||
List<_i8.PageRouteInfo>? children,
|
List<_i9.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ContactDetailRoute.name,
|
ContactDetailRoute.name,
|
||||||
args: ContactDetailRouteArgs(key: key, address: address),
|
args: ContactDetailRouteArgs(key: key, address: address),
|
||||||
|
|
@ -93,7 +94,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
|
||||||
|
|
||||||
static const String name = 'ContactDetailRoute';
|
static const String name = 'ContactDetailRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i9.PageInfo page = _i9.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ContactDetailRouteArgs>();
|
final args = data.argsAs<ContactDetailRouteArgs>();
|
||||||
|
|
@ -105,7 +106,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
|
||||||
class ContactDetailRouteArgs {
|
class ContactDetailRouteArgs {
|
||||||
const ContactDetailRouteArgs({this.key, required this.address});
|
const ContactDetailRouteArgs({this.key, required this.address});
|
||||||
|
|
||||||
final _i9.Key? key;
|
final _i10.Key? key;
|
||||||
|
|
||||||
final String address;
|
final String address;
|
||||||
|
|
||||||
|
|
@ -127,13 +128,13 @@ class ContactDetailRouteArgs {
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i3.ContactsScreen]
|
/// [_i3.ContactsScreen]
|
||||||
class ContactsRoute extends _i8.PageRouteInfo<void> {
|
class ContactsRoute extends _i9.PageRouteInfo<void> {
|
||||||
const ContactsRoute({List<_i8.PageRouteInfo>? children})
|
const ContactsRoute({List<_i9.PageRouteInfo>? children})
|
||||||
: super(ContactsRoute.name, initialChildren: children);
|
: super(ContactsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'ContactsRoute';
|
static const String name = 'ContactsRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i9.PageInfo page = _i9.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i3.ContactsScreen();
|
return const _i3.ContactsScreen();
|
||||||
|
|
@ -142,29 +143,45 @@ class ContactsRoute extends _i8.PageRouteInfo<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i4.InboxScreen]
|
/// [_i4.HomeScreen]
|
||||||
class InboxRoute extends _i8.PageRouteInfo<void> {
|
class HomeRoute extends _i9.PageRouteInfo<void> {
|
||||||
const InboxRoute({List<_i8.PageRouteInfo>? children})
|
const HomeRoute({List<_i9.PageRouteInfo>? children})
|
||||||
: super(InboxRoute.name, initialChildren: 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,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i4.InboxScreen();
|
return const _i4.HomeScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i5.MessageDetailScreen]
|
/// [_i5.InboxScreen]
|
||||||
class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
|
class InboxRoute extends _i9.PageRouteInfo<void> {
|
||||||
|
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<MessageDetailRouteArgs> {
|
||||||
MessageDetailRoute({
|
MessageDetailRoute({
|
||||||
_i9.Key? key,
|
_i10.Key? key,
|
||||||
required String folder,
|
required String folder,
|
||||||
required String id,
|
required String id,
|
||||||
List<_i8.PageRouteInfo>? children,
|
List<_i9.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
MessageDetailRoute.name,
|
MessageDetailRoute.name,
|
||||||
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
|
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
|
||||||
|
|
@ -173,11 +190,11 @@ class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
|
||||||
|
|
||||||
static const String name = 'MessageDetailRoute';
|
static const String name = 'MessageDetailRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i9.PageInfo page = _i9.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<MessageDetailRouteArgs>();
|
final args = data.argsAs<MessageDetailRouteArgs>();
|
||||||
return _i5.MessageDetailScreen(
|
return _i6.MessageDetailScreen(
|
||||||
key: args.key,
|
key: args.key,
|
||||||
folder: args.folder,
|
folder: args.folder,
|
||||||
id: args.id,
|
id: args.id,
|
||||||
|
|
@ -193,7 +210,7 @@ class MessageDetailRouteArgs {
|
||||||
required this.id,
|
required this.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
final _i9.Key? key;
|
final _i10.Key? key;
|
||||||
|
|
||||||
final String folder;
|
final String folder;
|
||||||
|
|
||||||
|
|
@ -216,33 +233,33 @@ class MessageDetailRouteArgs {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i6.OnboardingScreen]
|
/// [_i7.OnboardingScreen]
|
||||||
class OnboardingRoute extends _i8.PageRouteInfo<void> {
|
class OnboardingRoute extends _i9.PageRouteInfo<void> {
|
||||||
const OnboardingRoute({List<_i8.PageRouteInfo>? children})
|
const OnboardingRoute({List<_i9.PageRouteInfo>? children})
|
||||||
: super(OnboardingRoute.name, initialChildren: children);
|
: super(OnboardingRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'OnboardingRoute';
|
static const String name = 'OnboardingRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i9.PageInfo page = _i9.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i6.OnboardingScreen();
|
return const _i7.OnboardingScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i7.SettingsScreen]
|
/// [_i8.SettingsScreen]
|
||||||
class SettingsRoute extends _i8.PageRouteInfo<void> {
|
class SettingsRoute extends _i9.PageRouteInfo<void> {
|
||||||
const SettingsRoute({List<_i8.PageRouteInfo>? children})
|
const SettingsRoute({List<_i9.PageRouteInfo>? children})
|
||||||
: super(SettingsRoute.name, initialChildren: children);
|
: super(SettingsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'SettingsRoute';
|
static const String name = 'SettingsRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i9.PageInfo page = _i9.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i7.SettingsScreen();
|
return const _i8.SettingsScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class HomeGuard extends AutoRouteGuard {
|
||||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||||
if (ref.read(identityProvider) != null &&
|
if (ref.read(identityProvider) != null &&
|
||||||
ref.read(accountProvider) != null) {
|
ref.read(accountProvider) != null) {
|
||||||
router.replace(InboxRoute());
|
router.replace(HomeRoute());
|
||||||
} else {
|
} else {
|
||||||
resolver.next(true);
|
resolver.next(true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,34 @@ class ContactDetailScreen extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||||
bool resolving = false;
|
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<void> _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<void> _reResolve() async {
|
Future<void> _reResolve() async {
|
||||||
final client = ref.read(clientProvider);
|
final client = ref.read(clientProvider);
|
||||||
|
|
@ -54,7 +82,11 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.address)),
|
appBar: AppBar(title: Text(widget.address)),
|
||||||
|
|
@ -69,6 +101,29 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||||
? "verified key"
|
? "verified key"
|
||||||
: "key pinned on first use"),
|
: "key pinned on first use"),
|
||||||
const SizedBox(height: 20),
|
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, "fingerprint", fingerprint(contact.key)),
|
||||||
_field(context, "public key", b32encode(contact.key)),
|
_field(context, "public key", b32encode(contact.key)),
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ class ContactsScreen extends ConsumerWidget {
|
||||||
itemCount: contacts.length,
|
itemCount: contacts.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final (address, contact) = contacts[i];
|
final (address, contact) = contacts[i];
|
||||||
|
final accepted = ref.read(storeProvider).accepted(address);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
|
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
|
||||||
onTap: () => AutoRouter.of(context)
|
onTap: () => AutoRouter.of(context)
|
||||||
|
|
@ -93,12 +94,15 @@ class ContactsScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
trailing: Wrap(
|
trailing: Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_chip(context, contact.verified ? "verified" : "tofu",
|
_chip(context, contact.verified ? "verified" : "tofu",
|
||||||
ok: contact.verified),
|
ok: contact.verified),
|
||||||
if (contact.history.isNotEmpty)
|
if (contact.history.isNotEmpty)
|
||||||
_chip(context, "${contact.history.length} previous",
|
_chip(context, "${contact.history.length} previous",
|
||||||
ok: false),
|
ok: false),
|
||||||
|
if (accepted != null && accepted.active)
|
||||||
|
_chip(context, "accepted", ok: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
86
lib/presentation/screens/home_screen.dart
Normal file
86
lib/presentation/screens/home_screen.dart
Normal file
|
|
@ -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<AppColors>()!;
|
||||||
|
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,18 +4,46 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
|
||||||
import "package:smol_mail/data/providers/providers.dart";
|
import "package:smol_mail/data/providers/providers.dart";
|
||||||
import "package:smol_mail/presentation/routes/app_router.gr.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/image_banner.dart";
|
||||||
import "package:smol_mail/presentation/widgets/message/message_list.dart";
|
import "package:smol_mail/presentation/widgets/message/message_list.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||||
import "package:smol_mail/smol/errors.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
|
/// 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()
|
@RoutePage()
|
||||||
class InboxScreen extends ConsumerWidget {
|
class InboxScreen extends ConsumerStatefulWidget {
|
||||||
const InboxScreen({super.key});
|
const InboxScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<InboxScreen> createState() => _InboxScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InboxScreenState extends ConsumerState<InboxScreen>
|
||||||
|
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<void> _fetch(BuildContext context, WidgetRef ref) async {
|
Future<void> _fetch(BuildContext context, WidgetRef ref) async {
|
||||||
final client = ref.read(clientProvider);
|
final client = ref.read(clientProvider);
|
||||||
try {
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final messages = ref.watch(messagesProvider);
|
final unread = ref.watch(unreadProvider);
|
||||||
final folder = ref.watch(folderProvider);
|
final requestsUnread = ref.watch(requestsUnreadProvider);
|
||||||
|
final canFetch = _folders[_tabController.index] != "sent";
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(folder == "inbox" ? "Inbox" : "Sent"),
|
title: const Text("kirakira"),
|
||||||
actions: [
|
actions: [
|
||||||
if (folder == "inbox")
|
if (canFetch)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Fetch",
|
tooltip: "Fetch",
|
||||||
onPressed: () => _fetch(context, ref),
|
onPressed: () => _fetch(context, ref),
|
||||||
icon: const Icon(Icons.cloud_download),
|
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(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
|
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
|
||||||
child: const Icon(Icons.edit),
|
child: const Icon(Icons.edit),
|
||||||
),
|
),
|
||||||
body: messages.isEmpty
|
body: TabBarView(
|
||||||
? RefreshIndicator(
|
controller: _tabController,
|
||||||
onRefresh: () => folder == "inbox"
|
children: [
|
||||||
? _fetch(context, ref)
|
for (final folder in _folders) _folderView(context, ref, folder),
|
||||||
: 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(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/presentation/theme/app_colors.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||||
import "package:smol_mail/smol/errors.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
|
/// Deleting removes the local copy, and the server's too if "leave mail on
|
||||||
/// removes only the local sealed copy.
|
/// server" left one there to remove (settings; SPEC.md §10).
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class MessageDetailScreen extends ConsumerWidget {
|
class MessageDetailScreen extends ConsumerWidget {
|
||||||
final String folder;
|
final String folder;
|
||||||
|
|
@ -24,8 +25,14 @@ class MessageDetailScreen extends ConsumerWidget {
|
||||||
required this.id,
|
required this.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _delete(BuildContext context, WidgetRef ref) async {
|
Future<void> _delete(
|
||||||
await ref.read(storeProvider).deleteMessage(folder, id);
|
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();
|
ref.read(revisionProvider.notifier).bump();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AutoRouter.of(context).pop();
|
AutoRouter.of(context).pop();
|
||||||
|
|
@ -109,8 +116,8 @@ class MessageDetailScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Delete local copy",
|
tooltip: "Delete",
|
||||||
onPressed: () => _delete(context, ref),
|
onPressed: () => _delete(context, ref, record),
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -118,6 +125,7 @@ class MessageDetailScreen extends ConsumerWidget {
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
child: MessageView(
|
child: MessageView(
|
||||||
|
folder: folder,
|
||||||
record: record,
|
record: record,
|
||||||
opened: opened,
|
opened: opened,
|
||||||
onNameSender: opened.sender == null
|
onNameSender: opened.sender == null
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,17 @@ enum _Step { welcome, backup, restore, register }
|
||||||
|
|
||||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
_Step step = _Step.welcome;
|
_Step step = _Step.welcome;
|
||||||
Uint8List? createdSeed;
|
Uint8List? createdMaster;
|
||||||
bool busy = false;
|
bool busy = false;
|
||||||
// The register step doubles as "pin a key to finish recalling" when a
|
// 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
|
// restore's recall can't proceed without one yet — same fields, different
|
||||||
// framing and default action, not the generic "register a new address" copy.
|
// framing and default action, not the generic "register a new address" copy.
|
||||||
bool recallIntent = false;
|
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 seedController = TextEditingController();
|
||||||
final restoreAddressController = TextEditingController();
|
final restoreAddressController = TextEditingController();
|
||||||
|
|
@ -62,11 +67,11 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reaching onboarding at all means HomeGuard already found no complete
|
// Reaching onboarding at all means HomeGuard already found no complete
|
||||||
// identity+account, so a seed still sitting in the store here can only be
|
// identity+account, so a master still sitting in the store here can only be
|
||||||
// an abandoned attempt from earlier in this same flow (wrong seed, failed
|
// an abandoned attempt from earlier in this same flow (wrong master, failed
|
||||||
// recall, "Back") — safe to replace rather than reject.
|
// recall, "Back") — safe to replace rather than reject.
|
||||||
// wipe() is fire-and-forget here, like every other store write in this
|
// 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
|
// synchronously and persists to disk in the background, so the identity
|
||||||
// check right after is already consistent without awaiting the write.
|
// check right after is already consistent without awaiting the write.
|
||||||
void _clearAbandonedIdentity() {
|
void _clearAbandonedIdentity() {
|
||||||
|
|
@ -80,9 +85,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
_clearAbandonedIdentity();
|
_clearAbandonedIdentity();
|
||||||
final client = ref.read(clientProvider);
|
final client = ref.read(clientProvider);
|
||||||
try {
|
try {
|
||||||
final fresh = client.createIdentity();
|
final master = client.createIdentity();
|
||||||
setState(() {
|
setState(() {
|
||||||
createdSeed = fresh.seed;
|
createdMaster = master;
|
||||||
step = _Step.backup;
|
step = _Step.backup;
|
||||||
});
|
});
|
||||||
} on Exception catch (err) {
|
} on Exception catch (err) {
|
||||||
|
|
@ -94,74 +99,49 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
setState(() => step = _Step.restore);
|
setState(() => step = _Step.restore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restoring must succeed on its own even if recall fails (server not
|
// §2: a master alone does not say which rotation index a server bound, so
|
||||||
// pinned yet, offline, typo) — recall can always be retried from the
|
// restoring resolves the address and walks indices to find it — restore
|
||||||
// register step or settings afterward.
|
// always ends in a recall, never a bare local step.
|
||||||
void _submitRestore() {
|
Future<void> _submitRestore() async {
|
||||||
_clearAbandonedIdentity();
|
|
||||||
final client = ref.read(clientProvider);
|
|
||||||
try {
|
|
||||||
client.restoreIdentity(seedController.text);
|
|
||||||
} on Exception catch (err) {
|
|
||||||
_showError(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final addressText = restoreAddressController.text.trim();
|
final addressText = restoreAddressController.text.trim();
|
||||||
if (addressText.isEmpty) {
|
if (addressText.isEmpty) {
|
||||||
setState(() => step = _Step.register);
|
_showError(const SmolError("enter the address this master was registered under"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_clearAbandonedIdentity();
|
||||||
// The register step has its own address field (it also needs a server
|
// The register step has its own address field (it also needs a server
|
||||||
// key, which restore doesn't collect) — carry over what was already
|
// key, which restore doesn't collect) — carry over what was already
|
||||||
// typed rather than making the user re-enter it.
|
// typed rather than making the user re-enter it.
|
||||||
addressController.text = addressText;
|
addressController.text = addressText;
|
||||||
final SmolAddress addr;
|
final client = ref.read(clientProvider);
|
||||||
|
setState(() => busy = true);
|
||||||
try {
|
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) {
|
} on Exception catch (err) {
|
||||||
// A genuine typo, distinct from the merely-unpinned case below — still
|
if (!mounted) return;
|
||||||
// worth an error, but land on the same recall-oriented step to fix it.
|
// 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);
|
_showError(err);
|
||||||
setState(() {
|
setState(() {
|
||||||
recallIntent = true;
|
recallIntent = true;
|
||||||
|
restoreIntent = true;
|
||||||
step = _Step.register;
|
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<void> _submitRegistration() async {
|
Future<void> _submitRegistration() async {
|
||||||
await _submit(recall: false);
|
await _submit(recall: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restoring a seed on a new device knows the identity but not the address it
|
// Recall binds a restored or already-created identity to its registered
|
||||||
// was registered under; recall binds it without re-REGISTER.
|
// address without re-REGISTER.
|
||||||
Future<void> _submitRecall() async {
|
Future<void> _submitRecall() async {
|
||||||
await _submit(recall: true);
|
await _submit(recall: true);
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +158,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
if (serverKey.isNotEmpty) {
|
if (serverKey.isNotEmpty) {
|
||||||
client.pinServer(address.host, serverKey);
|
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);
|
await client.recallAccount(address.short);
|
||||||
} else {
|
} else {
|
||||||
await client.registerAccount(address.short,
|
await client.registerAccount(address.short,
|
||||||
|
|
@ -186,7 +168,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(revisionProvider.notifier).bump();
|
ref.read(revisionProvider.notifier).bump();
|
||||||
AutoRouter.of(context).replace(InboxRoute());
|
AutoRouter.of(context).replace(HomeRoute());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_showError(err);
|
_showError(err);
|
||||||
|
|
@ -248,20 +230,21 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _backup(BuildContext context) {
|
Widget _backup(BuildContext context) {
|
||||||
final seed = createdSeed!;
|
final master = createdMaster!;
|
||||||
final seedHex = hex(seed);
|
final masterHex = hex(master);
|
||||||
|
final publicKey = ref.read(clientProvider).identity!.publicKey;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}",
|
"fingerprint:\n${fingerprint(publicKey)}",
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SelectableText(
|
SelectableText(
|
||||||
seedHex,
|
masterHex,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall!
|
.bodySmall!
|
||||||
|
|
@ -269,14 +252,14 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await Clipboard.setData(ClipboardData(text: seedHex));
|
await Clipboard.setData(ClipboardData(text: masterHex));
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
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),
|
const SizedBox(height: 40),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
|
|
@ -292,7 +275,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(height: 20),
|
||||||
TextField(
|
TextField(
|
||||||
controller: seedController,
|
controller: seedController,
|
||||||
|
|
@ -306,7 +289,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
TextField(
|
TextField(
|
||||||
controller: restoreAddressController,
|
controller: restoreAddressController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Address (if already registered)",
|
labelText: "Address this master was registered under",
|
||||||
hintText: "alice@example.org",
|
hintText: "alice@example.org",
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||||
|
|
@ -314,13 +297,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
onPressed: _submitRestore,
|
onPressed: busy ? () {} : _submitRestore,
|
||||||
child: const Text("Restore"),
|
child: busy ? const SmallLoadingSpinner() : const Text("Restore"),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 5),
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
text: "Back",
|
text: "Back",
|
||||||
onPressed: () => setState(() => step = _Step.welcome),
|
onPressed: busy ? () {} : () => setState(() => step = _Step.welcome),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
|
|
@ -377,14 +360,18 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
? const SmallLoadingSpinner()
|
? const SmallLoadingSpinner()
|
||||||
: Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
|
: Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
|
||||||
),
|
),
|
||||||
TextButton(
|
// Restoring a master has no well-defined "register instead" fallback
|
||||||
onPressed: busy
|
// until its rotation index is resolved (§2), so that escape hatch is
|
||||||
? () {}
|
// only offered from the fresh-identity path.
|
||||||
: (recallIntent ? _submitRegistration : _submitRecall),
|
if (!restoreIntent)
|
||||||
child: Text(recallIntent
|
TextButton(
|
||||||
? "Register a new address instead"
|
onPressed: busy
|
||||||
: "Already registered? Recall"),
|
? () {}
|
||||||
),
|
: (recallIntent ? _submitRegistration : _submitRecall),
|
||||||
|
child: Text(recallIntent
|
||||||
|
? "Register a new address instead"
|
||||||
|
: "Already registered? Recall"),
|
||||||
|
),
|
||||||
const SizedBox(height: 80),
|
const SizedBox(height: 80),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// The export payload is sealed now (v2), so the counts for this
|
// The export payload is sealed now (v2), so the counts for this
|
||||||
// notice come straight from the store rather than the ciphertext.
|
// notice come straight from the store rather than the ciphertext.
|
||||||
final messages =
|
final messages = store.listMessages("inbox").length +
|
||||||
store.listMessages("inbox").length + store.listMessages("sent").length;
|
store.listMessages("requests").length +
|
||||||
|
store.listMessages("sent").length;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text("exported $messages messages, "
|
SnackBar(content: Text("exported $messages messages, "
|
||||||
"${store.allContacts().length} contacts, "
|
"${store.allContacts().length} contacts, "
|
||||||
|
|
@ -207,7 +208,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
final account = ref.watch(accountProvider);
|
final account = ref.watch(accountProvider);
|
||||||
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
|
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 share = account?.uri(me.publicKey);
|
||||||
final pins = store.allPins();
|
final pins = store.allPins();
|
||||||
|
|
||||||
|
|
@ -249,12 +250,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
context,
|
context,
|
||||||
borderColor: Theme.of(context).colorScheme.error,
|
borderColor: Theme.of(context).colorScheme.error,
|
||||||
children: [
|
children: [
|
||||||
Text("seed", style: Theme.of(context).textTheme.labelSmall),
|
Text("master secret", style: Theme.of(context).textTheme.labelSmall),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
seedShown ? seedHex : "•" * 64,
|
seedShown ? masterHex : "•" * 64,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -266,12 +267,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
Clipboard.setData(ClipboardData(text: seedHex)),
|
Clipboard.setData(ClipboardData(text: masterHex)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"back this seed up; it is the only secret",
|
"back this master secret up; it is the only secret",
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.labelSmall!
|
.labelSmall!
|
||||||
|
|
@ -279,12 +280,31 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
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),
|
style: Theme.of(context).textTheme.labelSmall),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
const Divider(height: 1),
|
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),
|
const SizedBox(height: 20),
|
||||||
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
|
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
|
||||||
4
lib/presentation/theme/breakpoints.dart
Normal file
4
lib/presentation/theme/breakpoints.dart
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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<AppColors>()!.cardFill,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 60),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
IdentityHeader(address: address, fingerprint: fp),
|
|
||||||
const SizedBox(height: 50),
|
|
||||||
const DrawerList(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: <Widget>[
|
|
||||||
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());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<AppColors>()!;
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: <Widget>[
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,21 +5,23 @@ import "package:smol_mail/data/providers/providers.dart";
|
||||||
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
|
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
|
||||||
|
|
||||||
class MessageList extends ConsumerWidget {
|
class MessageList extends ConsumerWidget {
|
||||||
|
final String folder;
|
||||||
final Future<void> Function() onRefresh;
|
final Future<void> Function() onRefresh;
|
||||||
|
|
||||||
const MessageList({
|
const MessageList({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.folder,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final messages = ref.watch(messagesProvider);
|
final messages = ref.watch(messagesForFolderProvider(folder));
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: onRefresh,
|
onRefresh: onRefresh,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemBuilder: (ctx, i) => MessageTile(record: messages[i]),
|
itemBuilder: (ctx, i) => MessageTile(folder: folder, record: messages[i]),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ import "package:smol_mail/smol/proto.dart";
|
||||||
import "package:smol_mail/smol/store.dart";
|
import "package:smol_mail/smol/store.dart";
|
||||||
|
|
||||||
class MessageTile extends ConsumerWidget {
|
class MessageTile extends ConsumerWidget {
|
||||||
|
final String folder;
|
||||||
final MailRecord record;
|
final MailRecord record;
|
||||||
|
|
||||||
const MessageTile({super.key, required this.record});
|
const MessageTile({super.key, required this.folder, required this.record});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final folder = ref.watch(folderProvider);
|
|
||||||
final store = ref.watch(storeProvider);
|
final store = ref.watch(storeProvider);
|
||||||
final opened = ref.read(clientProvider).describe(record);
|
final opened = ref.read(clientProvider).describe(record);
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
|
@ -31,7 +31,7 @@ class MessageTile extends ConsumerWidget {
|
||||||
} else {
|
} else {
|
||||||
who = "?";
|
who = "?";
|
||||||
}
|
}
|
||||||
final unread = folder == "inbox" && !store.isRead(record.id);
|
final unread = folder != "sent" && !store.isRead(record.id);
|
||||||
final errorText = opened.error != null ? "<unreadable>" : null;
|
final errorText = opened.error != null ? "<unreadable>" : null;
|
||||||
final subject =
|
final subject =
|
||||||
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
|
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
|
||||||
|
|
@ -44,7 +44,7 @@ class MessageTile extends ConsumerWidget {
|
||||||
splashColor: appColors.highlight,
|
splashColor: appColors.highlight,
|
||||||
focusColor: appColors.cardFill,
|
focusColor: appColors.cardFill,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (folder == "inbox") {
|
if (folder != "sent") {
|
||||||
store.markRead(record.id);
|
store.markRead(record.id);
|
||||||
ref.read(revisionProvider.notifier).bump();
|
ref.read(revisionProvider.notifier).bump();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ import "package:smol_mail/smol/store.dart";
|
||||||
/// The opened message: trust row (fingerprint and how the sender's key is
|
/// 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.
|
/// known), frontmatter fields, and the plain body — smol mail has no HTML.
|
||||||
class MessageView extends ConsumerWidget {
|
class MessageView extends ConsumerWidget {
|
||||||
|
final String folder;
|
||||||
final MailRecord record;
|
final MailRecord record;
|
||||||
final OpenedRecord opened;
|
final OpenedRecord opened;
|
||||||
final void Function(Uint8List senderKey)? onNameSender;
|
final void Function(Uint8List senderKey)? onNameSender;
|
||||||
|
|
||||||
const MessageView({
|
const MessageView({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.folder,
|
||||||
required this.record,
|
required this.record,
|
||||||
required this.opened,
|
required this.opened,
|
||||||
this.onNameSender,
|
this.onNameSender,
|
||||||
|
|
@ -38,18 +40,19 @@ class MessageView extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final folder = ref.watch(folderProvider);
|
|
||||||
final store = ref.watch(storeProvider);
|
final store = ref.watch(storeProvider);
|
||||||
final client = ref.read(clientProvider);
|
final client = ref.read(clientProvider);
|
||||||
|
|
||||||
// Sent copies carry no signature from a third party; the trust row then
|
// Sent copies carry no signature from a third party; the trust row then
|
||||||
// just says what they are.
|
// just says what they are.
|
||||||
final isSent = folder == "sent";
|
final isSent = folder == "sent";
|
||||||
|
final isRequest = folder == "requests";
|
||||||
final senderKey = opened.sender;
|
final senderKey = opened.sender;
|
||||||
final known = senderKey == null ? null : store.addressForKey(senderKey);
|
final known = senderKey == null ? null : store.addressForKey(senderKey);
|
||||||
final contact = known == null ? null : store.contact(known);
|
final contact = known == null ? null : store.contact(known);
|
||||||
final verifiedSender = contact?.verified ?? false;
|
final verifiedSender = contact?.verified ?? false;
|
||||||
final replyTo = isSent ? null : client.replyAddress(opened);
|
final replyTo = isSent ? null : client.replyAddress(opened);
|
||||||
|
final accepted = known == null ? null : store.accepted(known);
|
||||||
|
|
||||||
final subject =
|
final subject =
|
||||||
opened.subject.isEmpty ? "(no subject)" : opened.subject;
|
opened.subject.isEmpty ? "(no subject)" : opened.subject;
|
||||||
|
|
@ -69,6 +72,10 @@ class MessageView extends ConsumerWidget {
|
||||||
: "key bound to no address",
|
: "key bound to no address",
|
||||||
alert: known == null,
|
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) ...[
|
if (!isSent && senderKey != null) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
|
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
|
||||||
|
|
@ -78,7 +85,7 @@ class MessageView extends ConsumerWidget {
|
||||||
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
|
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
|
||||||
_field(context, "Date", formatTime(opened.time)),
|
_field(context, "Date", formatTime(opened.time)),
|
||||||
for (final entry in opened.fields.entries)
|
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),
|
_field(context, entry.key, entry.value),
|
||||||
if (!isSent && replyTo != null)
|
if (!isSent && replyTo != null)
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -114,6 +121,27 @@ class MessageView extends ConsumerWidget {
|
||||||
child: const Text("Name this sender"),
|
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 SizedBox(height: 10),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class OpenedRecord {
|
||||||
const OpenedRecord(this.id,
|
const OpenedRecord(this.id,
|
||||||
{this.sender, this.time, this.fields = const {}, this.body = "", this.error});
|
{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 {
|
class SmolClient {
|
||||||
|
|
@ -58,6 +58,8 @@ class SmolClient {
|
||||||
|
|
||||||
SmolIdentity? get identity => store.identity();
|
SmolIdentity? get identity => store.identity();
|
||||||
|
|
||||||
|
Uint8List? get master => store.master();
|
||||||
|
|
||||||
SmolAddress? accountAddress() {
|
SmolAddress? accountAddress() {
|
||||||
final account = store.account();
|
final account = store.account();
|
||||||
if (account == null) return null;
|
if (account == null) return null;
|
||||||
|
|
@ -91,25 +93,51 @@ class SmolClient {
|
||||||
|
|
||||||
// --- identity setup ------------------------------------------------------------
|
// --- identity setup ------------------------------------------------------------
|
||||||
|
|
||||||
SmolIdentity createIdentity() {
|
/// A fresh master secret at rotation index 0. Only this local step; nothing
|
||||||
final fresh = newIdentity();
|
/// is sent until [registerAccount].
|
||||||
store.setIdentity(fresh.seed);
|
Uint8List createIdentity() {
|
||||||
|
final fresh = randomBytes(keyLen);
|
||||||
|
store.setMaster(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
SmolIdentity restoreIdentity(String seedHex) {
|
/// §2: a master alone does not say which rotation index a server has bound,
|
||||||
Uint8List seed;
|
/// 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<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
|
||||||
|
Uint8List master;
|
||||||
try {
|
try {
|
||||||
seed = unhex(seedHex.trim());
|
master = unhex(masterHex.trim());
|
||||||
} on Exception {
|
} on Exception {
|
||||||
throw const SmolError("seed must be 64 hex characters");
|
throw const SmolError("master must be 64 hex characters");
|
||||||
}
|
}
|
||||||
if (seed.length != keyLen) {
|
if (master.length != keyLen) {
|
||||||
throw SmolError("seed is ${seed.length} bytes, expected $keyLen");
|
throw SmolError("master is ${master.length} bytes, expected $keyLen");
|
||||||
}
|
}
|
||||||
final restored = identityFromSeed(seed);
|
final addr = parseAddress(addressText);
|
||||||
store.setIdentity(seed);
|
final opened = await connect(addr, requirePin: true);
|
||||||
return restored;
|
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) {
|
void pinServer(String host, String keyB32) {
|
||||||
|
|
@ -126,7 +154,7 @@ class SmolClient {
|
||||||
final addr = parseAddress(addressText);
|
final addr = parseAddress(addressText);
|
||||||
final opened = await connect(addr, requirePin: true);
|
final opened = await connect(addr, requirePin: true);
|
||||||
try {
|
try {
|
||||||
await registerOp(opened.session, addr.user, me,
|
await registerOp(opened.session, opened.serverStatic, addr.user, me,
|
||||||
RegisterOptions(token: token));
|
RegisterOptions(token: token));
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.wire.close();
|
opened.session.wire.close();
|
||||||
|
|
@ -134,7 +162,7 @@ class SmolClient {
|
||||||
store.setAccount(addr);
|
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
|
/// address a *different device* registered it under — "account" is
|
||||||
/// local-only state, never asked of the server. This binds it without
|
/// 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
|
/// REGISTER: RESOLVE the address and require it name this exact key, so a
|
||||||
|
|
@ -162,46 +190,178 @@ class SmolClient {
|
||||||
|
|
||||||
Future<FetchSummary> fetch() async {
|
Future<FetchSummary> fetch() async {
|
||||||
final me = identity;
|
final me = identity;
|
||||||
|
final master = this.master;
|
||||||
final addr = accountAddress();
|
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) {
|
if (addr == null) {
|
||||||
throw const SmolError("not registered; register an address first");
|
throw const SmolError("not registered; register an address first");
|
||||||
}
|
}
|
||||||
var stored = 0;
|
var stored = 0;
|
||||||
final rejected = <String>[];
|
final rejected = <String>[];
|
||||||
|
// §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);
|
final opened = await connect(addr, requirePin: true);
|
||||||
try {
|
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) {
|
while (true) {
|
||||||
final records = await fetchOp(opened.session);
|
final records = await fetchOp(opened.session, afterTime, afterId);
|
||||||
if (records.isEmpty) break;
|
if (records.isEmpty) break;
|
||||||
final acked = <Uint8List>[];
|
final acked = <Uint8List>[];
|
||||||
for (final record in records) {
|
for (final record in records) {
|
||||||
|
afterTime = record.receivedAt;
|
||||||
|
afterId = record.id;
|
||||||
|
OpenedMessage msg;
|
||||||
try {
|
try {
|
||||||
if (!timingSafeEqual(messageId(record.envelope), record.id)) {
|
if (!timingSafeEqual(messageId(record.envelope), record.id)) {
|
||||||
throw const SmolError("id does not match the envelope");
|
throw const SmolError("id does not match the envelope");
|
||||||
}
|
}
|
||||||
unseal(store.identities(), record.envelope);
|
msg = unseal(store.identities(), record.envelope);
|
||||||
} on SmolError catch (err) {
|
} on SmolError catch (err) {
|
||||||
// Left on the server rather than destroyed, so a client-side bug
|
// Left on the server rather than destroyed, so a client-side bug
|
||||||
// cannot lose mail.
|
// cannot lose mail.
|
||||||
rejected.add("${hex(record.id)}: ${err.message}");
|
rejected.add("${hex(record.id)}: ${err.message}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope,
|
final fresh = await store.storeIfNew(
|
||||||
receivedAt: record.receivedAt));
|
"inbox",
|
||||||
if (fresh != null) stored++;
|
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);
|
acked.add(record.id);
|
||||||
}
|
}
|
||||||
if (acked.isEmpty) break;
|
if (leaveOnServer) {
|
||||||
await deleteOp(opened.session, acked);
|
// 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 {
|
} finally {
|
||||||
opened.session.wire.close();
|
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);
|
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<void> 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<int> 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<int> blockContact(String address) async {
|
||||||
|
store.block(address);
|
||||||
|
return _pushTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _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 -----------------------------------------------------------
|
// --- compose and send -----------------------------------------------------------
|
||||||
|
|
||||||
// Prefer a key we already trust; fall back to RESOLVE with trust on first
|
// Prefer a key we already trust; fall back to RESOLVE with trust on first
|
||||||
|
|
@ -227,7 +387,8 @@ class SmolClient {
|
||||||
Future<String> send(String toText, String subject, String body,
|
Future<String> send(String toText, String subject, String body,
|
||||||
{String? replyTo, bool anonymous = false}) async {
|
{String? replyTo, bool anonymous = false}) async {
|
||||||
final me = identity;
|
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 addr = parseAddress(toText);
|
||||||
final recipient = await resolveRecipient(addr);
|
final recipient = await resolveRecipient(addr);
|
||||||
final account = accountAddress();
|
final account = accountAddress();
|
||||||
|
|
@ -237,12 +398,21 @@ class SmolClient {
|
||||||
if (account != null && !anonymous) {
|
if (account != null && !anonymous) {
|
||||||
fields["Reply-To"] = account.uri(me.publicKey);
|
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(
|
final bodyBytes = utf8Bytes(buildFrontmatter(
|
||||||
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
|
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
|
||||||
final envelope = seal(me, recipient, bodyBytes);
|
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);
|
final opened = await connect(addr, requirePin: false);
|
||||||
try {
|
try {
|
||||||
await sendOp(opened.session, envelope);
|
await sendOp(opened.session, envelope, mac: mac);
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.wire.close();
|
opened.session.wire.close();
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +463,7 @@ class SmolClient {
|
||||||
/// full smol:// URI whose key matches the signer (§5.7); anything else is
|
/// full smol:// URI whose key matches the signer (§5.7); anything else is
|
||||||
/// ordinary text.
|
/// ordinary text.
|
||||||
SmolAddress? replyAddress(OpenedRecord opened) {
|
SmolAddress? replyAddress(OpenedRecord opened) {
|
||||||
final claim = opened.fields["Reply-To"];
|
final claim = opened.fields["reply-to"];
|
||||||
if (claim == null || opened.sender == null) return null;
|
if (claim == null || opened.sender == null) return null;
|
||||||
try {
|
try {
|
||||||
final parsed = parseAddress(claim);
|
final parsed = parseAddress(claim);
|
||||||
|
|
@ -370,7 +540,7 @@ class SmolClient {
|
||||||
if (timingSafeEqual(known.key, resolved.identity)) {
|
if (timingSafeEqual(known.key, resolved.identity)) {
|
||||||
return RefreshOutcome("${addr.short}: key unchanged", false);
|
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);
|
store.saveContact(addr.short, resolved.identity, known.verified);
|
||||||
return RefreshOutcome(
|
return RefreshOutcome(
|
||||||
"${addr.short} rotated its key; a signed chain confirms it.\n"
|
"${addr.short} rotated its key; a signed chain confirms it.\n"
|
||||||
|
|
@ -395,25 +565,27 @@ class SmolClient {
|
||||||
|
|
||||||
// --- rotation -----------------------------------------------------------------
|
// --- rotation -----------------------------------------------------------------
|
||||||
|
|
||||||
// §7: rotate to a fresh seed and rebind the account with a signed
|
// §7: rotate to the next index's derived key and rebind the account with a
|
||||||
// certificate. The old seed is kept by the store, since mail sealed to it
|
// signed certificate. The superseded key stays derivable from the master,
|
||||||
// stays readable with nothing else.
|
// since mail sealed to it stays readable with nothing else.
|
||||||
Future<SmolIdentity> rotateIdentity() async {
|
Future<SmolIdentity> rotateIdentity() async {
|
||||||
final me = identity;
|
final me = identity;
|
||||||
|
final master = this.master;
|
||||||
final addr = accountAddress();
|
final addr = accountAddress();
|
||||||
if (me == null || addr == null) {
|
if (me == null || master == null || addr == null) {
|
||||||
throw const SmolError("rotate needs a registered account");
|
throw const SmolError("rotate needs a registered account");
|
||||||
}
|
}
|
||||||
final fresh = newIdentity();
|
final freshSeed = identitySeed(master, store.rotations() + 1);
|
||||||
final cert = makeCert(me, fresh.seed);
|
final fresh = identityFromSeed(freshSeed);
|
||||||
|
final cert = makeCert(addr.user, me, freshSeed);
|
||||||
final opened = await connect(addr, requirePin: true);
|
final opened = await connect(addr, requirePin: true);
|
||||||
try {
|
try {
|
||||||
await registerOp(opened.session, addr.user, fresh,
|
await registerOp(opened.session, opened.serverStatic, addr.user, fresh,
|
||||||
RegisterOptions(cert: cert));
|
RegisterOptions(cert: cert));
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.wire.close();
|
opened.session.wire.close();
|
||||||
}
|
}
|
||||||
store.rotateIdentity(fresh.seed);
|
store.advanceRotation();
|
||||||
_openedCache.clear();
|
_openedCache.clear();
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
|
// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed
|
||||||
// signed envelopes, body frontmatter, key rotation, and the framed request
|
// and signed envelopes, body frontmatter, key rotation, accept tokens, and
|
||||||
// and response bodies of the five operations.
|
// the framed request and response bodies of the five operations.
|
||||||
|
|
||||||
import "dart:math";
|
import "dart:math";
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
|
|
@ -10,9 +10,11 @@ import "package:smol_mail/smol/errors.dart";
|
||||||
import "package:smol_mail/smol/noise.dart";
|
import "package:smol_mail/smol/noise.dart";
|
||||||
|
|
||||||
const defaultPort = 1961;
|
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 maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024;
|
||||||
const envelopeHeader = 69, payloadHeader = 45, maxChain = 16;
|
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 _frontmatterMax = 4096, _frontmatterKeys = 64;
|
||||||
|
|
||||||
const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03,
|
const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03,
|
||||||
|
|
@ -32,6 +34,10 @@ final _label = (
|
||||||
msg: utf8Bytes("smolmail/1 msg"),
|
msg: utf8Bytes("smolmail/1 msg"),
|
||||||
id: utf8Bytes("smolmail/1 id"),
|
id: utf8Bytes("smolmail/1 id"),
|
||||||
rotate: utf8Bytes("smolmail/1 rotate"),
|
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 ---------------------------------------------------------
|
// --- encoding helpers ---------------------------------------------------------
|
||||||
|
|
@ -139,11 +145,44 @@ SmolIdentity identityFromSeed(Uint8List seed) {
|
||||||
|
|
||||||
SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen));
|
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) -----------------------------------------------------------
|
// --- addressing (§3) -----------------------------------------------------------
|
||||||
|
|
||||||
final _address =
|
final _address =
|
||||||
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$");
|
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\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 {
|
class SmolAddress {
|
||||||
final String user;
|
final String user;
|
||||||
final String host;
|
final String host;
|
||||||
|
|
@ -179,8 +218,9 @@ SmolAddress parseAddress(String text) {
|
||||||
if (m == null) throw SmolError("'$text' is not a valid address");
|
if (m == null) throw SmolError("'$text' is not a valid address");
|
||||||
final user = m.namedGroup("user")!;
|
final user = m.namedGroup("user")!;
|
||||||
final host = m.namedGroup("host")!;
|
final host = m.namedGroup("host")!;
|
||||||
if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) {
|
if (!validUsername(user)) {
|
||||||
throw SmolError("$user may not begin or end with a separator");
|
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 portText = m.namedGroup("port");
|
||||||
final port = portText != null ? int.parse(portText) : defaultPort;
|
final port = portText != null ? int.parse(portText) : defaultPort;
|
||||||
|
|
@ -189,8 +229,9 @@ SmolAddress parseAddress(String text) {
|
||||||
|
|
||||||
// --- message format (§5) -------------------------------------------------------
|
// --- message format (§5) -------------------------------------------------------
|
||||||
|
|
||||||
Uint8List messageId(List<int> envelope) =>
|
// §5.4: derived from the envelope so no sender can choose it; used whole,
|
||||||
sha256(concat([_label.id, envelope])).sublist(0, idLen);
|
// nothing truncates it.
|
||||||
|
Uint8List messageId(List<int> envelope) => sha256(concat([_label.id, envelope]));
|
||||||
|
|
||||||
class OpenedMessage {
|
class OpenedMessage {
|
||||||
final Uint8List sender;
|
final Uint8List sender;
|
||||||
|
|
@ -288,6 +329,9 @@ OpenedMessage unseal(List<SmolIdentity> identities, Uint8List envelope) {
|
||||||
signature)) {
|
signature)) {
|
||||||
throw const SmolError("signature does not verify");
|
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));
|
return OpenedMessage(sender, when, body, messageId(envelope));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,7 +348,8 @@ class Frontmatter {
|
||||||
|
|
||||||
// A flat `Key: value` block, deliberately not YAML. Any malformed line
|
// A flat `Key: value` block, deliberately not YAML. Any malformed line
|
||||||
// invalidates the whole block, which is then returned as ordinary body text:
|
// 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) {
|
Frontmatter parseFrontmatter(String text) {
|
||||||
if (!text.startsWith("---\n")) return Frontmatter(const {}, text);
|
if (!text.startsWith("---\n")) return Frontmatter(const {}, text);
|
||||||
final lines = text.split("\n");
|
final lines = text.split("\n");
|
||||||
|
|
@ -327,7 +372,7 @@ Frontmatter parseFrontmatter(String text) {
|
||||||
return Frontmatter(const {}, text);
|
return Frontmatter(const {}, text);
|
||||||
}
|
}
|
||||||
// first occurrence wins
|
// first occurrence wins
|
||||||
fields.putIfAbsent(head, () => line.substring(colon + 1).trim());
|
fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim());
|
||||||
}
|
}
|
||||||
return Frontmatter(fields, rest);
|
return Frontmatter(fields, rest);
|
||||||
}
|
}
|
||||||
|
|
@ -343,35 +388,45 @@ String buildFrontmatter(Map<String, String> fields, String body) {
|
||||||
|
|
||||||
// --- key rotation (§7) ---------------------------------------------------------
|
// --- key rotation (§7) ---------------------------------------------------------
|
||||||
|
|
||||||
Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
|
// §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both
|
||||||
final newPub = ed25519PublicKey(newSeed);
|
// 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 time = i64be(BigInt.from(when ?? nowSeconds()));
|
||||||
|
final signed = concat(
|
||||||
|
[_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]);
|
||||||
return concat([
|
return concat([
|
||||||
oldIdentity.publicKey,
|
oldIdentity.publicKey,
|
||||||
newPub,
|
newIdentity.publicKey,
|
||||||
time,
|
time,
|
||||||
ed25519Sign(oldIdentity.seed,
|
ed25519Sign(oldIdentity.seed, signed),
|
||||||
concat([_label.rotate, oldIdentity.publicKey, newPub, time])),
|
ed25519Sign(newIdentity.seed, signed),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept a key change only when a signed chain leads from the key we hold to
|
// Accept a key change only when a signed chain leads from the key we hold to
|
||||||
// the one the server now returns (§7).
|
// the one the server now returns, both keys signing each link (§7).
|
||||||
bool walkChain(Uint8List pinned, Uint8List current, List<Uint8List> chain) {
|
bool walkChain(
|
||||||
|
String username, Uint8List pinned, Uint8List current, List<Uint8List> chain) {
|
||||||
if (timingSafeEqual(pinned, current)) return true;
|
if (timingSafeEqual(pinned, current)) return true;
|
||||||
if (chain.isEmpty || chain.length > maxChain) return false;
|
if (chain.isEmpty || chain.length > maxChain) return false;
|
||||||
var key = pinned;
|
var key = pinned;
|
||||||
var started = false;
|
var started = false;
|
||||||
for (final cert in chain) {
|
for (final cert in chain) {
|
||||||
final old = cert.sublist(0, 32), next = cert.sublist(32, 64);
|
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 (!started) {
|
||||||
if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold
|
if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold
|
||||||
started = true;
|
started = true;
|
||||||
} else if (!timingSafeEqual(old, key)) {
|
} else if (!timingSafeEqual(old, key)) {
|
||||||
return false; // the chain is not continuous
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
key = next;
|
key = next;
|
||||||
|
|
@ -482,18 +537,27 @@ void expectOk(int status, String what) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// §4 session authentication: sign the handshake hash, which binds the
|
// §4 session authentication: sign the handshake hash, which binds the
|
||||||
// signature to this session's server ephemeral and cannot be replayed.
|
// signature to this session's server ephemeral and cannot be replayed, and
|
||||||
Future<void> authenticate(
|
// push the accept token set (§5.8). `sync = 0` leaves the server's stored set
|
||||||
Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async {
|
// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly.
|
||||||
|
// Returns the number of accept tokens the server now holds.
|
||||||
|
Future<int> authenticate(Session session, Uint8List handshakeHash, String username,
|
||||||
|
SmolIdentity identity, {required int sync, List<Uint8List> tokens = const []}) async {
|
||||||
final name = utf8Bytes(username);
|
final name = utf8Bytes(username);
|
||||||
if (name.length > 255) throw const SmolError("username too long");
|
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([
|
final body = concat([
|
||||||
Uint8List.fromList([name.length]),
|
Uint8List.fromList([name.length]),
|
||||||
name,
|
name,
|
||||||
identity.publicKey,
|
identity.publicKey,
|
||||||
ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])),
|
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 {
|
class Resolved {
|
||||||
|
|
@ -516,8 +580,14 @@ Future<Resolved> resolveOp(Session session, String user) async {
|
||||||
List.generate(r.u8(), (_) => r.take(certLen)));
|
List.generate(r.u8(), (_) => r.take(certLen)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
|
// §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes.
|
||||||
final response = await session.call(opSend, envelope);
|
Future<Uint8List> 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");
|
expectOk(response.status, "sending");
|
||||||
return response.body.length == idLen
|
return response.body.length == idLen
|
||||||
? response.body
|
? response.body
|
||||||
|
|
@ -527,19 +597,27 @@ Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
|
||||||
class FetchedRecord {
|
class FetchedRecord {
|
||||||
final Uint8List id;
|
final Uint8List id;
|
||||||
final int receivedAt;
|
final int receivedAt;
|
||||||
|
final int flags;
|
||||||
final Uint8List envelope;
|
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<List<FetchedRecord>> fetchOp(Session session) async {
|
// §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
|
||||||
final response = await session.call(opFetch);
|
Future<List<FetchedRecord>> 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");
|
expectOk(response.status, "fetching");
|
||||||
final r = Reader(response.body);
|
final r = Reader(response.body);
|
||||||
return List.generate(r.u16(), (_) {
|
return List.generate(r.u16(), (_) {
|
||||||
final id = r.take(idLen);
|
final id = r.take(idLen);
|
||||||
final receivedAt = r.i64();
|
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});
|
const RegisterOptions({this.token = "", this.cert});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerOp(
|
// §6.1: the signature is proof of possession, bound to the server that will
|
||||||
Session session, String username, SmolIdentity identity,
|
// store the binding so it cannot be replayed to another server.
|
||||||
[RegisterOptions opts = const RegisterOptions()]) async {
|
Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) =>
|
||||||
|
concat([_label.register, serverStatic, utf8Bytes(username), identity]);
|
||||||
|
|
||||||
|
Future<void> registerOp(Session session, Uint8List serverStatic, String username,
|
||||||
|
SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async {
|
||||||
final name = utf8Bytes(username);
|
final name = utf8Bytes(username);
|
||||||
final tokenBytes = utf8Bytes(opts.token);
|
final tokenBytes = utf8Bytes(opts.token);
|
||||||
final cert = opts.cert ?? Uint8List(0);
|
final cert = opts.cert ?? Uint8List(0);
|
||||||
|
|
@ -571,6 +653,7 @@ Future<void> registerOp(
|
||||||
Uint8List.fromList([name.length]),
|
Uint8List.fromList([name.length]),
|
||||||
name,
|
name,
|
||||||
identity.publicKey,
|
identity.publicKey,
|
||||||
|
ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)),
|
||||||
Uint8List.fromList([tokenBytes.length]),
|
Uint8List.fromList([tokenBytes.length]),
|
||||||
tokenBytes,
|
tokenBytes,
|
||||||
Uint8List.fromList([cert.length]),
|
Uint8List.fromList([cert.length]),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Device state: identity, pins, contacts and read markers in one JSON blob;
|
// 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
|
// 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
|
// rest is plaintext (the master secret excepted — the device's app storage is
|
||||||
// boundary, like gsmol's browser profile).
|
// the trust boundary, like gsmol's browser profile).
|
||||||
|
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
|
|
@ -42,14 +42,36 @@ class StoredContact {
|
||||||
}
|
}
|
||||||
|
|
||||||
class MailRecord {
|
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 Uint8List envelope;
|
||||||
final int? receivedAt;
|
final int? receivedAt;
|
||||||
final String? recipient; // sent copies only
|
final String? recipient; // sent copies only
|
||||||
final int? sentAt;
|
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,
|
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 {
|
class ImportSummary {
|
||||||
|
|
@ -87,49 +109,66 @@ class SmolStore {
|
||||||
_state.put(_stateKey, next);
|
_state.put(_stateKey, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- identity --------------------------------------------------------------
|
// --- identity (§2) -----------------------------------------------------------
|
||||||
|
|
||||||
Uint8List? seed() {
|
/// The 32-byte master secret, or null before the user creates or restores
|
||||||
final raw = _load()["seed"];
|
/// 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);
|
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.
|
/// The active identity, or null before the user creates or restores one.
|
||||||
SmolIdentity? identity() {
|
SmolIdentity? identity() {
|
||||||
final s = seed();
|
final m = master();
|
||||||
return s == null ? null : identityFromSeed(s);
|
return m == null ? null : identityFromSeed(identitySeed(m, rotations()));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setIdentity(Uint8List newSeed) {
|
/// Whether the accepted-correspondent set held here may replace the
|
||||||
if (seed() != null) {
|
/// server's on the next AUTH — false right after a restore from the master
|
||||||
throw const SmolIdentityExistsException();
|
/// 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
|
/// §7: every key rotated away from is re-derivable from the master, since
|
||||||
// superseded key is readable with nothing else.
|
/// 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.
|
|
||||||
List<SmolIdentity> identities() {
|
List<SmolIdentity> identities() {
|
||||||
final s = seed();
|
final m = master();
|
||||||
if (s == null) return const [];
|
if (m == null) return const [];
|
||||||
final retired = (_load()["retired"] as List? ?? const [])
|
return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))];
|
||||||
.whereType<Map>()
|
|
||||||
.map((entry) => identityFromSeed(unhex(entry["seed"] as String)));
|
|
||||||
return [identityFromSeed(s), ...retired];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- account and server pins -------------------------------------------------
|
// --- account and server pins -------------------------------------------------
|
||||||
|
|
@ -176,6 +215,31 @@ class SmolStore {
|
||||||
return [for (final e in servers.entries) (e.key, b32decode(e.value))];
|
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 ------------------------------------------------------------------
|
// --- contacts ------------------------------------------------------------------
|
||||||
|
|
||||||
StoredContact? contact(String address) {
|
StoredContact? contact(String address) {
|
||||||
|
|
@ -233,6 +297,82 @@ class SmolStore {
|
||||||
return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)];
|
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<String, Map>();
|
||||||
|
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<String, Map>();
|
||||||
|
if (!accepted.containsKey(address)) {
|
||||||
|
throw SmolError("$address was never accepted");
|
||||||
|
}
|
||||||
|
_update((state) {
|
||||||
|
final accepted = (state["accepted"] as Map? ?? {}).cast<String, Map>();
|
||||||
|
accepted[address] = {...accepted[address]!, "active": false};
|
||||||
|
state["accepted"] = accepted;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(String, AcceptedContact)> allAccepted() {
|
||||||
|
final accepted = ((_load()["accepted"] as Map?) ?? {}).cast<String, Map>();
|
||||||
|
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<Uint8List>) 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<String, Map>();
|
||||||
|
tokens[address] = {
|
||||||
|
"token": b32encode(token),
|
||||||
|
"seenAt": DateTime.now().millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
state["tokens"] = tokens;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- read markers ---------------------------------------------------------------
|
// --- read markers ---------------------------------------------------------------
|
||||||
|
|
||||||
void markRead(String idHex) {
|
void markRead(String idHex) {
|
||||||
|
|
@ -255,6 +395,8 @@ class SmolStore {
|
||||||
"receivedAt": record.receivedAt,
|
"receivedAt": record.receivedAt,
|
||||||
"recipient": record.recipient,
|
"recipient": record.recipient,
|
||||||
"sentAt": record.sentAt,
|
"sentAt": record.sentAt,
|
||||||
|
"tier": record.tier,
|
||||||
|
"keptOnServer": record.keptOnServer,
|
||||||
};
|
};
|
||||||
|
|
||||||
MailRecord _mapToRecord(Map map) => MailRecord(
|
MailRecord _mapToRecord(Map map) => MailRecord(
|
||||||
|
|
@ -263,10 +405,18 @@ class SmolStore {
|
||||||
receivedAt: map["receivedAt"] as int?,
|
receivedAt: map["receivedAt"] as int?,
|
||||||
recipient: map["recipient"] as String?,
|
recipient: map["recipient"] as String?,
|
||||||
sentAt: map["sentAt"] as int?,
|
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";
|
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<void> storeMessage(String folder, MailRecord record) =>
|
Future<void> storeMessage(String folder, MailRecord record) =>
|
||||||
_mail.put(mailKey(folder, record.id), _recordToMap(record));
|
_mail.put(mailKey(folder, record.id), _recordToMap(record));
|
||||||
|
|
||||||
|
|
@ -279,12 +429,17 @@ class SmolStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MailRecord> listMessages(String folder) {
|
List<MailRecord> listMessages(String folder) {
|
||||||
final prefix = "$folder/";
|
final physical = _physicalFolder(folder);
|
||||||
|
final prefix = "$physical/";
|
||||||
|
final wantTier = folder == "requests" ? tierRequests : tierMain;
|
||||||
final rows = <MailRecord>[];
|
final rows = <MailRecord>[];
|
||||||
for (final key in _mail.keys.cast<String>()) {
|
for (final key in _mail.keys.cast<String>()) {
|
||||||
if (!key.startsWith(prefix)) continue;
|
if (!key.startsWith(prefix)) continue;
|
||||||
final row = _mail.get(key);
|
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) =>
|
rows.sort((a, b) =>
|
||||||
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
|
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
|
||||||
|
|
@ -292,29 +447,29 @@ class SmolStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
MailRecord? getMessage(String folder, String id) {
|
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;
|
return row is Map ? _mapToRecord(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteMessage(String folder, String id) =>
|
Future<void> deleteMessage(String folder, String id) =>
|
||||||
_mail.delete(mailKey(folder, id));
|
_mail.delete(mailKey(_physicalFolder(folder), id));
|
||||||
|
|
||||||
// --- export / import: mail, contacts, pins — never the seed --------------------
|
// --- export / import: mail, contacts, pins — never the seed --------------------
|
||||||
|
|
||||||
/// Label kept as gsmol wrote it originally; the export format version
|
/// Label kept as gsmol wrote it originally; the export format version
|
||||||
/// (gsmolExport) is what actually changed between v1 and v2.
|
/// (gsmolExport) is what actually changed between v1 and v2.
|
||||||
static final _exportLabel = utf8Bytes("gsmol/1 export");
|
static final _exportLabel = utf8Bytes("gsmol/1 export");
|
||||||
Uint8List _exportKey(Uint8List seed) =>
|
Uint8List _exportKey(Uint8List master) =>
|
||||||
hkdfSha256(seed, Uint8List(0), _exportLabel, 32);
|
hkdfSha256(master, Uint8List(0), _exportLabel, 32);
|
||||||
|
|
||||||
/// v2 matches gsmol's own current export: the whole payload — mail,
|
/// 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
|
/// contacts, pins — is sealed to a key derived from the identity's master,
|
||||||
/// a backup file is only readable by whoever holds that seed. Deliberately
|
/// so a backup file is only readable by whoever holds that master.
|
||||||
/// excludes the seed itself: it has its own reveal-and-copy flow in
|
/// Deliberately excludes the master itself: it has its own reveal-and-copy
|
||||||
/// settings, meant for a password manager, not a shareable file.
|
/// flow in settings, meant for a password manager, not a shareable file.
|
||||||
Map<String, dynamic> exportData() {
|
Map<String, dynamic> exportData() {
|
||||||
final seed = this.seed();
|
final master = this.master();
|
||||||
if (seed == null) throw const SmolError("no identity yet");
|
if (master == null) throw const SmolError("no identity yet");
|
||||||
final state = _load();
|
final state = _load();
|
||||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||||
final payload = {
|
final payload = {
|
||||||
|
|
@ -329,11 +484,13 @@ class SmolStore {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"inbox": [
|
"inbox": [
|
||||||
for (final row in listMessages("inbox"))
|
for (final row in [...listMessages("inbox"), ...listMessages("requests")])
|
||||||
{
|
{
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
"receivedAt": row.receivedAt,
|
"receivedAt": row.receivedAt,
|
||||||
"envelope": base64Encode(row.envelope),
|
"envelope": base64Encode(row.envelope),
|
||||||
|
"tier": row.tier,
|
||||||
|
"keptOnServer": row.keptOnServer,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sent": [
|
"sent": [
|
||||||
|
|
@ -348,7 +505,7 @@ class SmolStore {
|
||||||
};
|
};
|
||||||
final nonce = randomBytes(12);
|
final nonce = randomBytes(12);
|
||||||
final ciphertext = aeadEncrypt(
|
final ciphertext = aeadEncrypt(
|
||||||
_exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0));
|
_exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0));
|
||||||
return {
|
return {
|
||||||
"gsmolExport": 2,
|
"gsmolExport": 2,
|
||||||
"exportedAt": DateTime.now().millisecondsSinceEpoch,
|
"exportedAt": DateTime.now().millisecondsSinceEpoch,
|
||||||
|
|
@ -364,13 +521,13 @@ class SmolStore {
|
||||||
Future<ImportSummary> importData(Map data) async {
|
Future<ImportSummary> importData(Map data) async {
|
||||||
Map payload;
|
Map payload;
|
||||||
if (data["gsmolExport"] == 2) {
|
if (data["gsmolExport"] == 2) {
|
||||||
final seed = this.seed();
|
final master = this.master();
|
||||||
if (seed == null) {
|
if (master == null) {
|
||||||
throw const SmolError("no identity yet — restore it before importing");
|
throw const SmolError("no identity yet — restore it before importing");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final plaintext = aeadDecrypt(
|
final plaintext = aeadDecrypt(
|
||||||
_exportKey(seed),
|
_exportKey(master),
|
||||||
base64Decode(data["nonce"] as String),
|
base64Decode(data["nonce"] as String),
|
||||||
base64Decode(data["ciphertext"] as String),
|
base64Decode(data["ciphertext"] as String),
|
||||||
Uint8List(0),
|
Uint8List(0),
|
||||||
|
|
@ -447,6 +604,8 @@ class SmolStore {
|
||||||
receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null,
|
receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null,
|
||||||
recipient: folder == "sent" ? map["recipient"] as String? : null,
|
recipient: folder == "sent" ? map["recipient"] as String? : null,
|
||||||
sentAt: folder == "sent" ? map["sentAt"] as int? : 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++;
|
if (await storeIfNew(folder, record) != null) summary.mailAdded++;
|
||||||
} on Exception {
|
} on Exception {
|
||||||
|
|
@ -481,6 +640,14 @@ class SmolStore {
|
||||||
}
|
}
|
||||||
return count;
|
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"
|
/// The store throws these typed errors so the UI can tell "no identity yet"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
// End-to-end against a live reference server: register an address on a
|
// 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,
|
// locally running smolmaild, send sealed messages to ourselves, fetch them
|
||||||
// and check the server is drained afterwards. Skips when nothing listens on
|
// back, exercise the accept-token round trip (§5.8) between the requests and
|
||||||
// 127.0.0.1:1961, so `devbox run test` does not depend on a server.
|
// 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 &&
|
// To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key &&
|
||||||
// uv run smolmaild.py serve --key server.key --db mail.db)
|
// uv run smolmaild.py serve --key server.key --db mail.db)
|
||||||
// then `devbox run test`.
|
// then `devbox run test`.
|
||||||
|
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
import "dart:typed_data";
|
||||||
import "package:flutter_test/flutter_test.dart";
|
import "package:flutter_test/flutter_test.dart";
|
||||||
import "package:hive_flutter/hive_flutter.dart";
|
import "package:hive_flutter/hive_flutter.dart";
|
||||||
|
|
||||||
|
|
@ -47,7 +49,8 @@ void main() {
|
||||||
final warnings = <String>[];
|
final warnings = <String>[];
|
||||||
client.onWarning = warnings.add;
|
client.onWarning = warnings.add;
|
||||||
|
|
||||||
final me = client.createIdentity();
|
final master = client.createIdentity();
|
||||||
|
final me = client.identity!;
|
||||||
final user = "e2e${hex(randomBytes(4))}";
|
final user = "e2e${hex(randomBytes(4))}";
|
||||||
final address = parseAddress("$user@$host");
|
final address = parseAddress("$user@$host");
|
||||||
final learned = await client.connect(address, requirePin: false);
|
final learned = await client.connect(address, requirePin: false);
|
||||||
|
|
@ -67,13 +70,16 @@ void main() {
|
||||||
expect(summary.stored, 2);
|
expect(summary.stored, 2);
|
||||||
expect(summary.rejected, isEmpty);
|
expect(summary.rejected, isEmpty);
|
||||||
|
|
||||||
final inbox = store.listMessages("inbox");
|
// §5.8: we have not accepted ourselves as a correspondent yet, so this
|
||||||
expect(inbox.length, 2);
|
// 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
|
// receivedAt has second granularity, so the order of the two is not
|
||||||
// guaranteed; assert on the pair, then open the one we care about.
|
// 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"});
|
expect(subjects, {"hello e2e", "second"});
|
||||||
final hello = inbox.firstWhere(
|
final hello = requests.firstWhere(
|
||||||
(m) => client.describe(m).subject == "hello e2e");
|
(m) => client.describe(m).subject == "hello e2e");
|
||||||
final opened = client.describe(hello);
|
final opened = client.describe(hello);
|
||||||
expect(opened.error, isNull);
|
expect(opened.error, isNull);
|
||||||
|
|
@ -84,9 +90,27 @@ void main() {
|
||||||
final again = await client.fetch();
|
final again = await client.fetch();
|
||||||
expect(again.stored, 0);
|
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).
|
// The sent copy is sealed to ourselves and readable (§5.6).
|
||||||
final sent = store.listMessages("sent");
|
final sent = store.listMessages("sent");
|
||||||
expect(sent.length, 2);
|
expect(sent.length, 4);
|
||||||
expect(client.describe(sent.first).error, isNull);
|
expect(client.describe(sent.first).error, isNull);
|
||||||
|
|
||||||
// A restored seed can rebind the address without REGISTER; the address
|
// A restored seed can rebind the address without REGISTER; the address
|
||||||
|
|
@ -96,13 +120,13 @@ void main() {
|
||||||
expect(
|
expect(
|
||||||
() => client.recallAccount("nobody@$host"), throwsA(isA<SmolError>()));
|
() => client.recallAccount("nobody@$host"), throwsA(isA<SmolError>()));
|
||||||
|
|
||||||
// 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
|
// recall is refused; re-registering a taken name is refused; recall with
|
||||||
// the operator-supplied key then binds the account without REGISTER.
|
// the operator-supplied key then binds the account without REGISTER.
|
||||||
final restored = await SmolStore.open(
|
final restored = await SmolStore.open(
|
||||||
stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail");
|
stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail");
|
||||||
final secondDevice = SmolClient(restored);
|
final secondDevice = SmolClient(restored);
|
||||||
restored.setIdentity(me.seed);
|
restored.setMaster(master);
|
||||||
expect(
|
expect(
|
||||||
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
|
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
|
||||||
restored.pinServer(host, learned.serverStatic);
|
restored.pinServer(host, learned.serverStatic);
|
||||||
|
|
@ -118,4 +142,50 @@ void main() {
|
||||||
expect(outcome.message, contains("key unchanged"));
|
expect(outcome.message, contains("key unchanged"));
|
||||||
expect(store.contact(address.short)!.history, isEmpty);
|
expect(store.contact(address.short)!.history, isEmpty);
|
||||||
}, timeout: const Timeout(Duration(minutes: 2)));
|
}, 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)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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
|
// client's network operations are stubbed; what is under test is the UI and
|
||||||
// router flow itself.
|
// 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/presentation/app_widget.dart";
|
||||||
import "package:smol_mail/smol/client.dart";
|
import "package:smol_mail/smol/client.dart";
|
||||||
import "package:smol_mail/smol/crypto.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/proto.dart";
|
||||||
import "package:smol_mail/smol/store.dart";
|
import "package:smol_mail/smol/store.dart";
|
||||||
|
|
||||||
/// A [SmolClient] whose recall completes instantly, so the test exercises
|
/// A [SmolClient] whose network operations complete instantly, so the test
|
||||||
/// the flow rather than the network.
|
/// 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 {
|
class StubClient extends SmolClient {
|
||||||
StubClient(super.store);
|
StubClient(super.store);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SmolAddress> 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
|
@override
|
||||||
Future<SmolAddress> recallAccount(String addressText) async {
|
Future<SmolAddress> recallAccount(String addressText) async {
|
||||||
final addr = parseAddress(addressText);
|
final addr = parseAddress(addressText);
|
||||||
|
|
@ -58,7 +72,7 @@ void main() {
|
||||||
"restore with an address, but no pin yet, asks for the server key "
|
"restore with an address, but no pin yet, asks for the server key "
|
||||||
"and then recalls into the inbox", (tester) async {
|
"and then recalls into the inbox", (tester) async {
|
||||||
final store = storeA;
|
final store = storeA;
|
||||||
final seed = randomBytes(32);
|
final master = randomBytes(32);
|
||||||
await tester.pumpWidget(app(store));
|
await tester.pumpWidget(app(store));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text("Create Identity"), findsOneWidget);
|
expect(find.text("Create Identity"), findsOneWidget);
|
||||||
|
|
@ -68,7 +82,7 @@ void main() {
|
||||||
|
|
||||||
var fields = find.byType(TextField);
|
var fields = find.byType(TextField);
|
||||||
expect(fields, findsNWidgets(2));
|
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.enterText(fields.at(1), "randogoth@smol.place");
|
||||||
await tester.tap(find.text("Restore"));
|
await tester.tap(find.text("Restore"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -80,6 +94,9 @@ void main() {
|
||||||
expect(find.text("Pin this server's public key to finish restoring your address."),
|
expect(find.text("Pin this server's public key to finish restoring your address."),
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
expect(find.text("Invite token (optional)"), findsNothing);
|
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);
|
fields = find.byType(TextField);
|
||||||
expect(fields, findsNWidgets(2)); // address (carried over), server key
|
expect(fields, findsNWidgets(2)); // address (carried over), server key
|
||||||
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
|
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
|
||||||
|
|
@ -87,84 +104,71 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text("Inbox"), findsOneWidget);
|
expect(find.text("Inbox"), findsOneWidget);
|
||||||
expect(store.seed(), seed);
|
expect(store.master(), master);
|
||||||
expect(store.account()!.user, "randogoth");
|
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 {
|
(tester) async {
|
||||||
final store = storeB;
|
final store = storeB;
|
||||||
final seed = randomBytes(32);
|
|
||||||
await tester.pumpWidget(app(store));
|
await tester.pumpWidget(app(store));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text("Restore From Seed"));
|
await tester.tap(find.text("Restore From Seed"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
var fields = find.byType(TextField);
|
final fields = find.byType(TextField);
|
||||||
await tester.enterText(fields.at(0), hex(seed));
|
await tester.enterText(fields.at(0), hex(randomBytes(32)));
|
||||||
await tester.tap(find.text("Restore"));
|
await tester.tap(find.text("Restore"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// The register step: address, server key, optional invite token.
|
// Nothing was submitted, so nothing was persisted; still on this step.
|
||||||
fields = find.byType(TextField);
|
expect(find.text("Restore"), findsOneWidget);
|
||||||
expect(fields, findsNWidgets(3));
|
expect(find.text("Inbox"), findsNothing);
|
||||||
await tester.enterText(fields.at(0), "randogoth@smol.place");
|
expect(store.master(), isNull);
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
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 {
|
"instead of refusing it", (tester) async {
|
||||||
final store = storeC;
|
final store = storeC;
|
||||||
final abandonedSeed = randomBytes(32);
|
final realMaster = randomBytes(32);
|
||||||
final realSeed = randomBytes(32);
|
|
||||||
await tester.pumpWidget(app(store));
|
await tester.pumpWidget(app(store));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// First attempt: restore a seed but never finish registering — lands on
|
// First attempt: create a fresh identity but never finish registering —
|
||||||
// the register step, identity set, no account bound.
|
// lands on the backup step, master set, no account bound.
|
||||||
await tester.tap(find.text("Restore From Seed"));
|
await tester.tap(find.text("Create Identity"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
var fields = find.byType(TextField);
|
expect(store.master(), isNotNull);
|
||||||
await tester.enterText(fields.at(0), hex(abandonedSeed));
|
|
||||||
await tester.tap(find.text("Restore"));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(store.seed(), abandonedSeed);
|
|
||||||
expect(store.account(), isNull);
|
expect(store.account(), isNull);
|
||||||
|
|
||||||
// Simulate returning to onboarding later (e.g. a cold restart). Pumping
|
// Simulate returning to onboarding later (e.g. a cold restart). Pumping
|
||||||
// app(store) directly would just rebuild the existing OnboardingScreen
|
// app(store) directly would just rebuild the existing OnboardingScreen
|
||||||
// state in place (still parked on the register step) rather than really
|
// state in place rather than really restarting, so tear the tree down
|
||||||
// restarting, so tear the tree down first to force a fresh app state —
|
// first to force a fresh app state — HomeGuard then sends an
|
||||||
// HomeGuard then sends an identity-without-account back to welcome.
|
// identity-without-account back to welcome.
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
await tester.pumpWidget(app(store));
|
await tester.pumpWidget(app(store));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text("Create Identity"), findsOneWidget);
|
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.tap(find.text("Restore From Seed"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
fields = find.byType(TextField);
|
final fields = find.byType(TextField);
|
||||||
await tester.enterText(fields.at(0), hex(realSeed));
|
await tester.enterText(fields.at(0), hex(realMaster));
|
||||||
await tester.enterText(fields.at(1), "randogoth@smol.place");
|
await tester.enterText(fields.at(1), "randogoth@smol.place");
|
||||||
await tester.tap(find.text("Restore"));
|
await tester.tap(find.text("Restore"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Lands on the recall-framed register step (no pin yet); pin it and finish.
|
// Lands on the recall-framed register step (no pin yet); pin it and finish.
|
||||||
fields = find.byType(TextField);
|
expect(find.byType(TextField), findsNWidgets(2));
|
||||||
expect(fields, findsNWidgets(2));
|
await tester.enterText(find.byType(TextField).at(1), b32encode(randomBytes(32)));
|
||||||
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
|
|
||||||
await tester.tap(find.text("Pin and Recall"));
|
await tester.tap(find.text("Pin and Recall"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text("Inbox"), findsOneWidget);
|
expect(find.text("Inbox"), findsOneWidget);
|
||||||
expect(store.seed(), realSeed);
|
expect(store.master(), realMaster);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,24 +163,28 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("frontmatter parses and fails closed (§5.5)", () {
|
test("frontmatter parses and fails closed (§5.5)", () {
|
||||||
const spec =
|
const inReplyTo =
|
||||||
"---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
|
"4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5";
|
||||||
|
final spec =
|
||||||
|
"---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
|
||||||
final parsed = parseFrontmatter(spec);
|
final parsed = parseFrontmatter(spec);
|
||||||
expect(parsed.fields["Subject"], "Re: the thing");
|
expect(parsed.fields["subject"], "Re: the thing");
|
||||||
expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d");
|
expect(parsed.fields["in-reply-to"], inReplyTo);
|
||||||
expect(parsed.body, "Body text starts here.");
|
expect(parsed.body, "Body text starts here.");
|
||||||
// a malformed line invalidates the whole block, which fails closed toward display
|
// a malformed line invalidates the whole block, which fails closed toward display
|
||||||
expect(parseFrontmatter("---\nno colon here\n---\nrest").body,
|
expect(parseFrontmatter("---\nno colon here\n---\nrest").body,
|
||||||
"---\nno colon here\n---\nrest");
|
"---\nno colon here\n---\nrest");
|
||||||
expect(parseFrontmatter("---\nSubject: x\nno end").body,
|
expect(parseFrontmatter("---\nSubject: x\nno end").body,
|
||||||
"---\nSubject: x\nno end");
|
"---\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"),
|
expect(buildFrontmatter(const {}, "---\nactual body"),
|
||||||
"---\n---\n---\nactual body");
|
"---\n---\n---\nactual body");
|
||||||
expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere");
|
expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere");
|
||||||
expect(buildFrontmatter(const {}, "plain"), "plain");
|
expect(buildFrontmatter(const {}, "plain"), "plain");
|
||||||
expect(
|
expect(
|
||||||
parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"],
|
parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["subject"],
|
||||||
isNull);
|
isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -195,6 +199,9 @@ void main() {
|
||||||
expect(parsed.identity, key);
|
expect(parsed.identity, key);
|
||||||
expect(parsed.user, "bob");
|
expect(parsed.user, "bob");
|
||||||
expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>()));
|
expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>()));
|
||||||
|
// §3: never two separators in a row
|
||||||
|
expect(() => parseAddress("a..b@h"), throwsA(isA<SmolError>()));
|
||||||
|
expect(parseAddress("a.b_c@h").user, "a.b_c");
|
||||||
// §3: fingerprints are the first 20 base32 characters in groups of four
|
// §3: fingerprints are the first 20 base32 characters in groups of four
|
||||||
final b32 = b32encode(key);
|
final b32 = b32encode(key);
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -210,27 +217,31 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rotation chains validate, break and oversize per §7", () {
|
test("rotation chains validate, break and oversize per §7", () {
|
||||||
|
const username = "alice";
|
||||||
final old = identityFromSeed(randomBytes(32));
|
final old = identityFromSeed(randomBytes(32));
|
||||||
final mid = randomBytes(32);
|
final mid = randomBytes(32);
|
||||||
final fresh = randomBytes(32);
|
final fresh = randomBytes(32);
|
||||||
final when = nowSeconds();
|
final when = nowSeconds();
|
||||||
final chain = [
|
final chain = [
|
||||||
makeCert(old, mid, when),
|
makeCert(username, old, mid, when),
|
||||||
makeCert(identityFromSeed(mid), fresh, when),
|
makeCert(username, identityFromSeed(mid), fresh, when),
|
||||||
];
|
];
|
||||||
expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
|
expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
|
||||||
expect(walkChain(old.publicKey, old.publicKey, []), isTrue);
|
expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue);
|
||||||
expect(
|
expect(
|
||||||
walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
|
walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
|
||||||
isFalse);
|
isFalse);
|
||||||
final forged = List<Uint8List>.from(chain);
|
final forged = List<Uint8List>.from(chain);
|
||||||
forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when);
|
forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when);
|
||||||
expect(
|
expect(
|
||||||
walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
|
walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
|
||||||
expect(
|
expect(
|
||||||
walkChain(old.publicKey, ed25519PublicKey(fresh),
|
walkChain(username, old.publicKey, ed25519PublicKey(fresh),
|
||||||
List.filled(17, chain[0])),
|
List.filled(17, chain[0])),
|
||||||
isFalse);
|
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);
|
expect(chain[0].length, certLen);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,11 @@ void main() {
|
||||||
expect(saved.key, identity.publicKey);
|
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 {
|
() async {
|
||||||
final a = await freshStore("export");
|
final a = await freshStore("export");
|
||||||
final b = await freshStore("round-trip");
|
final b = await freshStore("round-trip");
|
||||||
a.setIdentity(randomBytes(32));
|
a.setMaster(randomBytes(32));
|
||||||
a.pinServer("example.org", randomBytes(32));
|
a.pinServer("example.org", randomBytes(32));
|
||||||
a.saveContact("alice@example.org", randomBytes(32), true);
|
a.saveContact("alice@example.org", randomBytes(32), true);
|
||||||
a.saveContact("alice@example.org", randomBytes(32), false); // history grows
|
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));
|
MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6));
|
||||||
|
|
||||||
final data = a.exportData();
|
final data = a.exportData();
|
||||||
expect(data["gsmolExport"], 2); // sealed to the identity's seed, like gsmol
|
expect(data["gsmolExport"], 2); // sealed to the identity's master, like gsmol
|
||||||
expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse);
|
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).
|
// 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);
|
final summary = await b.importData(data);
|
||||||
expect(summary.mailAdded, 2);
|
expect(summary.mailAdded, 2);
|
||||||
expect(summary.pinsAdded, 1);
|
expect(summary.pinsAdded, 1);
|
||||||
|
|
@ -96,14 +96,58 @@ void main() {
|
||||||
test("v2 import refuses a different identity's export", () async {
|
test("v2 import refuses a different identity's export", () async {
|
||||||
final a = await freshStore("export-wrong-identity");
|
final a = await freshStore("export-wrong-identity");
|
||||||
final c = await freshStore("round-trip-wrong-identity");
|
final c = await freshStore("round-trip-wrong-identity");
|
||||||
a.setIdentity(randomBytes(32));
|
a.setMaster(randomBytes(32));
|
||||||
a.pinServer("example.org", randomBytes(32));
|
a.pinServer("example.org", randomBytes(32));
|
||||||
final data = a.exportData();
|
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<SmolError>()));
|
expect(() => c.importData(data), throwsA(isA<SmolError>()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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<SmolError>()));
|
||||||
|
|
||||||
|
// 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 {
|
test("v1 legacy export still imports without an identity", () async {
|
||||||
final store = await freshStore("import-legacy-v1");
|
final store = await freshStore("import-legacy-v1");
|
||||||
final summary = await store.importData({
|
final summary = await store.importData({
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@
|
||||||
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
|
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
|
||||||
"time": 1730000000,
|
"time": 1730000000,
|
||||||
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
|
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
|
||||||
"id": "b05d15a2ab5293164eda564de02a6901",
|
"id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553",
|
||||||
"unpadded_plaintext_len": 143
|
"unpadded_plaintext_len": 143
|
||||||
},
|
},
|
||||||
"noise": {
|
"noise": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue