Commit dff3fa7e authored by Matt Perry's avatar Matt Perry

Support verifying .flx signatures when updating.

Adds a step to the updater to verify that the new .flx package is signed and
untampered.

Each .flx contains a signed manifest file. The manifest contains a SHA-256 hash
of the .flx contents. See bundle.dart for a description of the new .flx format.
parent 88bcfa2d
name: fitness name: fitness
version: 0.0.1 version: 0.0.1
update_url: http://localhost:9888/examples/fitness/ update-url: http://localhost:9888/examples/fitness/
material-design-icons: material-design-icons:
- name: action/assessment - name: action/assessment
- name: action/help - name: action/help
......
name: stocks
version: 0.0.2
update-url: http://localhost:9888/examples/stocks/
material-design-icons: material-design-icons:
- name: action/account_balance - name: action/account_balance
- name: action/assessment - name: action/assessment
......
...@@ -11,7 +11,7 @@ dependencies: ...@@ -11,7 +11,7 @@ dependencies:
newton: '>=0.1.4 <0.2.0' newton: '>=0.1.4 <0.2.0'
sky_engine: 0.0.38 sky_engine: 0.0.38
sky_services: 0.0.38 sky_services: 0.0.38
sky_tools: '>=0.0.20 <0.1.0' sky_tools: '>=0.0.25 <0.1.0'
vector_math: '>=1.4.3 <2.0.0' vector_math: '>=1.4.3 <2.0.0'
intl: '>=0.12.4+2 <0.13.0' intl: '>=0.12.4+2 <0.13.0'
environment: environment:
......
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
const String kBundleMagic = '#!mojo ';
Future<List<int>> _readBytesWithLength(RandomAccessFile file) async {
ByteData buffer = new ByteData(4);
await file.readInto(buffer.buffer.asUint8List());
int length = buffer.getUint32(0, Endianness.LITTLE_ENDIAN);
return await file.read(length);
}
const int kMaxLineLen = 10*1024;
const int kNewline = 0x0A;
Future<String> _readLine(RandomAccessFile file) async {
String line = '';
while (line.length < kMaxLineLen) {
int byte = await file.readByte();
if (byte == -1 || byte == kNewline)
break;
line += new String.fromCharCode(byte);
}
return line;
}
// Represents a parsed .flx Bundle. Contains information from the bundle's
// header, as well as an open File handle positioned where the zip content
// begins.
// The bundle format is:
// #!mojo <any string>\n
// <32-bit length><signature of the manifest data>
// <32-bit length><manifest data>
// <zip content>
//
// The manifest is a JSON string containing the following keys:
// (optional) name: the name of the package.
// version: the package version.
// update-url: the base URL to download a new manifest and bundle.
// key: a BASE-64 encoded DER-encoded ASN.1 representation of the Q point of the
// ECDSA public key that was used to sign this manifest.
// content-hash: an integer SHA-256 hash value of the <zip content>.
class Bundle {
Bundle(this.path);
final String path;
List<int> signatureBytes;
List<int> manifestBytes;
Map<String, dynamic> manifest;
RandomAccessFile content;
Future<bool> _readHeader() async {
content = await new File(path).open();
String magic = await _readLine(content);
if (!magic.startsWith(kBundleMagic))
return false;
signatureBytes = await _readBytesWithLength(content);
manifestBytes = await _readBytesWithLength(content);
String manifestString = UTF8.decode(manifestBytes);
manifest = JSON.decode(manifestString);
return true;
}
static Future<Bundle> readHeader(String path) async {
Bundle bundle = new Bundle(path);
if (!await bundle._readHeader())
return null;
return bundle;
}
}
...@@ -3,19 +3,38 @@ ...@@ -3,19 +3,38 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:mojo/core.dart'; import 'package:mojo/core.dart';
import 'package:flutter/services.dart'; // TODO(mpcomplete): Remove this 'hide' when we remove the conflicting
// UpdateService from activity.mojom.
import 'package:flutter/services.dart' hide UpdateServiceProxy;
import 'package:sky_services/updater/update_service.mojom.dart'; import 'package:sky_services/updater/update_service.mojom.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:yaml/yaml.dart' as yaml; import 'package:yaml/yaml.dart' as yaml;
import 'package:asn1lib/asn1lib.dart';
import 'package:bignum/bignum.dart';
import 'package:cipher/cipher.dart';
import 'package:cipher/impl/client.dart';
import 'version.dart'; import 'bundle.dart';
import 'pipe_to_file.dart'; import 'pipe_to_file.dart';
import 'version.dart';
const String kManifestFile = 'sky.yaml';
const String kBundleFile = 'app.flx';
const String kManifestFile = 'ui.yaml'; // Number of bytes to read at a time from a file.
const String kBundleFile = 'app.skyx'; const int kReadBlockSize = 32*1024;
// The ECDSA algorithm parameters we're using. These match the parameters used
// by the signing tool in flutter_tools.
final ECDomainParameters _ecDomain = new ECDomainParameters('prime256v1');
final String kSignerAlgorithm = 'SHA-256/ECDSA';
final String kHashAlgorithm = 'SHA-256';
UpdateServiceProxy _initUpdateService() { UpdateServiceProxy _initUpdateService() {
UpdateServiceProxy updateService = new UpdateServiceProxy.unbound(); UpdateServiceProxy updateService = new UpdateServiceProxy.unbound();
...@@ -32,20 +51,45 @@ Future<String> getDataDir() async { ...@@ -32,20 +51,45 @@ Future<String> getDataDir() async {
return cachedDataDir; return cachedDataDir;
} }
// Parses a DER-encoded ASN.1 ECDSA signature block.
ECSignature _asn1ParseSignature(Uint8List signature) {
ASN1Parser parser = new ASN1Parser(signature);
ASN1Object object = parser.nextObject();
if (object is! ASN1Sequence)
return null;
ASN1Sequence sequence = object;
if (!(sequence.elements.length == 2 &&
sequence.elements[0] is ASN1Integer &&
sequence.elements[1] is ASN1Integer))
return null;
ASN1Integer r = sequence.elements[0];
ASN1Integer s = sequence.elements[1];
return new ECSignature(r.valueAsPositiveBigInteger, s.valueAsPositiveBigInteger);
}
class UpdateFailure extends Error {
UpdateFailure(this._message);
String _message;
String toString() => _message;
}
class UpdateTask { class UpdateTask {
UpdateTask() {} UpdateTask();
run() async { Future run() async {
try { try {
await _runImpl(); await _runImpl();
} catch(e) { } on UpdateFailure catch (e) {
print('Update failed: $e');
} catch (e, stackTrace) {
print('Update failed: $e'); print('Update failed: $e');
print('Stack: $stackTrace');
} finally { } finally {
_updateService.ptr.notifyUpdateCheckComplete(); _updateService.ptr.notifyUpdateCheckComplete();
} }
} }
_runImpl() async { Future _runImpl() async {
_dataDir = await getDataDir(); _dataDir = await getDataDir();
await _readLocalManifest(); await _readLocalManifest();
...@@ -54,27 +98,25 @@ class UpdateTask { ...@@ -54,27 +98,25 @@ class UpdateTask {
print('Update skipped. No new version.'); print('Update skipped. No new version.');
return; return;
} }
MojoResult result = await _fetchBundle(); await _fetchBundle();
if (!result.isOk) { await _validateBundle();
print('Update failed while fetching new skyx bundle.');
return;
}
await _replaceBundle(); await _replaceBundle();
print('Update success.'); print('Update success.');
} }
yaml.YamlMap _currentManifest; Map _currentManifest;
String _dataDir; String _dataDir;
String _tempPath; String _tempPath;
_readLocalManifest() async { Future _readLocalManifest() async {
String manifestPath = path.join(_dataDir, kManifestFile); String bundlePath = path.join(_dataDir, kBundleFile);
String manifestData = await new File(manifestPath).readAsString(); Bundle bundle = await Bundle.readHeader(bundlePath);
_currentManifest = yaml.loadYaml(manifestData, sourceUrl: manifestPath); _currentManifest = bundle.manifest;
bundle.content.close();
} }
Future<yaml.YamlMap> _fetchManifest() async { Future<yaml.YamlMap> _fetchManifest() async {
String manifestUrl = _currentManifest['update_url'] + '/' + kManifestFile; String manifestUrl = _currentManifest['update-url'] + '/' + kManifestFile;
String manifestData = await fetchString(manifestUrl); String manifestData = await fetchString(manifestUrl);
return yaml.loadYaml(manifestData, sourceUrl: manifestUrl); return yaml.loadYaml(manifestData, sourceUrl: manifestUrl);
} }
...@@ -85,21 +127,74 @@ class UpdateTask { ...@@ -85,21 +127,74 @@ class UpdateTask {
return (currentVersion < remoteVersion); return (currentVersion < remoteVersion);
} }
Future<MojoResult> _fetchBundle() async { Future _fetchBundle() async {
// TODO(mpcomplete): Use the cache dir. We need an equivalent of mkstemp(). // TODO(mpcomplete): Use the cache dir. We need an equivalent of mkstemp().
_tempPath = path.join(_dataDir, 'tmp.skyx'); _tempPath = path.join(_dataDir, 'tmp.skyx');
String bundleUrl = _currentManifest['update_url'] + '/' + kBundleFile; String bundleUrl = _currentManifest['update-url'] + '/' + kBundleFile;
UrlResponse response = await fetchUrl(bundleUrl); UrlResponse response = await fetchUrl(bundleUrl);
return PipeToFile.copyToFile(response.body, _tempPath); MojoResult result = await PipeToFile.copyToFile(response.body, _tempPath);
if (!result.isOk)
throw new UpdateFailure('Failure fetching new package: ${response.statusLine}');
}
Future _validateBundle() async {
Bundle bundle = await Bundle.readHeader(_tempPath);
if (bundle == null)
throw new UpdateFailure('Remote package not a valid FLX file.');
if (bundle.manifest['key'] != _currentManifest['key'])
throw new UpdateFailure('Remote package key does not match.');
await _verifyManifestSignature(bundle);
await _verifyContentHash(bundle);
bundle.content.close();
}
Future _verifyManifestSignature(Bundle bundle) async {
ECSignature ecSignature = _asn1ParseSignature(bundle.signatureBytes);
if (ecSignature == null)
throw new UpdateFailure('Corrupt package signature.');
List keyBytes = BASE64.decode(_currentManifest['key']);
ECPoint q = _ecDomain.curve.decodePoint(keyBytes);
ECPublicKey ecPublicKey = new ECPublicKey(q, _ecDomain);
Signer signer = new Signer(kSignerAlgorithm);
signer.init(false, new PublicKeyParameter(ecPublicKey));
if (!signer.verifySignature(bundle.manifestBytes, ecSignature))
throw new UpdateFailure('Invalid package signature. This package has been tampered with.');
}
Future _verifyContentHash(Bundle bundle) async {
// Hash the bundle contents.
Digest hasher = new Digest(kHashAlgorithm);
RandomAccessFile content = bundle.content;
int remainingLen = await content.length() - await content.position();
while (remainingLen > 0) {
List<int> chunk = await content.read(min(remainingLen, kReadBlockSize));
hasher.update(chunk, 0, chunk.length);
remainingLen -= chunk.length;
}
Uint8List hashBytes = new Uint8List(hasher.digestSize);
int len = hasher.doFinal(hashBytes, 0);
hashBytes = hashBytes.sublist(0, len);
BigInteger actualHash = new BigInteger.fromBytes(1, hashBytes);
// Compare to our expected hash from the manifest.
BigInteger expectedHash = new BigInteger(bundle.manifest['content-hash'], 10);
if (expectedHash != actualHash)
throw new UpdateFailure('Invalid package content hash. This package has been tampered with.');
} }
_replaceBundle() async { Future _replaceBundle() async {
String bundlePath = path.join(_dataDir, kBundleFile); String bundlePath = path.join(_dataDir, kBundleFile);
await new File(_tempPath).rename(bundlePath); await new File(_tempPath).rename(bundlePath);
} }
} }
void main() { void main() {
var task = new UpdateTask(); initCipher();
UpdateTask task = new UpdateTask();
task.run(); task.run();
} }
...@@ -30,13 +30,13 @@ class PipeToFile { ...@@ -30,13 +30,13 @@ class PipeToFile {
return _consumer.endRead(thisRead.lengthInBytes); return _consumer.endRead(thisRead.lengthInBytes);
} }
Future<MojoResult> drain() async { Future drain() async {
var completer = new Completer(); Completer completer = new Completer();
// TODO(mpcomplete): Is it legit to pass an async callback to listen? // TODO(mpcomplete): Is it legit to pass an async callback to listen?
_eventStream.listen((List<int> event) async { _eventStream.listen((List<int> event) async {
var mojoSignals = new MojoHandleSignals(event[1]); MojoHandleSignals mojoSignals = new MojoHandleSignals(event[1]);
if (mojoSignals.isReadable) { if (mojoSignals.isReadable) {
var result = await _doRead(); MojoResult result = await _doRead();
if (!result.isOk) { if (!result.isOk) {
_eventStream.close(); _eventStream.close();
_eventStream = null; _eventStream = null;
...@@ -58,7 +58,7 @@ class PipeToFile { ...@@ -58,7 +58,7 @@ class PipeToFile {
} }
static Future<MojoResult> copyToFile(MojoDataPipeConsumer consumer, String outputPath) { static Future<MojoResult> copyToFile(MojoDataPipeConsumer consumer, String outputPath) {
var drainer = new PipeToFile(consumer, outputPath); PipeToFile drainer = new PipeToFile(consumer, outputPath);
return drainer.drain(); return drainer.drain();
} }
} }
...@@ -9,7 +9,7 @@ import 'dart:math'; ...@@ -9,7 +9,7 @@ import 'dart:math';
// Usage: assert(new Version('1.1.0') < new Version('1.2.1')); // Usage: assert(new Version('1.1.0') < new Version('1.2.1'));
class Version { class Version {
Version(String versionStr) : Version(String versionStr) :
_parts = versionStr.split('.').map((val) => int.parse(val)).toList(); _parts = versionStr.split('.').map((String val) => int.parse(val)).toList();
List<int> _parts; List<int> _parts;
...@@ -28,5 +28,5 @@ class Version { ...@@ -28,5 +28,5 @@ class Version {
return _parts.length - other._parts.length; // results in 1.0 < 1.0.0 return _parts.length - other._parts.length; // results in 1.0 < 1.0.0
} }
int get hashCode => _parts.fold(373, (acc, part) => 37*acc + part); int get hashCode => _parts.fold(373, (int acc, int part) => 37*acc + part);
} }
...@@ -9,6 +9,8 @@ dependencies: ...@@ -9,6 +9,8 @@ dependencies:
sky_services: any sky_services: any
path: any path: any
yaml: any yaml: any
cipher: any
asn1lib: any
dependency_overrides: dependency_overrides:
flutter: flutter:
path: ../sky path: ../sky
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment