This commit is contained in:
Nate Brown 2021-04-29 16:39:02 -05:00
parent 4c28cc196e
commit 41a382d218
15 changed files with 403 additions and 211 deletions

View File

@ -59,7 +59,7 @@ class NebulaVpnService : VpnService() {
// Link active site config in Main to avoid this // Link active site config in Main to avoid this
site = Site(File(path)) site = Site(File(path))
if (site!!.cert == null) { if (site!!.certInfos == null || site!!.certInfos.isEmpty()) {
announceExit(id, "Site is missing a certificate") announceExit(id, "Site is missing a certificate")
//TODO: can we signal failure? //TODO: can we signal failure?
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
@ -74,7 +74,7 @@ class NebulaVpnService : VpnService() {
var ipNet: CIDR var ipNet: CIDR
try { try {
ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.cert!!.cert.details.ips[0]) ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.primaryCertInfo!!.cert.details.ips[0])
} catch (err: Exception) { } catch (err: Exception) {
return announceExit(site!!.id, err.message ?: "$err") return announceExit(site!!.id, err.message ?: "$err")
} }

View File

@ -46,7 +46,7 @@ class Sites(private var engine: FlutterEngine) {
this.sites[site.id] = SiteContainer(site, updater) this.sites[site.id] = SiteContainer(site, updater)
} catch (err: Exception) { } catch (err: Exception) {
siteDir.deleteRecursively() // siteDir.deleteRecursively()
Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err) Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err)
} }
} }
@ -103,7 +103,8 @@ class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.S
data class CertificateInfo( data class CertificateInfo(
@SerializedName("Cert") val cert: Certificate, @SerializedName("Cert") val cert: Certificate,
@SerializedName("RawCert") val rawCert: String, @SerializedName("RawCert") val rawCert: String,
@SerializedName("Validity") val validity: CertificateValidity @SerializedName("Validity") val validity: CertificateValidity,
var primary: Boolean
) )
data class Certificate( data class Certificate(
@ -134,8 +135,8 @@ class Site {
val id: String val id: String
val staticHostmap: HashMap<String, StaticHosts> val staticHostmap: HashMap<String, StaticHosts>
val unsafeRoutes: List<UnsafeRoute> val unsafeRoutes: List<UnsafeRoute>
var cert: CertificateInfo? = null val certInfos: ArrayList<CertificateInfo> = ArrayList()
var ca: Array<CertificateInfo> lateinit var caInfos: Array<CertificateInfo>
val lhDuration: Int val lhDuration: Int
val port: Int val port: Int
val mtu: Int val mtu: Int
@ -155,6 +156,9 @@ class Site {
@Expose(serialize = false) @Expose(serialize = false)
val config: String val config: String
@Expose(serialize = false)
lateinit var primaryCertInfo: CertificateInfo
constructor(siteDir: File) { constructor(siteDir: File) {
val gson = Gson() val gson = Gson()
config = siteDir.resolve("config.json").readText() config = siteDir.resolve("config.json").readText()
@ -176,26 +180,23 @@ class Site {
connected = false connected = false
status = "Disconnected" status = "Disconnected"
try { incomingSite.certs?.forEach { certContainer ->
val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert) val certInfo = getCertDetails(certContainer.cert, gson) ?: return
val certs = gson.fromJson(rawDetails, Array<CertificateInfo>::class.java) certInfo.primary = certContainer.primary
if (certs.isEmpty()) { if (certInfo.primary) {
throw IllegalArgumentException("No certificate found") this.primaryCertInfo = certInfo
} }
cert = certs[0] this.certInfos.add(certInfo)
if (!cert!!.validity.valid) {
errors.add("Certificate is invalid: ${cert!!.validity.reason}")
} }
} catch (err: Exception) { // Upgrade the old cert property if present
errors.add("Error while loading certificate: ${err.message}") upgradeCert(incomingSite, gson)
}
try { try {
val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca) val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca)
ca = gson.fromJson(rawCa, Array<CertificateInfo>::class.java) caInfos = gson.fromJson(rawCa, Array<CertificateInfo>::class.java)
var hasErrors = false var hasErrors = false
ca.forEach { caInfos.forEach {
if (!it.validity.valid) { if (!it.validity.valid) {
hasErrors = true hasErrors = true
} }
@ -206,7 +207,7 @@ class Site {
} }
} catch (err: Exception) { } catch (err: Exception) {
ca = arrayOf() caInfos = arrayOf()
errors.add("Error while loading certificate authorities: ${err.message}") 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<CertificateInfo>::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? { 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() val k = f.readText()
f.close() f.close()
return k return k
@ -238,12 +296,21 @@ data class UnsafeRoute(
val mtu: Int? 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( class IncomingSite(
val name: String, val name: String,
val id: String, val id: String,
val staticHostmap: HashMap<String, StaticHosts>, val staticHostmap: HashMap<String, StaticHosts>,
val unsafeRoutes: List<UnsafeRoute>?, val unsafeRoutes: List<UnsafeRoute>?,
val cert: String, var certs: List<IncomingCert>?,
val ca: String, val ca: String,
val lhDuration: Int, val lhDuration: Int,
val port: Int, val port: Int,
@ -251,8 +318,9 @@ class IncomingSite(
val cipher: String, val cipher: String,
val sortKey: Int?, val sortKey: Int?,
var logVerbosity: String?, var logVerbosity: String?,
@Expose(serialize = false)
var key: String? @Deprecated("certs is the new property")
var cert: String?
) { ) {
fun save(context: Context) { fun save(context: Context) {
@ -261,13 +329,16 @@ class IncomingSite(
siteDir.mkdir() siteDir.mkdir()
} }
if (key != null) { certs?.forEach { cert ->
val f = EncFile(context).openWrite(siteDir.resolve("key")) if (cert.key != null) {
f.use { it.write(key) } val f = EncFile(context).openWrite(siteDir.resolve("key.${cert.fingerprint}"))
f.use { it.write(cert.key) }
f.close() f.close()
} }
key = null cert.key = null
}
val gson = Gson() val gson = Gson()
val confFile = siteDir.resolve("config.json") val confFile = siteDir.resolve("config.json")
confFile.writeText(gson.toJson(this)) confFile.writeText(gson.toJson(this))

View File

@ -28,8 +28,8 @@ class SiteItem extends StatelessWidget {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
final border = BorderSide(color: Utils.configSectionBorder(context)); final border = BorderSide(color: Utils.configSectionBorder(context));
var ip = "Error"; var ip = "Error";
if (site.cert != null && site.cert.cert.details.ips.length > 0) { if (site.primaryCertInfo.cert != null && site.primaryCertInfo.cert.details.ips.length > 0) {
ip = site.cert.cert.details.ips[0]; ip = site.primaryCertInfo.cert.details.ips[0];
} }
return SpecialButton( return SpecialButton(

View File

@ -620,7 +620,7 @@ class _SpecialSelectableTextState extends State<SpecialSelectableText> with Auto
toolbarOptions: widget.toolbarOptions, toolbarOptions: widget.toolbarOptions,
minLines: widget.minLines, minLines: widget.minLines,
maxLines: widget.maxLines ?? defaultTextStyle.maxLines, maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
selectionColor: themeData.textSelectionColor, selectionColor: TextSelectionTheme.of(context).selectionColor,
selectionControls: widget.selectionEnabled ? textSelectionControls : null, selectionControls: widget.selectionEnabled ? textSelectionControls : null,
onSelectionChanged: _handleSelectionChanged, onSelectionChanged: _handleSelectionChanged,
onSelectionHandleTapped: _handleSelectionHandleTapped, onSelectionHandleTapped: _handleSelectionHandleTapped,

View File

@ -4,7 +4,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
TextStyle basicTextStyle(BuildContext context) => 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; const double _headerFontSize = 13.0;

View File

@ -2,6 +2,10 @@ class CertificateInfo {
Certificate cert; Certificate cert;
String rawCert; String rawCert;
CertificateValidity validity; 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 = ""}) CertificateInfo.debug({this.rawCert = ""})
: this.cert = Certificate.debug(), : this.cert = Certificate.debug(),
@ -10,6 +14,7 @@ class CertificateInfo {
CertificateInfo.fromJson(Map<String, dynamic> json) CertificateInfo.fromJson(Map<String, dynamic> json)
: cert = Certificate.fromJson(json['Cert']), : cert = Certificate.fromJson(json['Cert']),
rawCert = json['RawCert'], rawCert = json['RawCert'],
primary = json['primary'],
validity = CertificateValidity.fromJson(json['Validity']); validity = CertificateValidity.fromJson(json['Validity']);
CertificateInfo({this.cert, this.rawCert, this.validity}); CertificateInfo({this.cert, this.rawCert, this.validity});
@ -17,6 +22,15 @@ class CertificateInfo {
static List<CertificateInfo> fromJsonList(List<dynamic> list) { static List<CertificateInfo> fromJsonList(List<dynamic> list) {
return list.map((v) => CertificateInfo.fromJson(v)); return list.map((v) => CertificateInfo.fromJson(v));
} }
Map<String, dynamic> toJson() {
return {
'cert': rawCert,
'key': key,
'primary': primary,
'fingerprint': cert.fingerprint
};
}
} }
class Certificate { class Certificate {

View File

@ -1,5 +1,3 @@
import 'dart:io';
class IPAndPort { class IPAndPort {
String ip; String ip;
int port; int port;

View File

@ -26,9 +26,9 @@ class Site {
List<UnsafeRoute> unsafeRoutes; List<UnsafeRoute> unsafeRoutes;
// pki fields // pki fields
List<CertificateInfo> ca; List<CertificateInfo> caInfos;
CertificateInfo cert; List<CertificateInfo> certInfos;
String key; CertificateInfo primaryCertInfo;
// lighthouse options // lighthouse options
int lhDuration; // in seconds int lhDuration; // in seconds
@ -51,8 +51,8 @@ class Site {
{this.name, {this.name,
id, id,
staticHostmap, staticHostmap,
ca, caInfos,
this.cert, certInfos,
this.lhDuration = 0, this.lhDuration = 0,
this.port = 0, this.port = 0,
this.cipher = "aes", this.cipher = "aes",
@ -67,7 +67,8 @@ class Site {
: staticHostmap = staticHostmap ?? {}, : staticHostmap = staticHostmap ?? {},
unsafeRoutes = unsafeRoutes ?? [], unsafeRoutes = unsafeRoutes ?? [],
errors = errors ?? [], errors = errors ?? [],
ca = ca ?? [], caInfos = caInfos ?? [],
certInfos = certInfos ?? [],
id = id ?? uuid.v4(); id = id ?? uuid.v4();
Site.fromJson(Map<String, dynamic> json) { Site.fromJson(Map<String, dynamic> json) {
@ -88,15 +89,21 @@ class Site {
}); });
} }
List<dynamic> rawCA = json['ca']; List<dynamic> rawCA = json['caInfos'];
ca = []; caInfos = [];
rawCA.forEach((val) { rawCA.forEach((val) {
ca.add(CertificateInfo.fromJson(val)); caInfos.add(CertificateInfo.fromJson(val));
}); });
if (json['cert'] != null) { List<dynamic> rawCerts = json['certInfos'];
cert = CertificateInfo.fromJson(json['cert']); certInfos = [];
rawCerts.forEach((val) {
final certInfo = CertificateInfo.fromJson(val);
if (certInfo.primary) {
primaryCertInfo = certInfo;
} }
certInfos.add(certInfo);
});
lhDuration = json['lhDuration']; lhDuration = json['lhDuration'];
port = json['port']; port = json['port'];
@ -142,12 +149,11 @@ class Site {
'id': id, 'id': id,
'staticHostmap': staticHostmap, 'staticHostmap': staticHostmap,
'unsafeRoutes': unsafeRoutes, 'unsafeRoutes': unsafeRoutes,
'ca': ca?.map((cert) { 'ca': caInfos?.map((cert) {
return cert.rawCert; return cert.rawCert;
})?.join('\n') ?? })?.join('\n') ??
"", "",
'cert': cert?.rawCert, 'certs': certInfos,
'key': key,
'lhDuration': lhDuration, 'lhDuration': lhDuration,
'port': port, 'port': port,
'mtu': mtu, 'mtu': mtu,

View File

@ -68,7 +68,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
labelWidth: 150, labelWidth: 150,
content: Text(hostInfo.cert.details.name), content: Text(hostInfo.cert.details.name),
onPressed: () => Utils.openPage( onPressed: () => Utils.openPage(
context, (context) => CertificateDetailsScreen(certificate: CertificateInfo(cert: hostInfo.cert)))) context, (context) => CertificateDetailsScreen(CertificateInfo(cert: hostInfo.cert))))
: Container(), : Container(),
]); ]);
} }

View File

@ -73,7 +73,7 @@ class _MainScreenState extends State<MainScreen> {
if (!ready) { if (!ready) {
return Center( return Center(
child: PlatformCircularProgressIndicator(cupertino: (_, __) { child: PlatformCircularProgressIndicator(cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 50); return CupertinoProgressIndicatorData(radius: 500);
}), }),
); );
} }
@ -172,21 +172,23 @@ CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT
mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc=
-----END NEBULA CERTIFICATE-----'''; -----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( var s = Site(
name: "DEBUG TEST", name: "DEBUG TEST",
id: uuid.v4(), id: uuid.v4(),
staticHostmap: { staticHostmap: {
"10.1.0.1": StaticHost(lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)]) "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)], caInfos: [CertificateInfo.debug(rawCert: ca)],
cert: CertificateInfo.debug(rawCert: cert), certInfos: [certInfo],
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')] 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(); var err = await s.save();
if (err != null) { if (err != null) {
Utils.popError(context, "Failed to save the site", err); Utils.popError(context, "Failed to save the site", err);

View File

@ -1,56 +1,47 @@
import 'dart:convert'; import 'dart:convert';
import 'package:barcode_scan/barcode_scan.dart'; import 'package:barcode_scan/barcode_scan.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.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/SpecialSelectableText.dart';
import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; import 'package:mobile_nebula/components/config/ConfigButtonItem.dart';
import 'package:mobile_nebula/components/config/ConfigItem.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/ConfigSection.dart';
import 'package:mobile_nebula/components/config/ConfigTextItem.dart'; import 'package:mobile_nebula/components/config/ConfigTextItem.dart';
import 'package:mobile_nebula/models/Certificate.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/share.dart';
import 'package:mobile_nebula/services/utils.dart'; import 'package:mobile_nebula/services/utils.dart';
class CertificateResult { import 'CertificateDetailsScreen.dart';
CertificateInfo cert;
String key;
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 { final ValueChanged<CertificateInfo> onSave;
const CertificateScreen({Key key, this.cert, this.onSave}) : super(key: key); final choosePrimary;
final CertificateInfo cert;
final ValueChanged<CertificateResult> onSave;
@override @override
_CertificateScreenState createState() => _CertificateScreenState(); _AddCertificateScreenState createState() => _AddCertificateScreenState();
} }
class _CertificateScreenState extends State<CertificateScreen> { class _AddCertificateScreenState extends State<AddCertificateScreen> {
String pubKey; String pubKey;
String privKey; String privKey;
bool changed = false;
CertificateInfo cert; CertificateInfo certInfo;
String inputType = 'paste'; String inputType = 'paste';
bool shared = false;
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() {
cert = widget.cert; _generateKeys();
super.initState(); super.initState();
} }
@ -62,71 +53,30 @@ class _CertificateScreenState extends State<CertificateScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
List<Widget> items = [];
bool hideSave = true;
if (cert == null) {
if (pubKey == null) { if (pubKey == null) {
items = _buildGenerate(); return Center(
} else { child: PlatformCircularProgressIndicator(cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 500);
}),
);
}
List<Widget> items = [];
items.addAll(_buildShare()); items.addAll(_buildShare());
items.addAll(_buildLoadCert()); items.addAll(_buildLoadCert());
}
} else {
items.addAll(_buildCertList());
hideSave = false;
}
return FormPage( return SimplePage(
title: 'Certificate', 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)); 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<Widget> _buildGenerate() {
return [
ConfigSection(label: 'Please generate a new public and private key', children: [
ConfigButtonItem(
content: Text('Generate Keys'),
onPressed: () => _generateKeys(),
)
])
];
}
_generateKeys() async { _generateKeys() async {
try { try {
var kp = await platform.invokeMethod("nebula.generateKeyPair"); var kp = await platform.invokeMethod("nebula.generateKeyPair");
Map<String, dynamic> keyPair = jsonDecode(kp); Map<String, dynamic> keyPair = jsonDecode(kp);
setState(() { setState(() {
changed = true;
pubKey = keyPair['PublicKey']; pubKey = keyPair['PublicKey'];
privKey = keyPair['PrivateKey']; privKey = keyPair['PrivateKey'];
}); });
@ -148,9 +98,6 @@ class _CertificateScreenState extends State<CertificateScreen> {
content: Text('Share Public Key'), content: Text('Share Public Key'),
onPressed: () async { onPressed: () async {
await Share.share(title: 'Please sign and return a certificate', text: pubKey, filename: 'device.pub'); 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<CertificateScreen> {
ConfigButtonItem( ConfigButtonItem(
content: Center(child: Text('Load Certificate')), content: Center(child: Text('Load Certificate')),
onPressed: () { onPressed: () {
_addCertEntry(pasteController.text, (err) { _addCertEntry(pasteController.text);
if (err != null) {
return Utils.popError(context, 'Failed to parse certificate content', err);
}
pasteController.text = '';
setState(() {});
});
}), }),
], ],
) )
@ -225,13 +165,7 @@ class _CertificateScreenState extends State<CertificateScreen> {
return; return;
} }
_addCertEntry(content, (err) { _addCertEntry(content);
if (err != null) {
Utils.popError(context, 'Error loading certificate file', err);
} else {
setState(() {});
}
});
} catch (err) { } catch (err) {
return Utils.popError(context, 'Failed to load certificate file', err.toString()); return Utils.popError(context, 'Failed to load certificate file', err.toString());
} }
@ -254,13 +188,7 @@ class _CertificateScreenState extends State<CertificateScreen> {
var result = await BarcodeScanner.scan(options: options); var result = await BarcodeScanner.scan(options: options);
if (result.rawContent != "") { if (result.rawContent != "") {
_addCertEntry(result.rawContent, (err) { _addCertEntry(result.rawContent);
if (err != null) {
Utils.popError(context, 'Error loading certificate content', err);
} else {
setState(() {});
}
});
} }
}), }),
], ],
@ -268,9 +196,7 @@ class _CertificateScreenState extends State<CertificateScreen> {
]; ];
} }
_addCertEntry(String rawCert, ValueChanged<String> callback) async { _addCertEntry(String rawCert) async {
String error;
// 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; privKey = _testKey;
@ -280,20 +206,31 @@ class _CertificateScreenState extends State<CertificateScreen> {
var rawCerts = await platform.invokeMethod("nebula.parseCerts", <String, String>{"certs": rawCert}); var rawCerts = await platform.invokeMethod("nebula.parseCerts", <String, String>{"certs": rawCert});
List<dynamic> certs = jsonDecode(rawCerts); List<dynamic> certs = jsonDecode(rawCerts);
if (certs.length > 0) { if (certs.length > 0) {
var tryCert = CertificateInfo.fromJson(certs.first); var tryCertInfo = CertificateInfo.fromJson(certs.first);
if (tryCert.cert.details.isCa) { if (tryCertInfo.cert.details.isCa) {
return callback('A certificate authority is not appropriate for a client certificate.'); return Utils.popError(context, 'Error loading certificate content', 'A certificate authority is not appropriate for a client certificate.');
} } else if (!tryCertInfo.validity.valid) {
//TODO: test that the pubkey matches the privkey return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity.reason);
cert = tryCert;
}
} on PlatformException catch (err) {
error = err.details ?? err.message;
} }
if (callback != null) { //TODO: test that the pubkey we generated equals the pub key in the cert
callback(error); certInfo = tryCertInfo;
certInfo.primary = !widget.choosePrimary;
certInfo.key = privKey;
} }
} on PlatformException catch (err) {
return Utils.popError(context, 'Error loading certificate content', err.details ?? err.message);
}
// 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);
});
});
} }
} }

View File

@ -77,7 +77,7 @@ class _CAListScreenState extends State<CAListScreen> {
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return CertificateDetailsScreen( return CertificateDetailsScreen(
certificate: ca, ca,
onDelete: () { onDelete: () {
setState(() { setState(() {
changed = true; changed = true;

View File

@ -2,30 +2,50 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_platform_widgets/flutter_platform_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/SpecialSelectableText.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/components/config/ConfigSection.dart';
import 'package:mobile_nebula/models/Certificate.dart'; import 'package:mobile_nebula/models/Certificate.dart';
import 'package:mobile_nebula/services/utils.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) /// 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.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 Function onDelete;
final ValueChanged<bool> onSave;
final bool newCert;
final bool choosePrimary;
@override @override
_CertificateDetailsScreenState createState() => _CertificateDetailsScreenState(); _CertificateDetailsScreenState createState() => _CertificateDetailsScreenState();
} }
class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> { class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
bool primary;
bool changed = false;
@override
void initState() {
primary = widget.certInfo.primary ?? false;
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SimplePage( return FormPage(
title: 'Certificate Details', title: 'Certificate Details',
onSave: () {
Navigator.pop(context);
widget.onSave(widget.choosePrimary == true ? primary : false);
},
changed: widget.newCert || changed,
child: Column(children: [ child: Column(children: [
widget.choosePrimary ? _buildPrimaryChooser() : Container(),
_buildID(), _buildID(),
_buildFilters(), _buildFilters(),
_buildValid(), _buildValid(),
@ -35,19 +55,37 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
); );
} }
Widget _buildPrimaryChooser() {
return ConfigSection(children: <Widget>[
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() { Widget _buildID() {
return ConfigSection(children: <Widget>[ return ConfigSection(children: <Widget>[
ConfigItem(label: Text('Name'), content: SpecialSelectableText(widget.certificate.cert.details.name)), ConfigItem(label: Text('Name'), content: SpecialSelectableText(widget.certInfo.cert.details.name)),
ConfigItem( ConfigItem(
label: Text('Type'), 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() { Widget _buildValid() {
var valid = Text('yes'); var valid = Text('yes');
if (widget.certificate.validity != null && !widget.certificate.validity.valid) { if (widget.certInfo.validity != null && !widget.certInfo.validity.valid) {
valid = Text(widget.certificate.validity.valid ? 'yes' : widget.certificate.validity.reason, valid = Text(widget.certInfo.validity.valid ? 'yes' : widget.certInfo.validity.reason,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context))); style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context)));
} }
return ConfigSection( return ConfigSection(
@ -56,33 +94,33 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
ConfigItem(label: Text('Valid?'), content: valid), ConfigItem(label: Text('Valid?'), content: valid),
ConfigItem( ConfigItem(
label: Text('Created'), label: Text('Created'),
content: SpecialSelectableText(widget.certificate.cert.details.notBefore.toLocal().toString())), content: SpecialSelectableText(widget.certInfo.cert.details.notBefore.toLocal().toString())),
ConfigItem( ConfigItem(
label: Text('Expires'), label: Text('Expires'),
content: SpecialSelectableText(widget.certificate.cert.details.notAfter.toLocal().toString())), content: SpecialSelectableText(widget.certInfo.cert.details.notAfter.toLocal().toString())),
], ],
); );
} }
Widget _buildFilters() { Widget _buildFilters() {
List<Widget> items = []; List<Widget> items = [];
if (widget.certificate.cert.details.groups.length > 0) { if (widget.certInfo.cert.details.groups.length > 0) {
items.add(ConfigItem( 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 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( 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 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(); : Container();
} }
@ -91,18 +129,18 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
children: <Widget>[ children: <Widget>[
ConfigItem( ConfigItem(
label: Text('Fingerprint'), label: Text('Fingerprint'),
content: SpecialSelectableText(widget.certificate.cert.fingerprint, content: SpecialSelectableText(widget.certInfo.cert.fingerprint,
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start), crossAxisAlignment: CrossAxisAlignment.start),
ConfigItem( ConfigItem(
label: Text('Public Key'), label: Text('Public Key'),
content: SpecialSelectableText(widget.certificate.cert.details.publicKey, content: SpecialSelectableText(widget.certInfo.cert.details.publicKey,
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start), crossAxisAlignment: CrossAxisAlignment.start),
widget.certificate.rawCert != null widget.certInfo.rawCert != null
? ConfigItem( ? ConfigItem(
label: Text('PEM Format'), label: Text('PEM Format'),
content: SpecialSelectableText(widget.certificate.rawCert, content: SpecialSelectableText(widget.certInfo.rawCert,
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start) crossAxisAlignment: CrossAxisAlignment.start)
: Container(), : Container(),
@ -115,7 +153,7 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
return Container(); 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( return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),

View File

@ -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<Site> onSave;
@override
_CertificatesScreenState createState() => _CertificatesScreenState();
}
class _CertificatesScreenState extends State<CertificatesScreen> {
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<Widget> _buildCertList() {
List<Widget> 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(() {});
}
}

View File

@ -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/ConfigItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/components/config/ConfigSection.dart';
import 'package:mobile_nebula/models/Site.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/AdvancedScreen.dart';
import 'package:mobile_nebula/screens/siteConfig/CAListScreen.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/screens/siteConfig/StaticHostsScreen.dart';
import 'package:mobile_nebula/services/utils.dart'; import 'package:mobile_nebula/services/utils.dart';
@ -105,10 +106,10 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
} }
Widget _keys() { Widget _keys() {
final certError = site.cert == null || !site.cert.validity.valid; final certError = site.primaryCertInfo == null || !site.primaryCertInfo.validity.valid;
var caError = site.ca.length == 0; var caError = site.caInfos.length == 0;
if (!caError) { if (!caError) {
site.ca.forEach((ca) { site.caInfos.forEach((ca) {
if (!ca.validity.valid) { if (!ca.validity.valid) {
caError = true; caError = true;
} }
@ -126,19 +127,29 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
padding: EdgeInsets.only(right: 5)) padding: EdgeInsets.only(right: 5))
: Container(), : Container(),
certError ? Text('Needs attention') : Text(site.cert.cert.details.name) certError ? Text('Needs attention') : Text(site.primaryCertInfo.cert.details.name)
]), ]),
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return CertificateScreen( if (site.certInfos.length > 0) {
cert: site.cert, return CertificatesScreen(
onSave: (result) { site: site,
onSave: (newSite) {
setState(() { setState(() {
changed = true; changed = true;
site.cert = result.cert; site = site;
site.key = result.key;
}); });
}); });
}
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<SiteConfigScreen> {
child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
padding: EdgeInsets.only(right: 5)) padding: EdgeInsets.only(right: 5))
: Container(), : Container(),
caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.ca.length)) caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.caInfos.length))
]), ]),
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return CAListScreen( return CAListScreen(
cas: site.ca, cas: site.caInfos,
onSave: (ca) { onSave: (ca) {
setState(() { setState(() {
changed = true; changed = true;
site.ca = ca; site.caInfos = ca;
}); });
}); });
}); });