Allow import of private key, make it so key material isn't removed when navigating off the add a cert page (#64)

This commit is contained in:
Nate Brown 2022-08-05 16:42:17 -05:00 committed by GitHub
parent 457952b5ed
commit 958b15d711
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 148 additions and 39 deletions

View File

@ -49,6 +49,7 @@ class MainActivity: FlutterActivity() {
"nebula.parseCerts" -> nebulaParseCerts(call, result) "nebula.parseCerts" -> nebulaParseCerts(call, result)
"nebula.generateKeyPair" -> nebulaGenerateKeyPair(result) "nebula.generateKeyPair" -> nebulaGenerateKeyPair(result)
"nebula.renderConfig" -> nebulaRenderConfig(call, result) "nebula.renderConfig" -> nebulaRenderConfig(call, result)
"nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result)
"listSites" -> listSites(result) "listSites" -> listSites(result)
"deleteSite" -> deleteSite(call, result) "deleteSite" -> deleteSite(call, result)
@ -104,6 +105,25 @@ class MainActivity: FlutterActivity() {
return result.success(yaml) return result.success(yaml)
} }
private fun nebulaVerifyCertAndKey(call: MethodCall, result: MethodChannel.Result) {
val cert = call.argument<String>("cert")
if (cert == "") {
return result.error("required_argument", "cert is a required argument", null)
}
val key = call.argument<String>("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) { private fun listSites(result: MethodChannel.Result) {
sites!!.refreshSites(activeSiteId) sites!!.refreshSites(activeSiteId)
val sites = sites!!.getSites() val sites = sites!!.getSites()

View File

@ -34,6 +34,7 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result) case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result)
case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result) case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result)
case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, 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 "listSites": return self.listSites(result: result)
case "deleteSite": return self.deleteSite(call: call, 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) return result(json)
} }
func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) {
guard let args = call.arguments as? Dictionary<String, String> 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) { func nebulaGenerateKeyPair(result: FlutterResult) {
var err: NSError? var err: NSError?
let kp = MobileNebulaGenerateKeyPair(&err) let kp = MobileNebulaGenerateKeyPair(&err)

View File

@ -5,10 +5,11 @@ import 'package:flutter/material.dart';
import 'package:mobile_nebula/components/SpecialTextField.dart'; import 'package:mobile_nebula/components/SpecialTextField.dart';
class ConfigTextItem extends StatelessWidget { 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 String placeholder;
final TextEditingController controller; final TextEditingController controller;
final TextStyle style;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -19,7 +20,7 @@ class ConfigTextItem extends StatelessWidget {
minLines: 3, minLines: 3,
maxLines: 10, maxLines: 10,
placeholder: placeholder, placeholder: placeholder,
style: TextStyle(fontFamily: 'RobotoMono'), style: style,
controller: controller)); controller: controller));
} }
} }

View File

@ -24,69 +24,54 @@ class CertificateResult {
} }
class AddCertificateScreen extends StatefulWidget { 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 // onSave will pop a new CertificateDetailsScreen
final ValueChanged<CertificateResult> onSave; final ValueChanged<CertificateResult> onSave;
// onReplace will return the CertificateResult, assuming the previous screen is a CertificateDetailsScreen // onReplace will return the CertificateResult, assuming the previous screen is a CertificateDetailsScreen
final ValueChanged<CertificateResult> onReplace; final ValueChanged<CertificateResult> onReplace;
final String pubKey;
final String privKey;
@override @override
_AddCertificateScreenState createState() => _AddCertificateScreenState(); _AddCertificateScreenState createState() => _AddCertificateScreenState();
} }
class _AddCertificateScreenState extends State<AddCertificateScreen> { class _AddCertificateScreenState extends State<AddCertificateScreen> {
String pubKey; String pubKey;
String privKey; bool showKey = false;
String inputType = 'paste'; String inputType = 'paste';
final keyController = TextEditingController();
final pasteController = TextEditingController(); final pasteController = TextEditingController();
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
@override @override
void initState() { void initState() {
_generateKeys(); pubKey = widget.pubKey;
keyController.text = widget.privKey;
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
pasteController.dispose(); pasteController.dispose();
keyController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (pubKey == null) {
return Center(
child: PlatformCircularProgressIndicator(cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 500);
}),
);
}
List<Widget> items = []; List<Widget> items = [];
items.addAll(_buildShare()); items.addAll(_buildShare());
items.add(_buildKey());
items.addAll(_buildLoadCert()); items.addAll(_buildLoadCert());
return SimplePage(title: 'Certificate', child: Column(children: items)); return SimplePage(title: 'Certificate', child: Column(children: items));
} }
_generateKeys() async {
try {
var kp = await platform.invokeMethod("nebula.generateKeyPair");
Map<String, dynamic> 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<Widget> _buildShare() { List<Widget> _buildShare() {
return [ return [
ConfigSection( ConfigSection(
@ -136,6 +121,33 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
return items; 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<Widget> _addPaste() { List<Widget> _addPaste() {
return [ return [
ConfigSection( ConfigSection(
@ -201,7 +213,7 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
_addCertEntry(String rawCert) async { _addCertEntry(String rawCert) async {
// Allow for app store review testing cert to override the generated key // Allow for app store review testing cert to override the generated key
if (rawCert.trim() == _testCert) { if (rawCert.trim() == _testCert) {
privKey = _testKey; keyController.text = _testKey;
} }
try { try {
@ -217,12 +229,20 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity.reason); 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",
<String, String>{"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 we are replacing we just return the results now
if (widget.onReplace != null) { if (widget.onReplace != null) {
Navigator.pop(context); Navigator.pop(context);
widget.onReplace(CertificateResult(certInfo: tryCertInfo, key: privKey)); widget.onReplace(CertificateResult(certInfo: tryCertInfo, key: keyController.text));
return; return;
} }
@ -232,7 +252,7 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
certInfo: tryCertInfo, certInfo: tryCertInfo,
onSave: () { onSave: () {
Navigator.pop(context); Navigator.pop(context);
widget.onSave(CertificateResult(certInfo: tryCertInfo, key: privKey)); widget.onSave(CertificateResult(certInfo: tryCertInfo, key: keyController.text));
}); });
}); });
} }

View File

@ -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) /// Displays the details of a CertificateInfo object. Respects incomplete objects (missing validity or rawCert)
class CertificateDetailsScreen extends StatefulWidget { 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); : super(key: key);
final CertificateInfo certInfo; 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 // onReplace is used to install a new certificate over top of the old one
final ValueChanged<CertificateResult> onReplace; final ValueChanged<CertificateResult> onReplace;
final String pubKey;
final String privKey;
@override @override
_CertificateDetailsScreenState createState() => _CertificateDetailsScreenState(); _CertificateDetailsScreenState createState() => _CertificateDetailsScreenState();
} }
@ -164,7 +167,7 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
// Slam the page back to the top // Slam the page back to the top
controller.animateTo(0, controller.animateTo(0,
duration: const Duration(milliseconds: 10), curve: Curves.linearToEaseOut); duration: const Duration(milliseconds: 10), curve: Curves.linearToEaseOut);
}); }, pubKey: widget.pubKey, privKey: widget.privKey, );
}); });
}))); })));
} }

View File

@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/FormPage.dart';
import 'package:mobile_nebula/components/PlatformTextFormField.dart'; import 'package:mobile_nebula/components/PlatformTextFormField.dart';
import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
@ -36,11 +38,16 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
bool newSite = false; bool newSite = false;
bool debug = false; bool debug = false;
Site site; Site site;
String pubKey;
String privKey;
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
final nameController = TextEditingController(); final nameController = TextEditingController();
@override @override
void initState() { void initState() {
//NOTE: this is slightly wasteful since a keypair will be generated every time this page is opened
_generateKeys();
if (widget.site == null) { if (widget.site == null) {
newSite = true; newSite = true;
site = Site(); site = Site();
@ -54,6 +61,14 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (pubKey == null) {
return Center(
child: fpw.PlatformCircularProgressIndicator(cupertino: (_, __) {
return fpw.CupertinoProgressIndicatorData(radius: 50);
}),
);
}
return FormPage( return FormPage(
title: newSite ? 'New Site' : 'Edit Site', title: newSite ? 'New Site' : 'Edit Site',
changed: changed, changed: changed,
@ -121,7 +136,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
}); });
} }
return ConfigSection( return ConfigSection(
label: "IDENTITY", label: "IDENTITY",
children: [ children: [
ConfigPageItem( ConfigPageItem(
@ -139,6 +154,8 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
if (site.certInfo != null) { if (site.certInfo != null) {
return CertificateDetailsScreen( return CertificateDetailsScreen(
certInfo: site.certInfo, certInfo: site.certInfo,
pubKey: pubKey,
privKey: privKey,
onReplace: (result) { onReplace: (result) {
setState(() { setState(() {
changed = true; changed = true;
@ -148,7 +165,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
}); });
} }
return AddCertificateScreen(onSave: (result) { return AddCertificateScreen(pubKey: pubKey, privKey: privKey, onSave: (result) {
setState(() { setState(() {
changed = true; changed = true;
site.certInfo = result.certInfo; site.certInfo = result.certInfo;
@ -243,4 +260,18 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
], ],
); );
} }
_generateKeys() async {
try {
var kp = await platform.invokeMethod("nebula.generateKeyPair");
Map<String, dynamic> 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);
}
}
} }

View File

@ -83,7 +83,11 @@ class Utils {
static Widget trailingSaveWidget(BuildContext context, Function onPressed) { static Widget trailingSaveWidget(BuildContext context, Function onPressed) {
return CupertinoButton( 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, padding: Platform.isAndroid ? null : EdgeInsets.zero,
onPressed: () => onPressed()); onPressed: () => onPressed());
} }

View File

@ -250,6 +250,20 @@ func x25519Keypair() ([]byte, []byte, error) {
return pubkey[:], privkey[:], nil 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
}