Skip to content

Commit

Permalink
Feature/account refactor (#20)
Browse files Browse the repository at this point in the history
Adds support for new user id and subscription type schema
  • Loading branch information
luckyrat authored Feb 21, 2023
1 parent 6691865 commit 311487f
Show file tree
Hide file tree
Showing 17 changed files with 211 additions and 84 deletions.
1 change: 1 addition & 0 deletions android/fastlane/metadata/android/en-GB/changelogs/42.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Hidden improvements to help support visible new features later in the year
3 changes: 3 additions & 0 deletions lib/credentials/quick_unlocker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class QuickUnlocker {
),
);

// In 2023 we changed the user string to be the user ID rather than emailHashed. Since we didn't change
// any existing user's ID and they used to default to emailHashed anyway, this will keep working.
// Maybe one day we could/should rename the user parameter to complete the tidy-up.
Future<QUStatus> initialiseForUser(String user, bool force) async {
if (!force && _currentCreds != null && _currentUser != null && _currentUser == user) {
return QUStatus.credsAvailable;
Expand Down
18 changes: 17 additions & 1 deletion lib/cubit/account_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ class AccountCubit extends Cubit<AccountState> {
}
}

User? get currentUserIfIdKnown {
final AccountState currentState = state;
if (currentState is AccountChosen && currentState.user.id != null) {
return currentState.user;
} else {
return null;
}
}

Future<User?> startup() async {
if (state is! AccountInitial) return null;
l.d('starting account cubit');
Expand Down Expand Up @@ -89,7 +98,7 @@ class AccountCubit extends Cubit<AccountState> {
l.d('sign in procedure now awaits a password and resulting SRP parameters');
emit(AccountIdentified(user, false));
} on KeeServiceTransportException catch (e) {
l.i('Unable to identify user due to a transport error. App should continue to work offline if user has previously stored their Vault. Details: $e');
l.i('Unable to identify user due to a transport error. App should continue to work offline if user has previously stored their Vault unless they have changed their email address previously. Details: $e');
emit(AccountIdentified(user, false));
}
}
Expand All @@ -108,11 +117,17 @@ class AccountCubit extends Cubit<AccountState> {
l.i('Unable to authenticate due to a transport error. App should continue to work offline if user has previously stored their Vault. Details: $e');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user.current.email', user.email!);
if (user.id?.isNotEmpty ?? false) {
await prefs.setString('user.authMaterialUserIdMap.${user.emailHashed}', user.id!);
}
emit(AccountAuthenticationBypassed(user));
} on KeeMaybeOfflineException {
l.i('Unable to authenticate since initial identification failed, probably due to a transport error. App should continue to work offline if user has previously stored their Vault.');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user.current.email', user.email!);
if (user.id?.isNotEmpty ?? false) {
await prefs.setString('user.authMaterialUserIdMap.${user.emailHashed}', user.id!);
}
emit(AccountAuthenticationBypassed(user));
}
return user;
Expand Down Expand Up @@ -152,6 +167,7 @@ class AccountCubit extends Cubit<AccountState> {
user = await _userRepo.finishSignin(key, user);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user.current.email', user.email!);
await prefs.setString('user.authMaterialUserIdMap.${user.emailHashed}', user.id!);
await _userRepo.setQuickUnlockUser(user);
final subscriptionStatus = user.subscriptionStatus;
if (subscriptionStatus == AccountSubscriptionStatus.current) {
Expand Down
2 changes: 1 addition & 1 deletion lib/cubit/vault_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,7 @@ class VaultCubit extends Cubit<VaultState> {
final requireFullPasswordPeriod =
int.tryParse(Settings.getValue<String>('requireFullPasswordPeriod') ?? '60') ?? 60;
l.d('Will require a full password to be entered every $requireFullPasswordPeriod days');
final quStatus = await _qu.initialiseForUser(user?.emailHashed ?? _qu.localUserMagicString, true);
final quStatus = await _qu.initialiseForUser(user?.id ?? _qu.localUserMagicString, true);
if (quStatus != QUStatus.mapAvailable && quStatus != QUStatus.credsAvailable) {
l.w("Quick unlock credential provider is unavailable or unknown. Can't proceed to save credentials in this state.");
return;
Expand Down
2 changes: 2 additions & 0 deletions lib/generated/intl/messages_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Subscription expired"),
"subscriptionExpiredDetails": MessageLookupByLibrary.simpleMessage(
"Your subscription or trial period has ended. Provide up to date payment details and re-enable your subscription on the Kee Vault Account Management web site."),
"subscriptionExpiredNoAction": MessageLookupByLibrary.simpleMessage(
"Your subscription or trial period has ended. Please contact us to discuss options for renewal."),
"subscriptionExpiredTrialAvailable": MessageLookupByLibrary.simpleMessage(
"Welcome back to Kee Vault. You can enable a new 30 day free trial to see what has improved since you first created your Kee Vault account."),
"tagRename": MessageLookupByLibrary.simpleMessage("Rename"),
Expand Down
10 changes: 10 additions & 0 deletions lib/generated/l10n.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@
"offerToSave": "Offer to save passwords",
"subscriptionExpired": "Subscription expired",
"subscriptionExpiredDetails": "Your subscription or trial period has ended. Provide up to date payment details and re-enable your subscription on the Kee Vault Account Management web site.",
"subscriptionExpiredNoAction": "Your subscription or trial period has ended. Please contact us to discuss options for renewal.",
"restartSubscription": "Restart subscription",
"subscriptionExpiredTrialAvailable": "Welcome back to Kee Vault. You can enable a new 30 day free trial to see what has improved since you first created your Kee Vault account.",
"startFreeTrial": "Start free trial",
Expand Down
21 changes: 10 additions & 11 deletions lib/local_vault_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class LocalVaultRepository {

Future<LocalVaultFile?> load(User user, Future<CredentialLookupResult> Function() getCredentials) async {
final directory = await getStorageDirectory();
final file = await _loadLocalFile(getCredentials, '${directory.path}/${user.emailHashedB64url}/current.kdbx');
final file = await _loadLocalFile(getCredentials, '${directory.path}/${user.idB64url}/current.kdbx');
return file;
}

Expand Down Expand Up @@ -166,7 +166,7 @@ class LocalVaultRepository {

Future<void> create(User user, LockedVaultFile lockedKdbx) async {
final directory = await getStorageDirectory();
final file = File('${directory.path}/${user.emailHashedB64url}/current.kdbx');
final file = File('${directory.path}/${user.idB64url}/current.kdbx');
await file.create(recursive: true);
await file.writeAsBytes(lockedKdbx.kdbxBytes, flush: true);
}
Expand All @@ -181,7 +181,7 @@ class LocalVaultRepository {

Future<VaultFileVersions> merge(User user, LocalVaultFile local, RemoteVaultFile remote) async {
final directory = await getStorageDirectory();
final file = File('${directory.path}/${user.emailHashedB64url}/current.kdbx');
final file = File('${directory.path}/${user.idB64url}/current.kdbx');
final firstKdbx = await local.files.remoteMergeTarget;
if (firstKdbx == null) {
throw Exception("Missing remote merge target. Can't proceed with merge.");
Expand All @@ -194,8 +194,7 @@ class LocalVaultRepository {
finalKdbx = firstKdbx;
kdbxData = await kdbxFormat().save(firstKdbx);
} on KdbxUnsupportedException catch (e) {
final backupFilename =
'${directory.path}/${user.emailHashedB64url}/backup-${DateTime.now().millisecondsSinceEpoch}.kdbx';
final backupFilename = '${directory.path}/${user.idB64url}/backup-${DateTime.now().millisecondsSinceEpoch}.kdbx';
l.w('Merge from remote failed! Most likely this is due to the user resetting their account on another device and then signing in to this device AND they reset their password to the same as it was before. We will create a backup file at $backupFilename just in case manual recovery becomes critical. Detailed reason: ${e.hint}');
await file.copy(backupFilename);
finalKdbx = secondKdbx;
Expand Down Expand Up @@ -226,7 +225,7 @@ class LocalVaultRepository {
}

final directory = await getStorageDirectory();
final file = File('${directory.path}/${user.emailHashedB64url}/staged.kdbx');
final file = File('${directory.path}/${user.idB64url}/staged.kdbx');
await file.writeAsBytes(bytes, flush: true);
l.d('staging complete');
}
Expand All @@ -236,15 +235,15 @@ class LocalVaultRepository {
final directory = await getStorageDirectory();
return await _loadRemoteFile(
getCredentials,
'${directory.path}/${user.emailHashedB64url}/staged.kdbx',
'${directory.path}/${user.idB64url}/staged.kdbx',
ifNewerThan,
);
}

remove(User user) async {
final directory = await getStorageDirectory();
final file = File('${directory.path}/${user.emailHashedB64url}/current.kdbx');
final stagedFile = File('${directory.path}/${user.emailHashedB64url}/staged.kdbx');
final file = File('${directory.path}/${user.idB64url}/current.kdbx');
final stagedFile = File('${directory.path}/${user.idB64url}/staged.kdbx');
try {
await file.delete();
} on Exception {
Expand Down Expand Up @@ -279,7 +278,7 @@ class LocalVaultRepository {
Future<LocalVaultFile> save(User? user, LocalVaultFile vault,
Future<KdbxFile> Function(KdbxFile vaultFile) applyAndConsumePendingAutofillAssociations) async {
final directory = await getStorageDirectory();
final userFolder = user?.emailHashedB64url ?? 'local_user';
final userFolder = user?.idB64url ?? 'local_user';
final file = File('${directory.path}/$userFolder/current.kdbx');
(await vault.files.pending)?.merge(vault.files.current);
final kdbxToSave = await beforeSave(
Expand Down Expand Up @@ -314,7 +313,7 @@ class LocalVaultRepository {

Future<LocalVaultFile?> tryAutofillMerge(User? user, Credentials creds, LocalVaultFile vault) async {
final directory = await getStorageDirectory();
final userFolder = user?.emailHashedB64url ?? 'local_user';
final userFolder = user?.idB64url ?? 'local_user';
final fileNameCurrent = File('${directory.path}/$userFolder/current.kdbx');
final fileNameAutofill = '${directory.path}/$userFolder/autofill.kdbx';
final fileAutofill = File(fileNameAutofill);
Expand Down
5 changes: 3 additions & 2 deletions lib/user_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ class UserRepository {
UserRepository(this.userService, this.qu);

Future<QUStatus> setQuickUnlockUser(User user, {bool force = false}) async {
if (user.emailHashed == null) return QUStatus.unknown;
final quStatus = await qu.initialiseForUser(user.emailHashed!, force);
if (user.id == null) return QUStatus.unknown;
final quStatus = await qu.initialiseForUser(user.id!, force);
if (quStatus == QUStatus.mapAvailable || quStatus == QUStatus.credsAvailable) {
if (user.passKey?.isNotEmpty ?? false) {
await qu.saveQuickUnlockUserPassKey(user.passKey);
Expand Down Expand Up @@ -42,6 +42,7 @@ class UserRepository {
}

await userService.loginFinish(user);
if (user.id?.isEmpty ?? true) throw KeeInvalidStateException();
return user;
}
}
4 changes: 3 additions & 1 deletion lib/vault_backend/claim.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Claim {
int iat;
List<String> features;
int featureExpiry;
String? subscriptionId; // a missing ID indicates user has not finished setup

Claim.fromJson(Map<String, dynamic> data)
: sub = data['sub'],
Expand All @@ -14,5 +15,6 @@ class Claim {
exp = data['exp'],
iat = data['iat'],
featureExpiry = data['featureExpiry'],
features = List<String>.from(data['features']);
features = List<String>.from(data['features']),
subscriptionId = data['subscriptionId'];
}
14 changes: 7 additions & 7 deletions lib/vault_backend/storage_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class URLlist {
}

class StorageItem {
String emailHashed;
String userId;
int schemaVersion;
String? id;
String? location;
Expand All @@ -23,28 +23,28 @@ class StorageItem {
String? name;

StorageItem(
{required this.emailHashed,
{required this.userId,
this.id,
this.location,
this.name,
required this.schemaVersion,
required this.type,
this.urls});

static fromEmailHash(String emailHashed) {
return StorageItem(emailHashed: emailHashed, schemaVersion: 1, type: StorageType.keeS3);
static fromUserId(String userId) {
return StorageItem(userId: userId, schemaVersion: 1, type: StorageType.keeS3);
}

static fromEmailHashAndId(String emailHashed, String id) {
return StorageItem(emailHashed: emailHashed, schemaVersion: 1, type: StorageType.keeS3, id: id);
static fromUserIdAndId(String userId, String id) {
return StorageItem(userId: userId, schemaVersion: 1, type: StorageType.keeS3, id: id);
}

StorageItem.fromJson(Map<String, dynamic> data)
: id = data['id'],
name = data['name'],
location = data['location'],
type = data['type'],
emailHashed = data['emailHashed'],
userId = data['emailHashed'],
schemaVersion = data['schemaVersion'],
urls = URLlist.fromJson(data['urls']);
}
81 changes: 68 additions & 13 deletions lib/vault_backend/user.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'dart:math';

import 'package:shared_preferences/shared_preferences.dart';

import 'login_parameters.dart';
import 'account_verification_status.dart';
import 'features.dart';
Expand All @@ -9,33 +11,66 @@ import 'utils.dart';
class User {
String? email;
String? emailHashed;
String? emailHashedB64url;
String? id;
String? idB64url;
String? salt;
String? passKey;
List<String>? kms;
Features? features;
Tokens? tokens;
LoginParameters? loginParameters;
AccountVerificationStatus verificationStatus = AccountVerificationStatus.never;
String? subscriptionId;

// hashedMasterKey may come from a combination of password and keyfile in
// future but for now, we require a text password
static Future<User> fromEmailAndKey(String email, List<int> hashedMasterKey) async {
final user = User();
user.email = email;
user.passKey = await derivePassKey(email, hashedMasterKey);
user.emailHashed = await hashString(email, EMAIL_ID_SALT);
user.emailHashedB64url =
user.emailHashed!.replaceAll(RegExp(r'\+'), '-').replaceAll(RegExp(r'/'), '_').replaceAll(RegExp(r'='), '.');
return user;
}
// static Future<User> fromEmailAndKey(String email, List<int> hashedMasterKey) async {
// final user = User();
// user.email = email;
// user.passKey = await derivePassKey(email, hashedMasterKey);
// user.emailHashed = await hashString(email, EMAIL_ID_SALT);
// user.idB64url =
// user.emailHashed!.replaceAll(RegExp(r'\+'), '-').replaceAll(RegExp(r'/'), '_').replaceAll(RegExp(r'='), '.');
// return user;
// }

static Future<User> fromEmail(String email) async {
final user = User();
user.email = email;
final prefsFuture = SharedPreferences.getInstance();
user.emailHashed = await hashString(email, EMAIL_ID_SALT);
user.emailHashedB64url =
user.emailHashed!.replaceAll(RegExp(r'\+'), '-').replaceAll(RegExp(r'/'), '_').replaceAll(RegExp(r'='), '.');
final prefs = await prefsFuture;

// We need to persistently store the map of email address hash to user ID so that we can
// allow offline access in the increasingly common case that a user's ID is not that same
// as their hashed email address. When we "forget" a user, this mapping of non-personally
// identifiable information will remain on the device ready for if/when they sign in again
// in future.

// If a user changes email address and then another person signs up with it later, they
// would be able to use the original user's device to associate their sign-in credentials
// with the user ID of the original user. This can't result in abuse of either the KDBX
// file or Kee Vault authentication service because the remote server can see the real
// up to date relationship and the KDBX file can only be decrypted if the new user has
// selected the same password as the old user (in which case they could access the
// data in any number of alternative ways).
String? userId;
try {
userId = prefs.getString('user.authMaterialUserIdMap.${user.emailHashed}');
} on Exception {
// no action required
// User ID may not be known (e.g. if user has never signed in on this device before)
}

// On upgrade from an earlier version, subscribers may only have a locally stored copy of the email
// address, rather than their user ID, however, since they can't have modified their email address
// using the old version, we can safely assume their emailHashed still equals their user.emailHashed
// property. Perhaps someone upgrading a different device after an email address change will
// experience a problem but that'll be rare and signing out and back in again should resolve it
// for them since they'll then load the user id after loginFinish.
user.id = userId ?? user.emailHashed;

user.idB64url = user.id!.replaceAll(RegExp(r'\+'), '-').replaceAll(RegExp(r'/'), '_').replaceAll(RegExp(r'='), '.');
return user;
}

Expand All @@ -51,7 +86,14 @@ class User {
now - (86400 * 548 * 1000), DateTime.utc(2022, 4, 1).millisecondsSinceEpoch); // 18 months or 1st April 2022
if (subValidUntil >= now) {
return AccountSubscriptionStatus.current;
} else if (subValidUntil < newestSubExpiryAllowedForNewTrial) {
} else if (subValidUntil < newestSubExpiryAllowedForNewTrial &&
subscriptionSource == AccountSubscriptionSource.chargeBee) {
// We don't allow retrials for anything except Chargebee and user must have
// set up a subscription first. Thus users who get given a temporary subscription
// when registering can't later enable a Chargebee trial, even if they never
// completed their subscription setup from a different source. We'll render
// "create new subscription" features in future using knowledge of the current
// device platform rather than the user object.
return AccountSubscriptionStatus.freeTrialAvailable;
} else {
return AccountSubscriptionStatus.expired;
Expand All @@ -61,9 +103,22 @@ class User {
// a user that does not exist in the Kee Vault service
return AccountSubscriptionStatus.unknown;
}

AccountSubscriptionSource get subscriptionSource {
if (subscriptionId != null) {
if (subscriptionId!.startsWith('adhoc_')) {
return AccountSubscriptionSource.adHoc;
} else if (subscriptionId!.startsWith('cb_')) {
return AccountSubscriptionSource.chargeBee;
}
}
return AccountSubscriptionSource.unknown;
}
}

enum AccountSubscriptionStatus { unknown, current, expired, freeTrialAvailable }

enum AccountSubscriptionSource { unknown, adHoc, chargeBee }

// ignore: constant_identifier_names
const EMAIL_ID_SALT = 'a7d60f672fc7836e94dabbd7000f7ef4e5e72bfbc66ba4372add41d7d46a1c24';
Loading

0 comments on commit 311487f

Please sign in to comment.