From 41a382d21864a26ab8b4fcabd89401d4f88260c0 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 29 Apr 2021 16:39:02 -0500 Subject: [PATCH] start --- .../defined/mobile_nebula/NebulaVpnService.kt | 4 +- .../kotlin/net/defined/mobile_nebula/Sites.kt | 131 +++++++++++---- lib/components/SiteItem.dart | 4 +- lib/components/SpecialSelectableText.dart | 2 +- lib/components/config/ConfigHeader.dart | 2 +- lib/models/Certificate.dart | 16 +- lib/models/IPAndPort.dart | 2 - lib/models/Site.dart | 36 ++-- lib/screens/HostInfoScreen.dart | 2 +- lib/screens/MainScreen.dart | 16 +- ...eScreen.dart => AddCertificateScreen.dart} | 157 ++++++------------ lib/screens/siteConfig/CAListScreen.dart | 2 +- .../siteConfig/CertificateDetailsScreen.dart | 82 ++++++--- .../siteConfig/CertificatesScreen.dart | 115 +++++++++++++ lib/screens/siteConfig/SiteConfigScreen.dart | 43 +++-- 15 files changed, 403 insertions(+), 211 deletions(-) rename lib/screens/siteConfig/{CertificateScreen.dart => AddCertificateScreen.dart} (60%) create mode 100644 lib/screens/siteConfig/CertificatesScreen.dart diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt index b75f3bb..9f80d34 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt @@ -59,7 +59,7 @@ class NebulaVpnService : VpnService() { // Link active site config in Main to avoid this site = Site(File(path)) - if (site!!.cert == null) { + if (site!!.certInfos == null || site!!.certInfos.isEmpty()) { announceExit(id, "Site is missing a certificate") //TODO: can we signal failure? return super.onStartCommand(intent, flags, startId) @@ -74,7 +74,7 @@ class NebulaVpnService : VpnService() { var ipNet: CIDR try { - ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.cert!!.cert.details.ips[0]) + ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.primaryCertInfo!!.cert.details.ips[0]) } catch (err: Exception) { return announceExit(site!!.id, err.message ?: "$err") } diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt index 5046744..70136dc 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt @@ -46,7 +46,7 @@ class Sites(private var engine: FlutterEngine) { this.sites[site.id] = SiteContainer(site, updater) } catch (err: Exception) { - siteDir.deleteRecursively() +// siteDir.deleteRecursively() Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err) } } @@ -101,9 +101,10 @@ class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.S } data class CertificateInfo( - @SerializedName("Cert") val cert: Certificate, - @SerializedName("RawCert") val rawCert: String, - @SerializedName("Validity") val validity: CertificateValidity + @SerializedName("Cert") val cert: Certificate, + @SerializedName("RawCert") val rawCert: String, + @SerializedName("Validity") val validity: CertificateValidity, + var primary: Boolean ) data class Certificate( @@ -134,8 +135,8 @@ class Site { val id: String val staticHostmap: HashMap val unsafeRoutes: List - var cert: CertificateInfo? = null - var ca: Array + val certInfos: ArrayList = ArrayList() + lateinit var caInfos: Array val lhDuration: Int val port: Int val mtu: Int @@ -154,6 +155,9 @@ class Site { // Strong representation of the site config @Expose(serialize = false) val config: String + + @Expose(serialize = false) + lateinit var primaryCertInfo: CertificateInfo constructor(siteDir: File) { val gson = Gson() @@ -176,26 +180,23 @@ class Site { connected = false status = "Disconnected" - try { - val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert) - val certs = gson.fromJson(rawDetails, Array::class.java) - if (certs.isEmpty()) { - throw IllegalArgumentException("No certificate found") + incomingSite.certs?.forEach { certContainer -> + val certInfo = getCertDetails(certContainer.cert, gson) ?: return + certInfo.primary = certContainer.primary + if (certInfo.primary) { + this.primaryCertInfo = certInfo } - cert = certs[0] - if (!cert!!.validity.valid) { - errors.add("Certificate is invalid: ${cert!!.validity.reason}") - } - - } catch (err: Exception) { - errors.add("Error while loading certificate: ${err.message}") + this.certInfos.add(certInfo) } + // Upgrade the old cert property if present + upgradeCert(incomingSite, gson) + try { val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca) - ca = gson.fromJson(rawCa, Array::class.java) + caInfos = gson.fromJson(rawCa, Array::class.java) var hasErrors = false - ca.forEach { + caInfos.forEach { if (!it.validity.valid) { hasErrors = true } @@ -206,7 +207,7 @@ class Site { } } catch (err: Exception) { - ca = arrayOf() + caInfos = arrayOf() errors.add("Error while loading certificate authorities: ${err.message}") } @@ -219,8 +220,65 @@ class Site { } } + // Upgrades cert -> certs in the stored site config if needed + private fun upgradeCert(site: IncomingSite, gson: Gson) { + if (site.cert == null) { + // Nothing to do + return + } + + val context = MainActivity.getContext()!! + // Try to get the details + val certInfo = getCertDetails(site.cert!!, gson) ?: return + + // Push this cert in as the primary certificate + certInfo.primary + certInfos.add(certInfo) + + // Upgrade the persisted object + site.cert = null + site.certs = arrayListOf(IncomingCert(certInfo.cert.fingerprint, certInfo.rawCert,true, null)) + + // Get the old key contents and delete the key + val oldKeyPath = File(path).resolve("key") + val oldKeyFile = EncFile(context).openRead(oldKeyPath) + val key = oldKeyFile.readText() + oldKeyFile.close() + oldKeyPath.delete() + // /data/data/net.defined.mobile_nebula/files/sites/8554c7fc-c2a5-4cba-9bc4-fe6e0eb3129a + + // Write to the new path + val newKeyFile = EncFile(context).openWrite(File(path).resolve("key.${certInfo.cert.fingerprint}")) + newKeyFile.use { it.write(key) } + newKeyFile.close() + + site.save(context) + } + + private fun getCertDetails(rawCert: String, gson: Gson): CertificateInfo? { + var cert: CertificateInfo? = null + try { + val rawDetails = mobileNebula.MobileNebula.parseCerts(rawCert) + val certs = gson.fromJson(rawDetails, Array::class.java) + if (certs.isEmpty()) { + throw IllegalArgumentException("No certificate found") + } + + cert = certs[0] + + if (!cert.validity.valid) { + errors.add("Certificate is invalid: ${cert.validity.reason}") + } + + } catch (err: Exception) { + errors.add("Error while loading certificate: ${err.message}") + } + + return cert + } + fun getKey(context: Context): String? { - val f = EncFile(context).openRead(File(path).resolve("key")) + val f = EncFile(context).openRead(File(path).resolve("key.${primaryCertInfo.cert.fingerprint}")) val k = f.readText() f.close() return k @@ -238,12 +296,21 @@ data class UnsafeRoute( val mtu: Int? ) +data class IncomingCert( + // fingerprint is the cert fingerprint, only used as part of the key file name + val fingerprint: String, + val cert: String, + val primary: Boolean, + @Expose(serialize = false) + var key: String? +) + class IncomingSite( val name: String, val id: String, val staticHostmap: HashMap, val unsafeRoutes: List?, - val cert: String, + var certs: List?, val ca: String, val lhDuration: Int, val port: Int, @@ -251,8 +318,9 @@ class IncomingSite( val cipher: String, val sortKey: Int?, var logVerbosity: String?, - @Expose(serialize = false) - var key: String? + + @Deprecated("certs is the new property") + var cert: String? ) { fun save(context: Context) { @@ -261,13 +329,16 @@ class IncomingSite( siteDir.mkdir() } - if (key != null) { - val f = EncFile(context).openWrite(siteDir.resolve("key")) - f.use { it.write(key) } - f.close() + certs?.forEach { cert -> + if (cert.key != null) { + val f = EncFile(context).openWrite(siteDir.resolve("key.${cert.fingerprint}")) + f.use { it.write(cert.key) } + f.close() + } + + cert.key = null } - key = null val gson = Gson() val confFile = siteDir.resolve("config.json") confFile.writeText(gson.toJson(this)) diff --git a/lib/components/SiteItem.dart b/lib/components/SiteItem.dart index 8048e0f..b949e75 100644 --- a/lib/components/SiteItem.dart +++ b/lib/components/SiteItem.dart @@ -28,8 +28,8 @@ class SiteItem extends StatelessWidget { Widget _buildContent(BuildContext context) { final border = BorderSide(color: Utils.configSectionBorder(context)); var ip = "Error"; - if (site.cert != null && site.cert.cert.details.ips.length > 0) { - ip = site.cert.cert.details.ips[0]; + if (site.primaryCertInfo.cert != null && site.primaryCertInfo.cert.details.ips.length > 0) { + ip = site.primaryCertInfo.cert.details.ips[0]; } return SpecialButton( diff --git a/lib/components/SpecialSelectableText.dart b/lib/components/SpecialSelectableText.dart index 365b4a6..42bbc44 100644 --- a/lib/components/SpecialSelectableText.dart +++ b/lib/components/SpecialSelectableText.dart @@ -620,7 +620,7 @@ class _SpecialSelectableTextState extends State with Auto toolbarOptions: widget.toolbarOptions, minLines: widget.minLines, maxLines: widget.maxLines ?? defaultTextStyle.maxLines, - selectionColor: themeData.textSelectionColor, + selectionColor: TextSelectionTheme.of(context).selectionColor, selectionControls: widget.selectionEnabled ? textSelectionControls : null, onSelectionChanged: _handleSelectionChanged, onSelectionHandleTapped: _handleSelectionHandleTapped, diff --git a/lib/components/config/ConfigHeader.dart b/lib/components/config/ConfigHeader.dart index 3a51fb4..7e572de 100644 --- a/lib/components/config/ConfigHeader.dart +++ b/lib/components/config/ConfigHeader.dart @@ -4,7 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; TextStyle basicTextStyle(BuildContext context) => - Platform.isIOS ? CupertinoTheme.of(context).textTheme.textStyle : Theme.of(context).textTheme.subhead; + Platform.isIOS ? CupertinoTheme.of(context).textTheme.textStyle : Theme.of(context).textTheme.subtitle1; const double _headerFontSize = 13.0; diff --git a/lib/models/Certificate.dart b/lib/models/Certificate.dart index 64bffc2..1b71c7c 100644 --- a/lib/models/Certificate.dart +++ b/lib/models/Certificate.dart @@ -2,6 +2,10 @@ class CertificateInfo { Certificate cert; String rawCert; CertificateValidity validity; + bool primary; + + // Key is only present when a new certificate is being installed, provided to the backend by the UI + String key; CertificateInfo.debug({this.rawCert = ""}) : this.cert = Certificate.debug(), @@ -10,6 +14,7 @@ class CertificateInfo { CertificateInfo.fromJson(Map json) : cert = Certificate.fromJson(json['Cert']), rawCert = json['RawCert'], + primary = json['primary'], validity = CertificateValidity.fromJson(json['Validity']); CertificateInfo({this.cert, this.rawCert, this.validity}); @@ -17,6 +22,15 @@ class CertificateInfo { static List fromJsonList(List list) { return list.map((v) => CertificateInfo.fromJson(v)); } + + Map toJson() { + return { + 'cert': rawCert, + 'key': key, + 'primary': primary, + 'fingerprint': cert.fingerprint + }; + } } class Certificate { @@ -80,4 +94,4 @@ class CertificateValidity { CertificateValidity.fromJson(Map json) : valid = json['Valid'], reason = json['Reason']; -} +} \ No newline at end of file diff --git a/lib/models/IPAndPort.dart b/lib/models/IPAndPort.dart index e1f1935..76de2d5 100644 --- a/lib/models/IPAndPort.dart +++ b/lib/models/IPAndPort.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - class IPAndPort { String ip; int port; diff --git a/lib/models/Site.dart b/lib/models/Site.dart index 1d3606a..abe38e4 100644 --- a/lib/models/Site.dart +++ b/lib/models/Site.dart @@ -26,9 +26,9 @@ class Site { List unsafeRoutes; // pki fields - List ca; - CertificateInfo cert; - String key; + List caInfos; + List certInfos; + CertificateInfo primaryCertInfo; // lighthouse options int lhDuration; // in seconds @@ -51,8 +51,8 @@ class Site { {this.name, id, staticHostmap, - ca, - this.cert, + caInfos, + certInfos, this.lhDuration = 0, this.port = 0, this.cipher = "aes", @@ -67,7 +67,8 @@ class Site { : staticHostmap = staticHostmap ?? {}, unsafeRoutes = unsafeRoutes ?? [], errors = errors ?? [], - ca = ca ?? [], + caInfos = caInfos ?? [], + certInfos = certInfos ?? [], id = id ?? uuid.v4(); Site.fromJson(Map json) { @@ -88,15 +89,21 @@ class Site { }); } - List rawCA = json['ca']; - ca = []; + List rawCA = json['caInfos']; + caInfos = []; rawCA.forEach((val) { - ca.add(CertificateInfo.fromJson(val)); + caInfos.add(CertificateInfo.fromJson(val)); }); - if (json['cert'] != null) { - cert = CertificateInfo.fromJson(json['cert']); - } + List rawCerts = json['certInfos']; + certInfos = []; + rawCerts.forEach((val) { + final certInfo = CertificateInfo.fromJson(val); + if (certInfo.primary) { + primaryCertInfo = certInfo; + } + certInfos.add(certInfo); + }); lhDuration = json['lhDuration']; port = json['port']; @@ -142,12 +149,11 @@ class Site { 'id': id, 'staticHostmap': staticHostmap, 'unsafeRoutes': unsafeRoutes, - 'ca': ca?.map((cert) { + 'ca': caInfos?.map((cert) { return cert.rawCert; })?.join('\n') ?? "", - 'cert': cert?.rawCert, - 'key': key, + 'certs': certInfos, 'lhDuration': lhDuration, 'port': port, 'mtu': mtu, diff --git a/lib/screens/HostInfoScreen.dart b/lib/screens/HostInfoScreen.dart index 6f8f440..6074f66 100644 --- a/lib/screens/HostInfoScreen.dart +++ b/lib/screens/HostInfoScreen.dart @@ -68,7 +68,7 @@ class _HostInfoScreenState extends State { labelWidth: 150, content: Text(hostInfo.cert.details.name), onPressed: () => Utils.openPage( - context, (context) => CertificateDetailsScreen(certificate: CertificateInfo(cert: hostInfo.cert)))) + context, (context) => CertificateDetailsScreen(CertificateInfo(cert: hostInfo.cert)))) : Container(), ]); } diff --git a/lib/screens/MainScreen.dart b/lib/screens/MainScreen.dart index 94de84b..a03b5ee 100644 --- a/lib/screens/MainScreen.dart +++ b/lib/screens/MainScreen.dart @@ -73,7 +73,7 @@ class _MainScreenState extends State { if (!ready) { return Center( child: PlatformCircularProgressIndicator(cupertino: (_, __) { - return CupertinoProgressIndicatorData(radius: 50); + return CupertinoProgressIndicatorData(radius: 500); }), ); } @@ -172,21 +172,23 @@ CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= -----END NEBULA CERTIFICATE-----'''; + var certInfo = CertificateInfo.debug(rawCert: cert); + certInfo.primary = true; + certInfo.key = '''-----BEGIN NEBULA X25519 PRIVATE KEY----- +rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= +-----END NEBULA X25519 PRIVATE KEY-----'''; + var s = Site( name: "DEBUG TEST", id: uuid.v4(), staticHostmap: { "10.1.0.1": StaticHost(lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)]) }, - ca: [CertificateInfo.debug(rawCert: ca)], - cert: CertificateInfo.debug(rawCert: cert), + caInfos: [CertificateInfo.debug(rawCert: ca)], + certInfos: [certInfo], unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')] ); - s.key = '''-----BEGIN NEBULA X25519 PRIVATE KEY----- -rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= ------END NEBULA X25519 PRIVATE KEY-----'''; - var err = await s.save(); if (err != null) { Utils.popError(context, "Failed to save the site", err); diff --git a/lib/screens/siteConfig/CertificateScreen.dart b/lib/screens/siteConfig/AddCertificateScreen.dart similarity index 60% rename from lib/screens/siteConfig/CertificateScreen.dart rename to lib/screens/siteConfig/AddCertificateScreen.dart index dcf11ca..f089c3b 100644 --- a/lib/screens/siteConfig/CertificateScreen.dart +++ b/lib/screens/siteConfig/AddCertificateScreen.dart @@ -1,56 +1,47 @@ import 'dart:convert'; import 'package:barcode_scan/barcode_scan.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/components/SpecialSelectableText.dart'; import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart'; -import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/components/config/ConfigTextItem.dart'; import 'package:mobile_nebula/models/Certificate.dart'; -import 'package:mobile_nebula/screens/siteConfig/CertificateDetailsScreen.dart'; import 'package:mobile_nebula/services/share.dart'; import 'package:mobile_nebula/services/utils.dart'; -class CertificateResult { - CertificateInfo cert; - String key; +import 'CertificateDetailsScreen.dart'; - CertificateResult({this.cert, this.key}); -} +class AddCertificateScreen extends StatefulWidget { + const AddCertificateScreen({Key key, this.onSave, this.choosePrimary = false}) : super(key: key); -class CertificateScreen extends StatefulWidget { - const CertificateScreen({Key key, this.cert, this.onSave}) : super(key: key); - - final CertificateInfo cert; - final ValueChanged onSave; + final ValueChanged onSave; + final choosePrimary; @override - _CertificateScreenState createState() => _CertificateScreenState(); + _AddCertificateScreenState createState() => _AddCertificateScreenState(); } -class _CertificateScreenState extends State { +class _AddCertificateScreenState extends State { String pubKey; String privKey; - bool changed = false; - CertificateInfo cert; + CertificateInfo certInfo; String inputType = 'paste'; - bool shared = false; final pasteController = TextEditingController(); static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); @override void initState() { - cert = widget.cert; + _generateKeys(); super.initState(); } @@ -62,71 +53,30 @@ class _CertificateScreenState extends State { @override Widget build(BuildContext context) { - List items = []; - bool hideSave = true; - - if (cert == null) { - if (pubKey == null) { - items = _buildGenerate(); - } else { - items.addAll(_buildShare()); - items.addAll(_buildLoadCert()); - } - } else { - items.addAll(_buildCertList()); - hideSave = false; + if (pubKey == null) { + return Center( + child: PlatformCircularProgressIndicator(cupertino: (_, __) { + return CupertinoProgressIndicatorData(radius: 500); + }), + ); } - return FormPage( + List items = []; + + items.addAll(_buildShare()); + items.addAll(_buildLoadCert()); + + return SimplePage( title: 'Certificate', - changed: changed, - hideSave: hideSave, - onSave: () { - Navigator.pop(context); - if (widget.onSave != null) { - widget.onSave(CertificateResult(cert: cert, key: privKey)); - } - }, child: Column(children: items)); } - _buildCertList() { - //TODO: generate a full list - return [ - ConfigSection( - children: [ - ConfigPageItem( - content: Text(cert.cert.details.name), - onPressed: () { - Utils.openPage(context, (context) { - //TODO: wire on delete - return CertificateDetailsScreen(certificate: cert); - }); - }, - ) - ], - ) - ]; - } - - List _buildGenerate() { - return [ - ConfigSection(label: 'Please generate a new public and private key', children: [ - ConfigButtonItem( - content: Text('Generate Keys'), - onPressed: () => _generateKeys(), - ) - ]) - ]; - } - _generateKeys() async { try { var kp = await platform.invokeMethod("nebula.generateKeyPair"); Map keyPair = jsonDecode(kp); setState(() { - changed = true; pubKey = keyPair['PublicKey']; privKey = keyPair['PrivateKey']; }); @@ -148,9 +98,6 @@ class _CertificateScreenState extends State { content: Text('Share Public Key'), onPressed: () async { await Share.share(title: 'Please sign and return a certificate', text: pubKey, filename: 'device.pub'); - setState(() { - shared = true; - }); }, ), ]) @@ -198,14 +145,7 @@ class _CertificateScreenState extends State { ConfigButtonItem( content: Center(child: Text('Load Certificate')), onPressed: () { - _addCertEntry(pasteController.text, (err) { - if (err != null) { - return Utils.popError(context, 'Failed to parse certificate content', err); - } - - pasteController.text = ''; - setState(() {}); - }); + _addCertEntry(pasteController.text); }), ], ) @@ -225,13 +165,7 @@ class _CertificateScreenState extends State { return; } - _addCertEntry(content, (err) { - if (err != null) { - Utils.popError(context, 'Error loading certificate file', err); - } else { - setState(() {}); - } - }); + _addCertEntry(content); } catch (err) { return Utils.popError(context, 'Failed to load certificate file', err.toString()); } @@ -254,13 +188,7 @@ class _CertificateScreenState extends State { var result = await BarcodeScanner.scan(options: options); if (result.rawContent != "") { - _addCertEntry(result.rawContent, (err) { - if (err != null) { - Utils.popError(context, 'Error loading certificate content', err); - } else { - setState(() {}); - } - }); + _addCertEntry(result.rawContent); } }), ], @@ -268,9 +196,7 @@ class _CertificateScreenState extends State { ]; } - _addCertEntry(String rawCert, ValueChanged callback) async { - String error; - + _addCertEntry(String rawCert) async { // Allow for app store review testing cert to override the generated key if (rawCert.trim() == _testCert) { privKey = _testKey; @@ -280,20 +206,31 @@ class _CertificateScreenState extends State { var rawCerts = await platform.invokeMethod("nebula.parseCerts", {"certs": rawCert}); List certs = jsonDecode(rawCerts); if (certs.length > 0) { - var tryCert = CertificateInfo.fromJson(certs.first); - if (tryCert.cert.details.isCa) { - return callback('A certificate authority is not appropriate for a client certificate.'); + var tryCertInfo = CertificateInfo.fromJson(certs.first); + if (tryCertInfo.cert.details.isCa) { + return Utils.popError(context, 'Error loading certificate content', 'A certificate authority is not appropriate for a client certificate.'); + } else if (!tryCertInfo.validity.valid) { + return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity.reason); } - //TODO: test that the pubkey matches the privkey - cert = tryCert; + + //TODO: test that the pubkey we generated equals the pub key in the cert + certInfo = tryCertInfo; + certInfo.primary = !widget.choosePrimary; + certInfo.key = privKey; } } on PlatformException catch (err) { - error = err.details ?? err.message; + return Utils.popError(context, 'Error loading certificate content', err.details ?? err.message); } - if (callback != null) { - callback(error); - } + // We have a cert, pop this screen and replace it with the cert detail screen and a save dialog + Utils.openPage(context, (context) { + //TODO: thread on save + return CertificateDetailsScreen(certInfo, newCert: true, choosePrimary: widget.choosePrimary, onSave: (isPrimary) { + Navigator.pop(context); + certInfo.primary = isPrimary; + widget.onSave(certInfo); + }); + }); } } diff --git a/lib/screens/siteConfig/CAListScreen.dart b/lib/screens/siteConfig/CAListScreen.dart index 06cdbcf..e93d482 100644 --- a/lib/screens/siteConfig/CAListScreen.dart +++ b/lib/screens/siteConfig/CAListScreen.dart @@ -77,7 +77,7 @@ class _CAListScreenState extends State { onPressed: () { Utils.openPage(context, (context) { return CertificateDetailsScreen( - certificate: ca, + ca, onDelete: () { setState(() { changed = true; diff --git a/lib/screens/siteConfig/CertificateDetailsScreen.dart b/lib/screens/siteConfig/CertificateDetailsScreen.dart index 313fc5f..909a678 100644 --- a/lib/screens/siteConfig/CertificateDetailsScreen.dart +++ b/lib/screens/siteConfig/CertificateDetailsScreen.dart @@ -2,30 +2,50 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; import 'package:mobile_nebula/components/SpecialSelectableText.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart'; import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/models/Certificate.dart'; import 'package:mobile_nebula/services/utils.dart'; +//TODO: add a primary toggle if we have multiple sites + /// Displays the details of a CertificateInfo object. Respects incomplete objects (missing validity or rawCert) class CertificateDetailsScreen extends StatefulWidget { - const CertificateDetailsScreen({Key key, this.certificate, this.onDelete}) : super(key: key); + const CertificateDetailsScreen(this.certInfo, {Key key, this.onDelete, this.onSave, this.newCert = false, this.choosePrimary = false}) : super(key: key); - final CertificateInfo certificate; + final CertificateInfo certInfo; final Function onDelete; + final ValueChanged onSave; + final bool newCert; + final bool choosePrimary; @override _CertificateDetailsScreenState createState() => _CertificateDetailsScreenState(); } class _CertificateDetailsScreenState extends State { + bool primary; + bool changed = false; + + @override + void initState() { + primary = widget.certInfo.primary ?? false; + super.initState(); + } + @override Widget build(BuildContext context) { - return SimplePage( + return FormPage( title: 'Certificate Details', + onSave: () { + Navigator.pop(context); + widget.onSave(widget.choosePrimary == true ? primary : false); + }, + changed: widget.newCert || changed, child: Column(children: [ + widget.choosePrimary ? _buildPrimaryChooser() : Container(), _buildID(), _buildFilters(), _buildValid(), @@ -35,19 +55,37 @@ class _CertificateDetailsScreenState extends State { ); } + Widget _buildPrimaryChooser() { + return ConfigSection(children: [ + ConfigItem( + label: Text('Primary Certificate'), + content: Container( + alignment: Alignment.centerRight, + child: Switch.adaptive( + value: primary, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) { + setState(() { + changed = true; + primary = v; + }); + })), + )]); + } + Widget _buildID() { return ConfigSection(children: [ - ConfigItem(label: Text('Name'), content: SpecialSelectableText(widget.certificate.cert.details.name)), + ConfigItem(label: Text('Name'), content: SpecialSelectableText(widget.certInfo.cert.details.name)), ConfigItem( label: Text('Type'), - content: Text(widget.certificate.cert.details.isCa ? 'CA certificate' : 'Client certificate')), + content: Text(widget.certInfo.cert.details.isCa ? 'CA certificate' : 'Client certificate')), ]); } Widget _buildValid() { var valid = Text('yes'); - if (widget.certificate.validity != null && !widget.certificate.validity.valid) { - valid = Text(widget.certificate.validity.valid ? 'yes' : widget.certificate.validity.reason, + if (widget.certInfo.validity != null && !widget.certInfo.validity.valid) { + valid = Text(widget.certInfo.validity.valid ? 'yes' : widget.certInfo.validity.reason, style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context))); } return ConfigSection( @@ -56,33 +94,33 @@ class _CertificateDetailsScreenState extends State { ConfigItem(label: Text('Valid?'), content: valid), ConfigItem( label: Text('Created'), - content: SpecialSelectableText(widget.certificate.cert.details.notBefore.toLocal().toString())), + content: SpecialSelectableText(widget.certInfo.cert.details.notBefore.toLocal().toString())), ConfigItem( label: Text('Expires'), - content: SpecialSelectableText(widget.certificate.cert.details.notAfter.toLocal().toString())), + content: SpecialSelectableText(widget.certInfo.cert.details.notAfter.toLocal().toString())), ], ); } Widget _buildFilters() { List items = []; - if (widget.certificate.cert.details.groups.length > 0) { + if (widget.certInfo.cert.details.groups.length > 0) { items.add(ConfigItem( - label: Text('Groups'), content: SpecialSelectableText(widget.certificate.cert.details.groups.join(', ')))); + label: Text('Groups'), content: SpecialSelectableText(widget.certInfo.cert.details.groups.join(', ')))); } - if (widget.certificate.cert.details.ips.length > 0) { + if (widget.certInfo.cert.details.ips.length > 0) { items - .add(ConfigItem(label: Text('IPs'), content: SpecialSelectableText(widget.certificate.cert.details.ips.join(', ')))); + .add(ConfigItem(label: Text('IPs'), content: SpecialSelectableText(widget.certInfo.cert.details.ips.join(', ')))); } - if (widget.certificate.cert.details.subnets.length > 0) { + if (widget.certInfo.cert.details.subnets.length > 0) { items.add(ConfigItem( - label: Text('Subnets'), content: SpecialSelectableText(widget.certificate.cert.details.subnets.join(', ')))); + label: Text('Subnets'), content: SpecialSelectableText(widget.certInfo.cert.details.subnets.join(', ')))); } return items.length > 0 - ? ConfigSection(label: widget.certificate.cert.details.isCa ? 'FILTERS' : 'DETAILS', children: items) + ? ConfigSection(label: widget.certInfo.cert.details.isCa ? 'FILTERS' : 'DETAILS', children: items) : Container(); } @@ -91,18 +129,18 @@ class _CertificateDetailsScreenState extends State { children: [ ConfigItem( label: Text('Fingerprint'), - content: SpecialSelectableText(widget.certificate.cert.fingerprint, + content: SpecialSelectableText(widget.certInfo.cert.fingerprint, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), crossAxisAlignment: CrossAxisAlignment.start), ConfigItem( label: Text('Public Key'), - content: SpecialSelectableText(widget.certificate.cert.details.publicKey, + content: SpecialSelectableText(widget.certInfo.cert.details.publicKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), crossAxisAlignment: CrossAxisAlignment.start), - widget.certificate.rawCert != null + widget.certInfo.rawCert != null ? ConfigItem( label: Text('PEM Format'), - content: SpecialSelectableText(widget.certificate.rawCert, + content: SpecialSelectableText(widget.certInfo.rawCert, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), crossAxisAlignment: CrossAxisAlignment.start) : Container(), @@ -115,7 +153,7 @@ class _CertificateDetailsScreenState extends State { return Container(); } - var title = widget.certificate.cert.details.isCa ? 'Delete CA?' : 'Delete cert?'; + var title = widget.certInfo.cert.details.isCa ? 'Delete CA?' : 'Delete cert?'; return Padding( padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), diff --git a/lib/screens/siteConfig/CertificatesScreen.dart b/lib/screens/siteConfig/CertificatesScreen.dart new file mode 100644 index 0000000..466d067 --- /dev/null +++ b/lib/screens/siteConfig/CertificatesScreen.dart @@ -0,0 +1,115 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/Certificate.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/screens/siteConfig/CertificateDetailsScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'AddCertificateScreen.dart'; + +class CertificatesScreen extends StatefulWidget { + const CertificatesScreen({Key key, this.site, this.onSave}) : super(key: key); + + final Site site; + final ValueChanged onSave; + + @override + _CertificatesScreenState createState() => _CertificatesScreenState(); +} + +class _CertificatesScreenState extends State { + Site site; + CertificateInfo primary; + bool changed = false; + + @override + void initState() { + site = widget.site; + super.initState(); + } + + @override + Widget build(BuildContext context) { + var items = _buildCertList(); + + items.add(ConfigButtonItem( + content: Text('Add a certificate'), + onPressed: () { + Utils.openPage(context, (context) { + //TODO: thread through the primary choice + return AddCertificateScreen(choosePrimary: true, onSave: (certInfo) { + setState(() { + changed = true; + site.certInfos.add(certInfo); + if (certInfo.primary) { + _setPrimary(certInfo); + } + }); + }); + }); + }, + )); + + return FormPage( + title: 'Certificates', + changed: changed, + onSave: () { + Navigator.pop(context); + if (widget.onSave != null) { + widget.onSave(site); + } + }, + child: ConfigSection(children: items)); + } + + List _buildCertList() { + List list = []; + site.certInfos.forEach((certInfo) { + var title = certInfo.cert.details.name; + if (certInfo.primary ?? false) { + title += " (primary)"; + } + list.add(ConfigPageItem( + content: Text(title), + onPressed: () { + Utils.openPage(context, (context) { + return CertificateDetailsScreen( + certInfo, + choosePrimary: site.certInfos.length > 1, + onSave: (isPrimary) { + if (isPrimary) { + _setPrimary(certInfo); + } + }, + onDelete: () { + setState(() { + changed = true; + site.certInfos.remove(certInfo); + if (primary.cert.fingerprint) + }); + } + ); + }); + }, + )); + }); + + return list; + } + + _setPrimary(CertificateInfo certInfo) { + // Turn every certInfo object to non primary + site.certInfos.forEach((certInfo) { + certInfo.primary = false; + }); + + // Flip this new primary on + certInfo.primary = true; + site.primaryCertInfo = certInfo; + setState(() {}); + } +} diff --git a/lib/screens/siteConfig/SiteConfigScreen.dart b/lib/screens/siteConfig/SiteConfigScreen.dart index a819468..795ff9e 100644 --- a/lib/screens/siteConfig/SiteConfigScreen.dart +++ b/lib/screens/siteConfig/SiteConfigScreen.dart @@ -11,9 +11,10 @@ import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart'; import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/screens/siteConfig/AddCertificateScreen.dart'; import 'package:mobile_nebula/screens/siteConfig/AdvancedScreen.dart'; import 'package:mobile_nebula/screens/siteConfig/CAListScreen.dart'; -import 'package:mobile_nebula/screens/siteConfig/CertificateScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/CertificatesScreen.dart'; import 'package:mobile_nebula/screens/siteConfig/StaticHostsScreen.dart'; import 'package:mobile_nebula/services/utils.dart'; @@ -105,10 +106,10 @@ class _SiteConfigScreenState extends State { } Widget _keys() { - final certError = site.cert == null || !site.cert.validity.valid; - var caError = site.ca.length == 0; + final certError = site.primaryCertInfo == null || !site.primaryCertInfo.validity.valid; + var caError = site.caInfos.length == 0; if (!caError) { - site.ca.forEach((ca) { + site.caInfos.forEach((ca) { if (!ca.validity.valid) { caError = true; } @@ -126,19 +127,29 @@ class _SiteConfigScreenState extends State { child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), padding: EdgeInsets.only(right: 5)) : Container(), - certError ? Text('Needs attention') : Text(site.cert.cert.details.name) + certError ? Text('Needs attention') : Text(site.primaryCertInfo.cert.details.name) ]), onPressed: () { Utils.openPage(context, (context) { - return CertificateScreen( - cert: site.cert, - onSave: (result) { - setState(() { - changed = true; - site.cert = result.cert; - site.key = result.key; + if (site.certInfos.length > 0) { + return CertificatesScreen( + site: site, + onSave: (newSite) { + setState(() { + changed = true; + site = site; + }); }); - }); + } + + return AddCertificateScreen(onSave: (certInfo) { + //TODO: onSave we may want to route them to the certificates screen somehow + changed = true; + certInfo.primary = true; + site.primaryCertInfo = certInfo; + site.certInfos.add(certInfo); + setState(() {}); + }); }); }, ), @@ -151,16 +162,16 @@ class _SiteConfigScreenState extends State { child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), padding: EdgeInsets.only(right: 5)) : Container(), - caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.ca.length)) + caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.caInfos.length)) ]), onPressed: () { Utils.openPage(context, (context) { return CAListScreen( - cas: site.ca, + cas: site.caInfos, onSave: (ca) { setState(() { changed = true; - site.ca = ca; + site.caInfos = ca; }); }); });