import 'package:crypton/crypton.dart'; import 'package:flutter/foundation.dart'; import 'push_secure_storage.dart'; /// An RSA-2048 keypair exported as PEM strings ready for storage and for the /// Nextcloud push-v2 registration. @immutable class PushKeypairPems { /// PKCS#1 private-key PEM (`-----BEGIN RSA PRIVATE KEY-----`). final String privateKeyPem; /// SPKI public-key PEM in Nextcloud's expected shape: base64 wrapped at 64 /// characters per line. For RSA-2048 this is exactly 450 or 451 characters. final String publicKeyPem; const PushKeypairPems({ required this.privateKeyPem, required this.publicKeyPem, }); } /// Generates a fresh keypair. Pure and synchronous so it can be run both inside /// an isolate ([PushKeypair.generate]) and directly from unit tests. The public /// PEM uses [RSAPublicKey.toFormattedPEM] which produces the 64-column SPKI /// layout Nextcloud validates against. PushKeypairPems generatePushKeypairPems() { final keypair = RSAKeypair.fromRandom(); return PushKeypairPems( privateKeyPem: keypair.privateKey.toPEM(), publicKeyPem: keypair.publicKey.toFormattedPEM(), ); } PushKeypairPems _generateInIsolate(void _) => generatePushKeypairPems(); /// Persists and lazily generates the device RSA keypair used for push /// encryption. The private key never leaves the secure keystore; the public /// key PEM is what gets registered with Nextcloud. class PushKeypair { static const _privateKeyKey = 'push_device_private_key_pem'; static const _publicKeyKey = 'push_device_public_key_pem'; final FlutterSecureStorageLike _storage; const PushKeypair({FlutterSecureStorageLike? storage}) : _storage = storage ?? const _DefaultStorage(); /// Returns the stored keypair PEMs, generating and persisting a fresh keypair /// on first use. Generation is offloaded to an isolate because RSA-2048 key /// generation blocks the UI thread for a noticeable moment. Future ensure() async { final existing = await _load(); if (existing != null) return existing; final generated = await compute(_generateInIsolate, null); await _storage.write(key: _privateKeyKey, value: generated.privateKeyPem); await _storage.write(key: _publicKeyKey, value: generated.publicKeyPem); return generated; } /// Loads the stored private key, or `null` when no keypair exists yet. Future loadPrivateKey() async { final pem = await _storage.read(key: _privateKeyKey); if (pem == null || pem.isEmpty) return null; return RSAPrivateKey.fromPEM(pem); } Future loadPublicKeyPem() => _storage.read(key: _publicKeyKey); Future clear() async { await _storage.delete(key: _privateKeyKey); await _storage.delete(key: _publicKeyKey); } Future _load() async { final priv = await _storage.read(key: _privateKeyKey); final pub = await _storage.read(key: _publicKeyKey); if (priv == null || priv.isEmpty || pub == null || pub.isEmpty) return null; return PushKeypairPems(privateKeyPem: priv, publicKeyPem: pub); } } /// Minimal storage contract so tests can inject an in-memory fake instead of /// touching the platform keystore. abstract class FlutterSecureStorageLike { Future read({required String key}); Future write({required String key, required String? value}); Future delete({required String key}); } class _DefaultStorage implements FlutterSecureStorageLike { const _DefaultStorage(); @override Future read({required String key}) => pushSecureStorage.read(key: key); @override Future write({required String key, required String? value}) => pushSecureStorage.write(key: key, value: value); @override Future delete({required String key}) => pushSecureStorage.delete(key: key); }