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 2405aee..a6b276e 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 @@ -263,9 +263,11 @@ class IncomingSite( } if (key != null) { - val f = EncFile(context).openWrite(siteDir.resolve("key")) - f.use { it.write(key) } - f.close() + val keyFile = siteDir.resolve("key") + keyFile.delete() + val encFile = EncFile(context).openWrite(keyFile) + encFile.use { it.write(key) } + encFile.close() } key = null diff --git a/lib/components/FormPage.dart b/lib/components/FormPage.dart index 73cbad1..97117ef 100644 --- a/lib/components/FormPage.dart +++ b/lib/components/FormPage.dart @@ -8,12 +8,13 @@ import 'package:mobile_nebula/services/utils.dart'; /// SimplePage with a form and built in validation and confirmation to discard changes if any are made class FormPage extends StatefulWidget { const FormPage( - {Key key, this.title, @required this.child, @required this.onSave, @required this.changed, this.hideSave = false}) + {Key key, this.title, @required this.child, @required this.onSave, @required this.changed, this.hideSave = false, this.scrollController}) : super(key: key); final String title; final Function onSave; final Widget child; + final ScrollController scrollController; /// If you need the page to progress to a certain point before saving, control it here final bool hideSave; @@ -50,6 +51,7 @@ class _FormPageState extends State { child: SimplePage( leadingAction: _buildLeader(context), trailingActions: _buildTrailer(context), + scrollController: widget.scrollController, title: widget.title, child: Form( key: _formKey, diff --git a/lib/components/SiteItem.dart b/lib/components/SiteItem.dart index 8048e0f..1051b1e 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.certInfo != null && site.certInfo.cert.details.ips.length > 0) { + ip = site.certInfo.cert.details.ips[0]; } return SpecialButton( 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..c3f54b7 100644 --- a/lib/models/Site.dart +++ b/lib/models/Site.dart @@ -27,7 +27,7 @@ class Site { // pki fields List ca; - CertificateInfo cert; + CertificateInfo certInfo; String key; // lighthouse options @@ -52,7 +52,7 @@ class Site { id, staticHostmap, ca, - this.cert, + this.certInfo, this.lhDuration = 0, this.port = 0, this.cipher = "aes", @@ -95,7 +95,7 @@ class Site { }); if (json['cert'] != null) { - cert = CertificateInfo.fromJson(json['cert']); + certInfo = CertificateInfo.fromJson(json['cert']); } lhDuration = json['lhDuration']; @@ -146,7 +146,7 @@ class Site { return cert.rawCert; })?.join('\n') ?? "", - 'cert': cert?.rawCert, + 'cert': certInfo?.rawCert, 'key': key, 'lhDuration': lhDuration, 'port': port, diff --git a/lib/screens/HostInfoScreen.dart b/lib/screens/HostInfoScreen.dart index 6f8f440..696fa34 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(certInfo: CertificateInfo(cert: hostInfo.cert)))) : Container(), ]); } diff --git a/lib/screens/MainScreen.dart b/lib/screens/MainScreen.dart index 1298757..1761c6b 100644 --- a/lib/screens/MainScreen.dart +++ b/lib/screens/MainScreen.dart @@ -179,7 +179,7 @@ mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= "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), + certInfo: CertificateInfo.debug(rawCert: cert), unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')] ); diff --git a/lib/screens/siteConfig/CertificateScreen.dart b/lib/screens/siteConfig/AddCertificateScreen.dart similarity index 62% rename from lib/screens/siteConfig/CertificateScreen.dart rename to lib/screens/siteConfig/AddCertificateScreen.dart index dcf11ca..011a062 100644 --- a/lib/screens/siteConfig/CertificateScreen.dart +++ b/lib/screens/siteConfig/AddCertificateScreen.dart @@ -1,56 +1,54 @@ 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'; +import 'CertificateDetailsScreen.dart'; + class CertificateResult { - CertificateInfo cert; + CertificateInfo certInfo; String key; - CertificateResult({this.cert, this.key}); + CertificateResult({this.certInfo, this.key}); } -class CertificateScreen extends StatefulWidget { - const CertificateScreen({Key key, this.cert, this.onSave}) : super(key: key); +class AddCertificateScreen extends StatefulWidget { + const AddCertificateScreen({Key key, this.onSave, this.onReplace}) : super(key: key); - final CertificateInfo cert; + // onSave will pop a new CertificateDetailsScreen final ValueChanged onSave; + // onReplace will return the CertificateResult, assuming the previous screen is a CertificateDetailsScreen + final ValueChanged onReplace; @override - _CertificateScreenState createState() => _CertificateScreenState(); + _AddCertificateScreenState createState() => _AddCertificateScreenState(); } -class _CertificateScreenState extends State { +class _AddCertificateScreenState extends State { String pubKey; String privKey; - bool changed = false; - - CertificateInfo cert; 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 +60,29 @@ 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 +104,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 +151,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 +171,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 +194,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 +202,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; @@ -278,21 +210,36 @@ class _CertificateScreenState extends State { try { 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 + + // If we are replacing we just return the results now + if (widget.onReplace != null) { + Navigator.pop(context); + widget.onReplace(CertificateResult(certInfo: tryCertInfo, key: privKey)); + return; + } + + // We have a cert, pop the details screen where they can hit save + Utils.openPage(context, (context) { + return CertificateDetailsScreen(certInfo: tryCertInfo, onSave: () { + Navigator.pop(context); + widget.onSave(CertificateResult(certInfo: tryCertInfo, key: privKey)); + }); + }); } } on PlatformException catch (err) { - error = err.details ?? err.message; - } - - if (callback != null) { - callback(error); + return Utils.popError(context, 'Error loading certificate content', err.details ?? err.message); } } } diff --git a/lib/screens/siteConfig/CAListScreen.dart b/lib/screens/siteConfig/CAListScreen.dart index 06cdbcf..bea726e 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, + certInfo: ca, onDelete: () { setState(() { changed = true; diff --git a/lib/screens/siteConfig/CertificateDetailsScreen.dart b/lib/screens/siteConfig/CertificateDetailsScreen.dart index 313fc5f..2e86cf8 100644 --- a/lib/screens/siteConfig/CertificateDetailsScreen.dart +++ b/lib/screens/siteConfig/CertificateDetailsScreen.dart @@ -2,34 +2,73 @@ 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/screens/siteConfig/AddCertificateScreen.dart'; import 'package:mobile_nebula/services/utils.dart'; /// 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({Key key, this.certInfo, this.onDelete, this.onSave, this.onReplace}) : super(key: key); - final CertificateInfo certificate; + final CertificateInfo certInfo; + + // onDelete is used to remove a CA cert final Function onDelete; + // onSave is used to install a new certificate + final Function onSave; + + // onReplace is used to install a new certificate over top of the old one + final ValueChanged onReplace; + @override _CertificateDetailsScreenState createState() => _CertificateDetailsScreenState(); } class _CertificateDetailsScreenState extends State { + bool changed = false; + CertificateResult certResult; + CertificateInfo certInfo; + ScrollController controller = ScrollController(); + + @override + void initState() { + certInfo = widget.certInfo; + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return SimplePage( + return FormPage( title: 'Certificate Details', + scrollController: controller, + changed: widget.onSave != null || changed, + onSave: () { + if (widget.onSave != null) { + Navigator.pop(context); + widget.onSave(); + } else if (widget.onReplace != null) { + Navigator.pop(context); + widget.onReplace(certResult); + } + }, + hideSave: widget.onSave == null && widget.onReplace == null, child: Column(children: [ _buildID(), _buildFilters(), _buildValid(), _buildAdvanced(), + _buildReplace(), _buildDelete(), ]), ); @@ -37,17 +76,17 @@ class _CertificateDetailsScreenState extends State { Widget _buildID() { return ConfigSection(children: [ - ConfigItem(label: Text('Name'), content: SpecialSelectableText(widget.certificate.cert.details.name)), + ConfigItem(label: Text('Name'), content: SpecialSelectableText(certInfo.cert.details.name)), ConfigItem( label: Text('Type'), - content: Text(widget.certificate.cert.details.isCa ? 'CA certificate' : 'Client certificate')), + content: Text(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 (certInfo.validity != null && !certInfo.validity.valid) { + valid = Text(certInfo.validity.valid ? 'yes' : certInfo.validity.reason, style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context))); } return ConfigSection( @@ -56,33 +95,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(certInfo.cert.details.notBefore.toLocal().toString())), ConfigItem( label: Text('Expires'), - content: SpecialSelectableText(widget.certificate.cert.details.notAfter.toLocal().toString())), + content: SpecialSelectableText(certInfo.cert.details.notAfter.toLocal().toString())), ], ); } Widget _buildFilters() { List items = []; - if (widget.certificate.cert.details.groups.length > 0) { + if (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(certInfo.cert.details.groups.join(', ')))); } - if (widget.certificate.cert.details.ips.length > 0) { + if (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(certInfo.cert.details.ips.join(', ')))); } - if (widget.certificate.cert.details.subnets.length > 0) { + if (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(certInfo.cert.details.subnets.join(', ')))); } return items.length > 0 - ? ConfigSection(label: widget.certificate.cert.details.isCa ? 'FILTERS' : 'DETAILS', children: items) + ? ConfigSection(label: certInfo.cert.details.isCa ? 'FILTERS' : 'DETAILS', children: items) : Container(); } @@ -91,18 +130,18 @@ class _CertificateDetailsScreenState extends State { children: [ ConfigItem( label: Text('Fingerprint'), - content: SpecialSelectableText(widget.certificate.cert.fingerprint, + content: SpecialSelectableText(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(certInfo.cert.details.publicKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), crossAxisAlignment: CrossAxisAlignment.start), - widget.certificate.rawCert != null + certInfo.rawCert != null ? ConfigItem( label: Text('PEM Format'), - content: SpecialSelectableText(widget.certificate.rawCert, + content: SpecialSelectableText(certInfo.rawCert, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), crossAxisAlignment: CrossAxisAlignment.start) : Container(), @@ -110,12 +149,40 @@ class _CertificateDetailsScreenState extends State { ); } + Widget _buildReplace() { + if (widget.onReplace == null) { + return Container(); + } + + return Padding( + padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), + child: SizedBox( + width: double.infinity, + child: PlatformButton( + child: Text('Replace certificate'), + color: CupertinoColors.systemRed.resolveFrom(context), + onPressed: () { + Utils.openPage(context, (context) { + return AddCertificateScreen( + onReplace: (result) { + setState(() { + changed = true; + certResult = result; + certInfo = certResult.certInfo; + }); + // Slam the page back to the top + controller.animateTo(0, duration: const Duration(milliseconds: 10), curve: Curves.linearToEaseOut); + }); + }); + }))); + } + Widget _buildDelete() { if (widget.onDelete == null) { return Container(); } - var title = widget.certificate.cert.details.isCa ? 'Delete CA?' : 'Delete cert?'; + var title = 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/SiteConfigScreen.dart b/lib/screens/siteConfig/SiteConfigScreen.dart index a819468..c2aabcc 100644 --- a/lib/screens/siteConfig/SiteConfigScreen.dart +++ b/lib/screens/siteConfig/SiteConfigScreen.dart @@ -13,11 +13,13 @@ import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/models/Site.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/AddCertificateScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/CertificateDetailsScreen.dart'; import 'package:mobile_nebula/screens/siteConfig/StaticHostsScreen.dart'; import 'package:mobile_nebula/services/utils.dart'; //TODO: Add a config test mechanism +//TODO: Enforce a name class SiteConfigScreen extends StatefulWidget { const SiteConfigScreen({Key key, this.site, this.onSave}) : super(key: key); @@ -100,12 +102,18 @@ class _SiteConfigScreenState extends State { content: PlatformTextFormField( placeholder: 'Required', controller: nameController, + validator: (name) { + if (name == null || name == "") { + return "A name is required"; + } + return null; + }, )) ]); } Widget _keys() { - final certError = site.cert == null || !site.cert.validity.valid; + final certError = site.certInfo == null || !site.certInfo.validity.valid; var caError = site.ca.length == 0; if (!caError) { site.ca.forEach((ca) { @@ -126,16 +134,28 @@ 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.certInfo.cert.details.name) ]), onPressed: () { Utils.openPage(context, (context) { - return CertificateScreen( - cert: site.cert, + if (site.certInfo != null) { + return CertificateDetailsScreen( + certInfo: site.certInfo, + onReplace: (result) { + setState(() { + changed = true; + site.certInfo = result.certInfo; + site.key = result.key; + }); + } + ); + } + + return AddCertificateScreen( onSave: (result) { setState(() { changed = true; - site.cert = result.cert; + site.certInfo = result.certInfo; site.key = result.key; }); });