forked from core/mobile_nebula
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:
parent
457952b5ed
commit
958b15d711
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, );
|
||||||
});
|
});
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue