Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/core/core_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:bb_mobile/core/bip85/bip85_locator.dart';
import 'package:bb_mobile/core/bitbox/bitbox_locator.dart';
import 'package:bb_mobile/core/blockchain/blockchain_locator.dart';
import 'package:bb_mobile/core/electrum/frameworks/di/electrum_locator.dart';
import 'package:bb_mobile/core/entropy/entropy_locator.dart';
import 'package:bb_mobile/core/exchange/exchange_locator.dart';
import 'package:bb_mobile/core/fees/fees_locator.dart';
import 'package:bb_mobile/features/labels/labels_facade.dart';
Expand Down Expand Up @@ -69,6 +70,9 @@ class CoreLocator {
}

static void registerServices(GetIt locator) {
// Entropy must be registered before SeedLocator because mnemonic
// generation depends on the pool and operating-system source.
EntropyLocator.setup(locator);
ExchangeLocator.registerServices(locator);
MempoolLocator.registerServices(locator);
SeedLocator.registerServices(locator);
Expand Down
181 changes: 181 additions & 0 deletions lib/core/entropy/data/services/entropy_pool.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:bb_mobile/core/errors/bull_exception.dart';
import 'package:crypto/crypto.dart';

/// Stateful SHA-512 combiner for wallet-generation entropy.
///
/// A fresh touch ceremony and a fresh operating-system CSPRNG draw are both
/// required for every extraction. Touch input is deliberately uncredited: it
/// is a physically distinct hedge against predictable RNG output, not a
/// claimed number of entropy bits.
///
/// Each operation hashes framed input together with the previous 256-bit
/// state. Extraction returns the first half of a SHA-512 digest and retains
/// the second half as the next secret state.
class EntropyPool {
static const stateSize = 32;
static const maxExtractBytes = 32;
static const minOsEntropyBytes = 32;
static const requiredTouchSamples = 500;

static const _touchBeginDomain = 'touch-ceremony-begin-v1';
static const _touchSampleDomain = 'touch-sample-v1';
static const _touchCompleteDomain = 'touch-ceremony-complete-v1';
static const _osRngDomain = 'os-rng-v1';

Uint8List _state = Uint8List(stateSize);
int _counter = 0;
int _ceremonyId = 0;
int _touchSamples = 0;
bool _ceremonyActive = false;
bool _ceremonyComplete = false;

/// Starts a new human-entropy session while retaining all prior pool state.
///
/// Starting again invalidates any unconsumed completion from an older
/// ceremony, so a completed gesture cannot be reused for a later attempt.
void beginTouchCeremony() {
_ceremonyId++;
_touchSamples = 0;
_ceremonyActive = true;
_ceremonyComplete = false;
_mixInternal(_touchBeginDomain, _encodeU64(_ceremonyId));
}

/// Mixes one serialized pointer sample into the active ceremony.
void mixTouchSample(Uint8List data) {
if (!_ceremonyActive || _ceremonyComplete) {
throw TouchEntropyCeremonyStateException(
'Touch entropy requires an active, incomplete ceremony',
);
}
if (data.isEmpty) {
throw ArgumentError.value(data, 'data', 'must not be empty');
}

_mixInternal(_touchSampleDomain, data);
_touchSamples++;
}

/// Marks the active ceremony ready for one extraction.
void completeTouchCeremony() {
if (!_ceremonyActive || _ceremonyComplete) {
throw TouchEntropyCeremonyStateException(
'No active touch entropy ceremony can be completed',
);
}
if (_touchSamples < requiredTouchSamples) {
throw TouchEntropyCeremonyIncompleteException(
collected: _touchSamples,
required: requiredTouchSamples,
);
}

_mixInternal(_touchCompleteDomain, _encodeU64(_touchSamples));
_ceremonyActive = false;
_ceremonyComplete = true;
}

/// Mixes fresh [osEntropy] and returns [length] bytes in one transaction.
///
/// The completed touch ceremony is consumed only after successful
/// extraction. There is no fallback when either required input is absent.
Uint8List extractWithOsEntropy(Uint8List osEntropy, int length) {
if (length <= 0 || length > maxExtractBytes) {
throw ArgumentError.value(
length,
'length',
'must be between 1 and $maxExtractBytes bytes',
);
}
if (!_ceremonyComplete) {
throw EntropyPoolNotReadyException(
'A completed touch entropy ceremony is required',
);
}
if (osEntropy.length < minOsEntropyBytes) {
throw OsEntropyTooShortException(osEntropy.length);
}

_mixInternal(_osRngDomain, osEntropy);

final input = BytesBuilder(copy: false)
..add(_encodeU64(_counter++))
..add(_state);
final buffer = input.takeBytes();
final digest = Uint8List.fromList(sha512.convert(buffer).bytes);
_zero(buffer);

final output = Uint8List.fromList(digest.sublist(0, length));
_replaceState(digest);
_zero(digest);

_ceremonyComplete = false;
_touchSamples = 0;
return output;
}

void _mixInternal(String domain, Uint8List data) {
final domainBytes = Uint8List.fromList(utf8.encode(domain));
final input = BytesBuilder(copy: false)
..add(_encodeU64(domainBytes.length))
..add(domainBytes)
..add(_encodeU64(_counter++))
..add(_encodeU64(data.length))
..add(data)
..add(_state);
final buffer = input.takeBytes();
final digest = Uint8List.fromList(sha512.convert(buffer).bytes);
_zero(buffer);

_replaceState(digest);
_zero(digest);
}

void _replaceState(Uint8List digest) {
assert(digest.length == 64);
final oldState = _state;
_state = Uint8List.fromList(digest.sublist(stateSize));
_zero(oldState);
}

static void _zero(Uint8List bytes) {
for (var i = 0; i < bytes.length; i++) {
bytes[i] = 0;
}
}

static Uint8List _encodeU64(int value) {
final bytes = Uint8List(8);
ByteData.view(bytes.buffer).setUint64(0, value);
return bytes;
}
}

class EntropyPoolNotReadyException extends BullException {
EntropyPoolNotReadyException(super.message);
}

class TouchEntropyCeremonyStateException extends BullException {
TouchEntropyCeremonyStateException(super.message);
}

class TouchEntropyCeremonyIncompleteException extends BullException {
TouchEntropyCeremonyIncompleteException({
required int collected,
required int required,
}) : super(
'Touch entropy ceremony has $collected samples; '
'$required are required',
);
}

class OsEntropyTooShortException extends BullException {
OsEntropyTooShortException(int length)
: super(
'Operating-system entropy source delivered $length bytes; '
'minimum is ${EntropyPool.minOsEntropyBytes}',
);
}
96 changes: 96 additions & 0 deletions lib/core/entropy/data/services/sources/os_rng_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import 'dart:math';
import 'dart:typed_data';

import 'package:bb_mobile/core/errors/bull_exception.dart';
import 'package:crypto/crypto.dart';

typedef OsEntropyProvider = Uint8List Function();

/// Produces a fresh draw from Dart's platform CSPRNG binding.
///
/// API failures, malformed draws, an all-identical draw, or an exact repeat
/// within this process abort generation. These checks catch catastrophic
/// failures only; they do not attempt to statistically certify randomness.
class OsRngSource {
OsRngSource({OsEntropyProvider? provider})
: _provider = provider ?? _secureBytes;

static const bytesPerDraw = 64;

final OsEntropyProvider _provider;
Uint8List? _lastDigest;

Future<Uint8List> collect() async {
final bytes = _provider();
try {
if (bytes.length != bytesPerDraw) {
throw OsEntropyLengthException(bytes.length);
}
if (_allBytesEqual(bytes)) {
throw OsEntropySanityException(
'Operating-system entropy draw contained one repeated byte value',
);
}

final digest = Uint8List.fromList(sha256.convert(bytes).bytes);
final previous = _lastDigest;
if (previous != null && _equal(previous, digest)) {
_zero(digest);
throw OsEntropySanityException(
'Operating-system entropy draw exactly repeated in this process',
);
}

if (previous != null) _zero(previous);
_lastDigest = digest;
return bytes;
} catch (_) {
_zero(bytes);
rethrow;
}
}

static Uint8List _secureBytes() {
final random = Random.secure();
final bytes = Uint8List(bytesPerDraw);
for (var i = 0; i < bytes.length; i++) {
bytes[i] = random.nextInt(256);
}
return bytes;
}

static bool _allBytesEqual(Uint8List bytes) {
final first = bytes.first;
for (var i = 1; i < bytes.length; i++) {
if (bytes[i] != first) return false;
}
return true;
}

static bool _equal(Uint8List left, Uint8List right) {
if (left.length != right.length) return false;
var difference = 0;
for (var i = 0; i < left.length; i++) {
difference |= left[i] ^ right[i];
}
return difference == 0;
}

static void _zero(Uint8List bytes) {
for (var i = 0; i < bytes.length; i++) {
bytes[i] = 0;
}
}
}

class OsEntropyLengthException extends BullException {
OsEntropyLengthException(int length)
: super(
'Operating-system entropy source delivered $length bytes; '
'expected ${OsRngSource.bytesPerDraw}',
);
}

class OsEntropySanityException extends BullException {
OsEntropySanityException(super.message);
}
18 changes: 18 additions & 0 deletions lib/core/entropy/domain/usecases/mix_entropy_usecase.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'dart:typed_data';

import 'package:bb_mobile/core/entropy/data/services/entropy_pool.dart';

/// Owns the touch-ceremony lifecycle exposed to onboarding.
class MixEntropyUsecase {
const MixEntropyUsecase({required this._entropyPool});

static const requiredSampleCount = EntropyPool.requiredTouchSamples;

final EntropyPool _entropyPool;

void begin() => _entropyPool.beginTouchCeremony();

void execute(Uint8List data) => _entropyPool.mixTouchSample(data);

void complete() => _entropyPool.completeTouchCeremony();
}
18 changes: 18 additions & 0 deletions lib/core/entropy/entropy_locator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:bb_mobile/core/entropy/data/services/entropy_pool.dart';
import 'package:bb_mobile/core/entropy/data/services/sources/os_rng_source.dart';
import 'package:bb_mobile/core/entropy/domain/usecases/mix_entropy_usecase.dart';
import 'package:get_it/get_it.dart';

class EntropyLocator {
static void setup(GetIt locator) {
// The pool is process-wide so its secret state survives across creation
// attempts for the lifetime of the app process.
locator.registerLazySingleton<EntropyPool>(() => EntropyPool());

locator.registerLazySingleton<OsRngSource>(() => OsRngSource());

locator.registerFactory<MixEntropyUsecase>(
() => MixEntropyUsecase(entropyPool: locator<EntropyPool>()),
);
}
}
Loading
Loading