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.
This commit is contained in:
75
engine/src/flutter/sky/packages/updater/lib/bundle.dart
Normal file
75
engine/src/flutter/sky/packages/updater/lib/bundle.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
// 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 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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:path/path.dart' as path;
|
||||
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 'version.dart';
|
||||
|
||||
const String kManifestFile = 'ui.yaml';
|
||||
const String kBundleFile = 'app.skyx';
|
||||
const String kManifestFile = 'sky.yaml';
|
||||
const String kBundleFile = 'app.flx';
|
||||
|
||||
// Number of bytes to read at a time from a file.
|
||||
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 updateService = new UpdateServiceProxy.unbound();
|
||||
@@ -32,20 +51,45 @@ Future<String> getDataDir() async {
|
||||
return cachedDataDir;
|
||||
}
|
||||
|
||||
class UpdateTask {
|
||||
UpdateTask() {}
|
||||
// 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);
|
||||
}
|
||||
|
||||
run() async {
|
||||
class UpdateFailure extends Error {
|
||||
UpdateFailure(this._message);
|
||||
String _message;
|
||||
String toString() => _message;
|
||||
}
|
||||
|
||||
class UpdateTask {
|
||||
UpdateTask();
|
||||
|
||||
Future run() async {
|
||||
try {
|
||||
await _runImpl();
|
||||
} catch(e) {
|
||||
} on UpdateFailure catch (e) {
|
||||
print('Update failed: $e');
|
||||
} catch (e, stackTrace) {
|
||||
print('Update failed: $e');
|
||||
print('Stack: $stackTrace');
|
||||
} finally {
|
||||
_updateService.ptr.notifyUpdateCheckComplete();
|
||||
}
|
||||
}
|
||||
|
||||
_runImpl() async {
|
||||
Future _runImpl() async {
|
||||
_dataDir = await getDataDir();
|
||||
|
||||
await _readLocalManifest();
|
||||
@@ -54,27 +98,25 @@ class UpdateTask {
|
||||
print('Update skipped. No new version.');
|
||||
return;
|
||||
}
|
||||
MojoResult result = await _fetchBundle();
|
||||
if (!result.isOk) {
|
||||
print('Update failed while fetching new skyx bundle.');
|
||||
return;
|
||||
}
|
||||
await _fetchBundle();
|
||||
await _validateBundle();
|
||||
await _replaceBundle();
|
||||
print('Update success.');
|
||||
}
|
||||
|
||||
yaml.YamlMap _currentManifest;
|
||||
Map _currentManifest;
|
||||
String _dataDir;
|
||||
String _tempPath;
|
||||
|
||||
_readLocalManifest() async {
|
||||
String manifestPath = path.join(_dataDir, kManifestFile);
|
||||
String manifestData = await new File(manifestPath).readAsString();
|
||||
_currentManifest = yaml.loadYaml(manifestData, sourceUrl: manifestPath);
|
||||
Future _readLocalManifest() async {
|
||||
String bundlePath = path.join(_dataDir, kBundleFile);
|
||||
Bundle bundle = await Bundle.readHeader(bundlePath);
|
||||
_currentManifest = bundle.manifest;
|
||||
bundle.content.close();
|
||||
}
|
||||
|
||||
Future<yaml.YamlMap> _fetchManifest() async {
|
||||
String manifestUrl = _currentManifest['update_url'] + '/' + kManifestFile;
|
||||
String manifestUrl = _currentManifest['update-url'] + '/' + kManifestFile;
|
||||
String manifestData = await fetchString(manifestUrl);
|
||||
return yaml.loadYaml(manifestData, sourceUrl: manifestUrl);
|
||||
}
|
||||
@@ -85,21 +127,74 @@ class UpdateTask {
|
||||
return (currentVersion < remoteVersion);
|
||||
}
|
||||
|
||||
Future<MojoResult> _fetchBundle() async {
|
||||
Future _fetchBundle() async {
|
||||
// TODO(mpcomplete): Use the cache dir. We need an equivalent of mkstemp().
|
||||
_tempPath = path.join(_dataDir, 'tmp.skyx');
|
||||
String bundleUrl = _currentManifest['update_url'] + '/' + kBundleFile;
|
||||
String bundleUrl = _currentManifest['update-url'] + '/' + kBundleFile;
|
||||
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}');
|
||||
}
|
||||
|
||||
_replaceBundle() async {
|
||||
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.');
|
||||
}
|
||||
|
||||
Future _replaceBundle() async {
|
||||
String bundlePath = path.join(_dataDir, kBundleFile);
|
||||
await new File(_tempPath).rename(bundlePath);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
var task = new UpdateTask();
|
||||
initCipher();
|
||||
UpdateTask task = new UpdateTask();
|
||||
task.run();
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ class PipeToFile {
|
||||
return _consumer.endRead(thisRead.lengthInBytes);
|
||||
}
|
||||
|
||||
Future<MojoResult> drain() async {
|
||||
var completer = new Completer();
|
||||
Future drain() async {
|
||||
Completer completer = new Completer();
|
||||
// TODO(mpcomplete): Is it legit to pass an async callback to listen?
|
||||
_eventStream.listen((List<int> event) async {
|
||||
var mojoSignals = new MojoHandleSignals(event[1]);
|
||||
MojoHandleSignals mojoSignals = new MojoHandleSignals(event[1]);
|
||||
if (mojoSignals.isReadable) {
|
||||
var result = await _doRead();
|
||||
MojoResult result = await _doRead();
|
||||
if (!result.isOk) {
|
||||
_eventStream.close();
|
||||
_eventStream = null;
|
||||
@@ -58,7 +58,7 @@ class PipeToFile {
|
||||
}
|
||||
|
||||
static Future<MojoResult> copyToFile(MojoDataPipeConsumer consumer, String outputPath) {
|
||||
var drainer = new PipeToFile(consumer, outputPath);
|
||||
PipeToFile drainer = new PipeToFile(consumer, outputPath);
|
||||
return drainer.drain();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'dart:math';
|
||||
// Usage: assert(new Version('1.1.0') < new Version('1.2.1'));
|
||||
class Version {
|
||||
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;
|
||||
|
||||
@@ -28,5 +28,5 @@ class Version {
|
||||
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:
|
||||
sky_services: any
|
||||
path: any
|
||||
yaml: any
|
||||
cipher: any
|
||||
asn1lib: any
|
||||
dependency_overrides:
|
||||
flutter:
|
||||
path: ../sky
|
||||
|
||||
@@ -6,6 +6,8 @@ homepage: https://github.com/flutter/engine/tree/master/sky/packages/workbench
|
||||
dependencies:
|
||||
flutter: ">=0.0.3 <0.1.0"
|
||||
sky_tools: any
|
||||
cipher: any
|
||||
asn1lib: any
|
||||
dependency_overrides:
|
||||
material_design_icons:
|
||||
path: ../material_design_icons
|
||||
|
||||
1
engine/src/flutter/sky/tools/sky_build.py
Normal file → Executable file
1
engine/src/flutter/sky/tools/sky_build.py
Normal file → Executable file
@@ -33,6 +33,7 @@ def main():
|
||||
'--output-file', os.path.abspath(args.output_file),
|
||||
'--package-root', os.path.abspath(args.package_root),
|
||||
'--snapshot', os.path.abspath(args.snapshot),
|
||||
'--private-key', os.path.abspath(os.path.join(args.package_root, '..', 'privatekey.der')),
|
||||
]
|
||||
|
||||
if args.manifest:
|
||||
|
||||
Reference in New Issue
Block a user