From 958b15d71184666fc14aec190978cb7571b0e89b Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Fri, 5 Aug 2022 16:42:17 -0500 Subject: [PATCH] Allow import of private key, make it so key material isn't removed when navigating off the add a cert page (#64) --- .../net/defined/mobile_nebula/MainActivity.kt | 20 +++++ ios/Runner/AppDelegate.swift | 16 ++++ lib/components/config/ConfigTextItem.dart | 5 +- .../siteConfig/AddCertificateScreen.dart | 78 ++++++++++++------- .../siteConfig/CertificateDetailsScreen.dart | 7 +- lib/screens/siteConfig/SiteConfigScreen.dart | 35 ++++++++- lib/services/utils.dart | 6 +- nebula/mobileNebula.go | 20 ++++- 8 files changed, 148 insertions(+), 39 deletions(-) diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt index b41c3c3..a5d35ce 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -49,6 +49,7 @@ class MainActivity: FlutterActivity() { "nebula.parseCerts" -> nebulaParseCerts(call, result) "nebula.generateKeyPair" -> nebulaGenerateKeyPair(result) "nebula.renderConfig" -> nebulaRenderConfig(call, result) + "nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result) "listSites" -> listSites(result) "deleteSite" -> deleteSite(call, result) @@ -104,6 +105,25 @@ class MainActivity: FlutterActivity() { return result.success(yaml) } + private fun nebulaVerifyCertAndKey(call: MethodCall, result: MethodChannel.Result) { + val cert = call.argument("cert") + if (cert == "") { + return result.error("required_argument", "cert is a required argument", null) + } + + val key = call.argument("key") + if (key == "") { + return result.error("required_argument", "key is a required argument", null) + } + + return try { + val json = mobileNebula.MobileNebula.verifyCertAndKey(cert, key) + result.success(json) + } catch (err: Exception) { + result.error("unhandled_error", err.message, null) + } + } + private fun listSites(result: MethodChannel.Result) { sites!!.refreshSites(activeSiteId) val sites = sites!!.getSites() diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6070a82..0135453 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -34,6 +34,7 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result) case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result) case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result) + case "nebula.verifyCertAndKey": return self.nebulaVerifyCertAndKey(call: call, result: result) case "listSites": return self.listSites(result: result) case "deleteSite": return self.deleteSite(call: call, result: result) @@ -71,6 +72,21 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { return result(json) } + func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let cert = args["cert"] else { return result(MissingArgumentError(message: "cert is a required argument")) } + guard let key = args["key"] else { return result(MissingArgumentError(message: "key is a required argument")) } + + var err: NSError? + var validd: ObjCBool = false + let valid = MobileNebulaVerifyCertAndKey(cert, key, &validd, &err) + if (err != nil) { + return result(CallFailedError(message: "Error while verifying certificate and private key", details: err!.localizedDescription)) + } + + return result(valid) + } + func nebulaGenerateKeyPair(result: FlutterResult) { var err: NSError? let kp = MobileNebulaGenerateKeyPair(&err) diff --git a/lib/components/config/ConfigTextItem.dart b/lib/components/config/ConfigTextItem.dart index aa6e975..b911723 100644 --- a/lib/components/config/ConfigTextItem.dart +++ b/lib/components/config/ConfigTextItem.dart @@ -5,10 +5,11 @@ import 'package:flutter/material.dart'; import 'package:mobile_nebula/components/SpecialTextField.dart'; class ConfigTextItem extends StatelessWidget { - const ConfigTextItem({Key key, this.placeholder, this.controller}) : super(key: key); + const ConfigTextItem({Key key, this.placeholder, this.controller, this.style = const TextStyle(fontFamily: 'RobotoMono')}) : super(key: key); final String placeholder; final TextEditingController controller; + final TextStyle style; @override Widget build(BuildContext context) { @@ -19,7 +20,7 @@ class ConfigTextItem extends StatelessWidget { minLines: 3, maxLines: 10, placeholder: placeholder, - style: TextStyle(fontFamily: 'RobotoMono'), + style: style, controller: controller)); } } diff --git a/lib/screens/siteConfig/AddCertificateScreen.dart b/lib/screens/siteConfig/AddCertificateScreen.dart index 81b37c6..0e028ea 100644 --- a/lib/screens/siteConfig/AddCertificateScreen.dart +++ b/lib/screens/siteConfig/AddCertificateScreen.dart @@ -24,69 +24,54 @@ class CertificateResult { } class AddCertificateScreen extends StatefulWidget { - const AddCertificateScreen({Key key, this.onSave, this.onReplace}) : super(key: key); + const AddCertificateScreen({Key key, this.onSave, this.onReplace, this.pubKey, this.privKey}) : super(key: key); // onSave will pop a new CertificateDetailsScreen final ValueChanged onSave; // onReplace will return the CertificateResult, assuming the previous screen is a CertificateDetailsScreen final ValueChanged onReplace; + final String pubKey; + final String privKey; + @override _AddCertificateScreenState createState() => _AddCertificateScreenState(); } class _AddCertificateScreenState extends State { String pubKey; - String privKey; + bool showKey = false; String inputType = 'paste'; + final keyController = TextEditingController(); final pasteController = TextEditingController(); static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); @override void initState() { - _generateKeys(); + pubKey = widget.pubKey; + keyController.text = widget.privKey; super.initState(); } @override void dispose() { pasteController.dispose(); + keyController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - if (pubKey == null) { - return Center( - child: PlatformCircularProgressIndicator(cupertino: (_, __) { - return CupertinoProgressIndicatorData(radius: 500); - }), - ); - } - List items = []; items.addAll(_buildShare()); + items.add(_buildKey()); items.addAll(_buildLoadCert()); return SimplePage(title: 'Certificate', child: Column(children: items)); } - _generateKeys() async { - try { - var kp = await platform.invokeMethod("nebula.generateKeyPair"); - Map keyPair = jsonDecode(kp); - - setState(() { - pubKey = keyPair['PublicKey']; - privKey = keyPair['PrivateKey']; - }); - } on PlatformException catch (err) { - Utils.popError(context, 'Failed to generate key pair', err.details ?? err.message); - } - } - List _buildShare() { return [ ConfigSection( @@ -136,6 +121,33 @@ class _AddCertificateScreenState extends State { return items; } + Widget _buildKey() { + if (!showKey) { + return Padding( + padding: EdgeInsets.only(top: 20, bottom: 10, left: 10, right: 10), + child: SizedBox( + width: double.infinity, + child: PlatformElevatedButton( + child: Text('Show/Import Private Key'), + color: CupertinoColors.secondaryLabel.resolveFrom(context), + onPressed: () => Utils.confirmDelete(context, 'Show/Import Private Key?', () { + setState(() { + showKey = true; + }); + }, deleteLabel: 'Yes')))); + } + + return ConfigSection( + label: 'Import a private key generated on another device', + children: [ + ConfigTextItem( + controller: keyController, + style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14) + ), + ], + ); + } + List _addPaste() { return [ ConfigSection( @@ -201,7 +213,7 @@ class _AddCertificateScreenState extends State { _addCertEntry(String rawCert) async { // Allow for app store review testing cert to override the generated key if (rawCert.trim() == _testCert) { - privKey = _testKey; + keyController.text = _testKey; } try { @@ -217,12 +229,20 @@ class _AddCertificateScreenState extends State { return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity.reason); } - //TODO: test that the pubkey we generated equals the pub key in the cert + var certMatch = await platform.invokeMethod( + "nebula.verifyCertAndKey", + {"cert": rawCert, "key": keyController.text} + ); + if (!certMatch) { + // The method above will throw if there is a mismatch, this is just here in case we introduce a bug in the future + return Utils.popError(context, 'Error loading certificate content', + 'The provided certificates public key is not compatible with the private key.'); + } // If we are replacing we just return the results now if (widget.onReplace != null) { Navigator.pop(context); - widget.onReplace(CertificateResult(certInfo: tryCertInfo, key: privKey)); + widget.onReplace(CertificateResult(certInfo: tryCertInfo, key: keyController.text)); return; } @@ -232,7 +252,7 @@ class _AddCertificateScreenState extends State { certInfo: tryCertInfo, onSave: () { Navigator.pop(context); - widget.onSave(CertificateResult(certInfo: tryCertInfo, key: privKey)); + widget.onSave(CertificateResult(certInfo: tryCertInfo, key: keyController.text)); }); }); } diff --git a/lib/screens/siteConfig/CertificateDetailsScreen.dart b/lib/screens/siteConfig/CertificateDetailsScreen.dart index 523231a..979ca86 100644 --- a/lib/screens/siteConfig/CertificateDetailsScreen.dart +++ b/lib/screens/siteConfig/CertificateDetailsScreen.dart @@ -10,7 +10,7 @@ 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.certInfo, this.onDelete, this.onSave, this.onReplace}) + const CertificateDetailsScreen({Key key, this.certInfo, this.onDelete, this.onSave, this.onReplace, this.pubKey, this.privKey}) : super(key: key); final CertificateInfo certInfo; @@ -24,6 +24,9 @@ class CertificateDetailsScreen extends StatefulWidget { // onReplace is used to install a new certificate over top of the old one final ValueChanged onReplace; + final String pubKey; + final String privKey; + @override _CertificateDetailsScreenState createState() => _CertificateDetailsScreenState(); } @@ -164,7 +167,7 @@ class _CertificateDetailsScreenState extends State { // Slam the page back to the top controller.animateTo(0, duration: const Duration(milliseconds: 10), curve: Curves.linearToEaseOut); - }); + }, pubKey: widget.pubKey, privKey: widget.privKey, ); }); }))); } diff --git a/lib/screens/siteConfig/SiteConfigScreen.dart b/lib/screens/siteConfig/SiteConfigScreen.dart index 7b16e50..ba47fce 100644 --- a/lib/screens/siteConfig/SiteConfigScreen.dart +++ b/lib/screens/siteConfig/SiteConfigScreen.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' as fpw; import 'package:mobile_nebula/components/FormPage.dart'; import 'package:mobile_nebula/components/PlatformTextFormField.dart'; import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; @@ -36,11 +38,16 @@ class _SiteConfigScreenState extends State { bool newSite = false; bool debug = false; Site site; + String pubKey; + String privKey; + static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); final nameController = TextEditingController(); @override void initState() { + //NOTE: this is slightly wasteful since a keypair will be generated every time this page is opened + _generateKeys(); if (widget.site == null) { newSite = true; site = Site(); @@ -54,6 +61,14 @@ class _SiteConfigScreenState extends State { @override Widget build(BuildContext context) { + if (pubKey == null) { + return Center( + child: fpw.PlatformCircularProgressIndicator(cupertino: (_, __) { + return fpw.CupertinoProgressIndicatorData(radius: 50); + }), + ); + } + return FormPage( title: newSite ? 'New Site' : 'Edit Site', changed: changed, @@ -121,7 +136,7 @@ class _SiteConfigScreenState extends State { }); } - return ConfigSection( + return ConfigSection( label: "IDENTITY", children: [ ConfigPageItem( @@ -139,6 +154,8 @@ class _SiteConfigScreenState extends State { if (site.certInfo != null) { return CertificateDetailsScreen( certInfo: site.certInfo, + pubKey: pubKey, + privKey: privKey, onReplace: (result) { setState(() { changed = true; @@ -148,7 +165,7 @@ class _SiteConfigScreenState extends State { }); } - return AddCertificateScreen(onSave: (result) { + return AddCertificateScreen(pubKey: pubKey, privKey: privKey, onSave: (result) { setState(() { changed = true; site.certInfo = result.certInfo; @@ -243,4 +260,18 @@ class _SiteConfigScreenState extends State { ], ); } + + _generateKeys() async { + try { + var kp = await platform.invokeMethod("nebula.generateKeyPair"); + Map keyPair = jsonDecode(kp); + + setState(() { + pubKey = keyPair['PublicKey']; + privKey = keyPair['PrivateKey']; + }); + } on PlatformException catch (err) { + Utils.popError(context, 'Failed to generate key pair', err.details ?? err.message); + } + } } diff --git a/lib/services/utils.dart b/lib/services/utils.dart index d22711a..8922c9c 100644 --- a/lib/services/utils.dart +++ b/lib/services/utils.dart @@ -83,7 +83,11 @@ class Utils { static Widget trailingSaveWidget(BuildContext context, Function onPressed) { return CupertinoButton( - child: Text('Save', style: TextStyle(fontWeight: FontWeight.bold)), + child: Text('Save', style: TextStyle( + fontWeight: FontWeight.bold, + //TODO: For some reason on android if inherit is the default of true the text color here turns to the background color + inherit: Platform.isIOS ? true : false + )), padding: Platform.isAndroid ? null : EdgeInsets.zero, onPressed: () => onPressed()); } diff --git a/nebula/mobileNebula.go b/nebula/mobileNebula.go index 03a64fc..b327e17 100644 --- a/nebula/mobileNebula.go +++ b/nebula/mobileNebula.go @@ -250,6 +250,20 @@ func x25519Keypair() ([]byte, []byte, error) { return pubkey[:], privkey[:], nil } -//func VerifyCertAndKey(cert string, key string) (string, error) { -// -//} +func VerifyCertAndKey(rawCert string, pemPrivateKey string) (bool, error) { + rawKey, _, err := cert.UnmarshalX25519PrivateKey([]byte(pemPrivateKey)) + if err != nil { + return false, fmt.Errorf("error while unmarshaling private key: %s", err) + } + + nebulaCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(rawCert)) + if err != nil { + return false, fmt.Errorf("error while unmarshaling cert: %s", err) + } + + if err = nebulaCert.VerifyPrivateKey(rawKey); err != nil { + return false, err + } + + return true, nil +}