diff --git a/examples/fitness/sky.yaml b/examples/fitness/sky.yaml index ebb7dc228008a..3eb1406efe186 100644 --- a/examples/fitness/sky.yaml +++ b/examples/fitness/sky.yaml @@ -1,6 +1,6 @@ name: fitness version: 0.0.1 -update_url: http://localhost:9888/examples/fitness/ +update-url: http://localhost:9888/examples/fitness/ material-design-icons: - name: action/assessment - name: action/help diff --git a/examples/stocks/sky.yaml b/examples/stocks/sky.yaml index 7e3bf1d81cc10..e71bce607a8d4 100644 --- a/examples/stocks/sky.yaml +++ b/examples/stocks/sky.yaml @@ -1,3 +1,6 @@ +name: stocks +version: 0.0.2 +update-url: http://localhost:9888/examples/stocks/ material-design-icons: - name: action/account_balance - name: action/assessment diff --git a/sky/packages/sky/pubspec.yaml b/sky/packages/sky/pubspec.yaml index 638ce8b56de66..5851cef4099a6 100644 --- a/sky/packages/sky/pubspec.yaml +++ b/sky/packages/sky/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: newton: '>=0.1.4 <0.2.0' sky_engine: 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' intl: '>=0.12.4+2 <0.13.0' environment: diff --git a/sky/packages/updater/lib/bundle.dart b/sky/packages/updater/lib/bundle.dart new file mode 100644 index 0000000000000..9e72a259215d9 --- /dev/null +++ b/sky/packages/updater/lib/bundle.dart @@ -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> _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 _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 \n +// <32-bit length> +// <32-bit length> +// +// +// 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 . +class Bundle { + Bundle(this.path); + + final String path; + List signatureBytes; + List manifestBytes; + Map manifest; + RandomAccessFile content; + + Future _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 readHeader(String path) async { + Bundle bundle = new Bundle(path); + if (!await bundle._readHeader()) + return null; + return bundle; + } +} diff --git a/sky/packages/updater/lib/main.dart b/sky/packages/updater/lib/main.dart index 9a748eb6e1a6e..307984c0d04a6 100644 --- a/sky/packages/updater/lib/main.dart +++ b/sky/packages/updater/lib/main.dart @@ -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 = 'sky.yaml'; +const String kBundleFile = 'app.flx'; -const String kManifestFile = 'ui.yaml'; -const String kBundleFile = 'app.skyx'; +// 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 getDataDir() async { 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 { - UpdateTask() {} + UpdateTask(); - run() async { + 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 _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 _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}'); + } + + 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 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); await new File(_tempPath).rename(bundlePath); } } void main() { - var task = new UpdateTask(); + initCipher(); + UpdateTask task = new UpdateTask(); task.run(); } diff --git a/sky/packages/updater/lib/pipe_to_file.dart b/sky/packages/updater/lib/pipe_to_file.dart index f6a62d0a0b77e..511a7a369e153 100644 --- a/sky/packages/updater/lib/pipe_to_file.dart +++ b/sky/packages/updater/lib/pipe_to_file.dart @@ -30,13 +30,13 @@ class PipeToFile { return _consumer.endRead(thisRead.lengthInBytes); } - Future 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 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 copyToFile(MojoDataPipeConsumer consumer, String outputPath) { - var drainer = new PipeToFile(consumer, outputPath); + PipeToFile drainer = new PipeToFile(consumer, outputPath); return drainer.drain(); } } diff --git a/sky/packages/updater/lib/version.dart b/sky/packages/updater/lib/version.dart index 975927736cb5d..15c1ab7d5c23f 100644 --- a/sky/packages/updater/lib/version.dart +++ b/sky/packages/updater/lib/version.dart @@ -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 _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); } diff --git a/sky/packages/updater/pubspec.yaml b/sky/packages/updater/pubspec.yaml index 198081cc74bd6..a1eb2a6f57ebc 100644 --- a/sky/packages/updater/pubspec.yaml +++ b/sky/packages/updater/pubspec.yaml @@ -9,6 +9,8 @@ dependencies: sky_services: any path: any yaml: any + cipher: any + asn1lib: any dependency_overrides: flutter: path: ../sky diff --git a/sky/packages/workbench/pubspec.yaml b/sky/packages/workbench/pubspec.yaml index 5d7ec55f90576..bd035f07db39f 100644 --- a/sky/packages/workbench/pubspec.yaml +++ b/sky/packages/workbench/pubspec.yaml @@ -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 diff --git a/sky/tools/sky_build.py b/sky/tools/sky_build.py old mode 100644 new mode 100755 index 49152d1ebd335..21eb820ebe187 --- a/sky/tools/sky_build.py +++ b/sky/tools/sky_build.py @@ -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: