mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Change updater to use shared flx package.
This changes flutter's updater package to depend on and use the new flx package. I also did a little cleanup of the Bundle interface.
This commit is contained in:
parent
abe6ee9b90
commit
cf097758bf
@ -7,7 +7,16 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
const String kBundleMagic = '#!mojo ';
|
||||
import 'package:bignum/bignum.dart';
|
||||
|
||||
import 'signing.dart';
|
||||
|
||||
// Magic string we put at the top of all bundle files.
|
||||
const String kBundleMagic = '#!mojo mojo:sky_viewer\n';
|
||||
|
||||
// Prefix of the above, used when reading bundle files. This allows us to be
|
||||
// more flexbile about what we accept.
|
||||
const String kBundleMagicPrefix = '#!mojo ';
|
||||
|
||||
Future<List<int>> _readBytesWithLength(RandomAccessFile file) async {
|
||||
ByteData buffer = new ByteData(4);
|
||||
@ -29,6 +38,16 @@ Future<String> _readLine(RandomAccessFile file) async {
|
||||
return line;
|
||||
}
|
||||
|
||||
// Writes a 32-bit length followed by the content of [bytes].
|
||||
void _writeBytesWithLengthSync(File outputFile, List<int> bytes) {
|
||||
if (bytes == null)
|
||||
bytes = new Uint8List(0);
|
||||
assert(bytes.length < 0xffffffff);
|
||||
ByteData length = new ByteData(4)..setUint32(0, bytes.length, Endianness.LITTLE_ENDIAN);
|
||||
outputFile.writeAsBytesSync(length.buffer.asUint8List(), mode: FileMode.APPEND);
|
||||
outputFile.writeAsBytesSync(bytes, mode: FileMode.APPEND);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@ -46,30 +65,78 @@ Future<String> _readLine(RandomAccessFile file) async {
|
||||
// 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);
|
||||
Bundle._fromFile(this.path);
|
||||
Bundle.fromContent({
|
||||
this.path,
|
||||
this.manifest,
|
||||
contentBytes,
|
||||
KeyPair keyPair: null
|
||||
}) : _contentBytes = contentBytes {
|
||||
assert(path != null);
|
||||
assert(manifest != null);
|
||||
assert(_contentBytes != null);
|
||||
manifestBytes = serializeManifest(manifest, keyPair?.publicKey, _contentBytes);
|
||||
signatureBytes = signManifest(manifestBytes, keyPair?.privateKey);
|
||||
}
|
||||
|
||||
final String path;
|
||||
List<int> signatureBytes;
|
||||
List<int> manifestBytes;
|
||||
Map<String, dynamic> manifest;
|
||||
RandomAccessFile content;
|
||||
|
||||
// File byte offset of the start of the zip content. Only valid when opened
|
||||
// from a file.
|
||||
int _contentOffset;
|
||||
|
||||
// Zip content bytes. Only valid when created in memory.
|
||||
List<int> _contentBytes;
|
||||
|
||||
Future<bool> _readHeader() async {
|
||||
content = await new File(path).open();
|
||||
String magic = await _readLine(content);
|
||||
if (!magic.startsWith(kBundleMagic))
|
||||
RandomAccessFile file = await new File(path).open();
|
||||
String magic = await _readLine(file);
|
||||
if (!magic.startsWith(kBundleMagicPrefix)) {
|
||||
file.close();
|
||||
return false;
|
||||
signatureBytes = await _readBytesWithLength(content);
|
||||
manifestBytes = await _readBytesWithLength(content);
|
||||
}
|
||||
signatureBytes = await _readBytesWithLength(file);
|
||||
manifestBytes = await _readBytesWithLength(file);
|
||||
_contentOffset = await file.position();
|
||||
file.close();
|
||||
|
||||
String manifestString = UTF8.decode(manifestBytes);
|
||||
manifest = JSON.decode(manifestString);
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<Bundle> readHeader(String path) async {
|
||||
Bundle bundle = new Bundle(path);
|
||||
Bundle bundle = new Bundle._fromFile(path);
|
||||
if (!await bundle._readHeader())
|
||||
return null;
|
||||
return bundle;
|
||||
}
|
||||
|
||||
// When opened from a file, verifies that the package has a valid signature
|
||||
// and content.
|
||||
Future<bool> verifyContent() async {
|
||||
assert(_contentOffset != null);
|
||||
if (!verifyManifestSignature(manifest, manifestBytes, signatureBytes))
|
||||
return false;
|
||||
|
||||
Stream<List<int>> content = await new File(path).openRead(_contentOffset);
|
||||
BigInteger expectedHash = new BigInteger(manifest['content-hash'], 10);
|
||||
if (!await verifyContentHash(expectedHash, content))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Writes the in-memory representation to disk.
|
||||
void writeSync() {
|
||||
assert(_contentBytes != null);
|
||||
File outputFile = new File(path);
|
||||
outputFile.writeAsStringSync('#!mojo mojo:sky_viewer\n');
|
||||
_writeBytesWithLengthSync(outputFile, signatureBytes);
|
||||
_writeBytesWithLengthSync(outputFile, manifestBytes);
|
||||
outputFile.writeAsBytesSync(_contentBytes, mode: FileMode.APPEND, flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,20 +4,23 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:asn1lib/asn1lib.dart';
|
||||
import 'package:bignum/bignum.dart';
|
||||
import 'package:cipher/cipher.dart';
|
||||
import 'package:cipher/impl/client.dart';
|
||||
|
||||
// The ECDSA algorithm parameters we're using. These match the parameters used
|
||||
// by the Flutter updater package.
|
||||
final ECDomainParameters _ecDomain = new ECDomainParameters('prime256v1');
|
||||
final String kSignerAlgorithm = 'SHA-256/ECDSA';
|
||||
final String kHashAlgorithm = 'SHA-256';
|
||||
|
||||
final SecureRandom _random = _initRandom();
|
||||
class CipherParameters {
|
||||
final String signerAlgorithm = 'SHA-256/ECDSA';
|
||||
final String hashAlgorithm = 'SHA-256';
|
||||
final ECDomainParameters domain = new ECDomainParameters('prime256v1');
|
||||
final SecureRandom random = _initRandom();
|
||||
}
|
||||
|
||||
SecureRandom _initRandom() {
|
||||
// TODO(mpcomplete): Provide a better seed here. External entropy source?
|
||||
@ -29,6 +32,13 @@ SecureRandom _initRandom() {
|
||||
return random;
|
||||
}
|
||||
|
||||
CipherParameters _initParams() {
|
||||
initCipher();
|
||||
return new CipherParameters();
|
||||
}
|
||||
|
||||
final CipherParameters _params = _initParams();
|
||||
|
||||
// Returns a serialized manifest, with the public key and hash of the content
|
||||
// included.
|
||||
Uint8List serializeManifest(Map manifestDescriptor, ECPublicKey publicKey, Uint8List zipBytes) {
|
||||
@ -48,7 +58,7 @@ Uint8List serializeManifest(Map manifestDescriptor, ECPublicKey publicKey, Uint8
|
||||
if (publicKey != null)
|
||||
outputManifest['key'] = BASE64.encode(publicKey.Q.getEncoded());
|
||||
|
||||
Uint8List zipHash = new Digest(kHashAlgorithm).process(zipBytes);
|
||||
Uint8List zipHash = new Digest(_params.hashAlgorithm).process(zipBytes);
|
||||
BigInteger zipHashInt = new BigInteger.fromBytes(1, zipHash);
|
||||
outputManifest['content-hash'] = zipHashInt.intValue();
|
||||
|
||||
@ -59,9 +69,9 @@ Uint8List serializeManifest(Map manifestDescriptor, ECPublicKey publicKey, Uint8
|
||||
List<int> signManifest(Uint8List manifestBytes, ECPrivateKey privateKey) {
|
||||
if (manifestBytes == null || privateKey == null)
|
||||
return [];
|
||||
Signer signer = new Signer(kSignerAlgorithm);
|
||||
Signer signer = new Signer(_params.signerAlgorithm);
|
||||
PrivateKeyParameter params = new PrivateKeyParameter(privateKey);
|
||||
signer.init(true, new ParametersWithRandom(params, _random));
|
||||
signer.init(true, new ParametersWithRandom(params, _params.random));
|
||||
ECSignature signature = signer.generateSignature(manifestBytes);
|
||||
ASN1Sequence asn1 = new ASN1Sequence()
|
||||
..add(new ASN1Integer(signature.r))
|
||||
@ -69,6 +79,37 @@ List<int> signManifest(Uint8List manifestBytes, ECPrivateKey privateKey) {
|
||||
return asn1.encodedBytes;
|
||||
}
|
||||
|
||||
bool verifyManifestSignature(Map<String, dynamic> manifest,
|
||||
Uint8List manifestBytes,
|
||||
Uint8List signatureBytes) {
|
||||
ECSignature signature = _asn1ParseSignature(signatureBytes);
|
||||
if (signature == null)
|
||||
return false;
|
||||
|
||||
List<int> keyBytes = BASE64.decode(manifest['key']);
|
||||
ECPoint q = _params.domain.curve.decodePoint(keyBytes);
|
||||
ECPublicKey publicKey = new ECPublicKey(q, _params.domain);
|
||||
|
||||
Signer signer = new Signer(_params.signerAlgorithm);
|
||||
signer.init(false, new PublicKeyParameter(publicKey));
|
||||
return signer.verifySignature(manifestBytes, signature);
|
||||
}
|
||||
|
||||
Future<bool> verifyContentHash(BigInteger expectedHash, Stream<List<int>> content) async {
|
||||
// Hash the file incrementally.
|
||||
Digest hasher = new Digest(_params.hashAlgorithm);
|
||||
await content.forEach((List<int> chunk) {
|
||||
hasher.update(chunk, 0, 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);
|
||||
|
||||
return expectedHash == actualHash;
|
||||
}
|
||||
|
||||
// Parses a DER-encoded ASN.1 ECDSA private key block.
|
||||
ECPrivateKey _asn1ParsePrivateKey(ECDomainParameters ecDomain, Uint8List privateKey) {
|
||||
ASN1Parser parser = new ASN1Parser(privateKey);
|
||||
ASN1Sequence seq = parser.nextObject();
|
||||
@ -78,17 +119,47 @@ ECPrivateKey _asn1ParsePrivateKey(ECDomainParameters ecDomain, Uint8List private
|
||||
return new ECPrivateKey(d, ecDomain);
|
||||
}
|
||||
|
||||
Future<ECPrivateKey> loadPrivateKey(String privateKeyPath) async {
|
||||
// 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);
|
||||
}
|
||||
|
||||
ECPrivateKey _readPrivateKeySync(String privateKeyPath) {
|
||||
File file = new File(privateKeyPath);
|
||||
if (!file.existsSync())
|
||||
return null;
|
||||
List<int> bytes = file.readAsBytesSync();
|
||||
return _asn1ParsePrivateKey(_ecDomain, new Uint8List.fromList(bytes));
|
||||
return _asn1ParsePrivateKey(_params.domain, new Uint8List.fromList(bytes));
|
||||
}
|
||||
|
||||
ECPublicKey publicKeyFromPrivateKey(ECPrivateKey privateKey) {
|
||||
if (privateKey == null)
|
||||
return null;
|
||||
ECPublicKey _publicKeyFromPrivateKey(ECPrivateKey privateKey) {
|
||||
ECPoint Q = privateKey.parameters.G * privateKey.d;
|
||||
return new ECPublicKey(Q, privateKey.parameters);
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
KeyPair(this.publicKey, this.privateKey);
|
||||
|
||||
ECPublicKey publicKey;
|
||||
ECPrivateKey privateKey;
|
||||
|
||||
static KeyPair readFromPrivateKeySync(String path) {
|
||||
ECPrivateKey privateKey = _readPrivateKeySync(path);
|
||||
if (privateKey == null)
|
||||
return null;
|
||||
|
||||
ECPublicKey publicKey = _publicKeyFromPrivateKey(privateKey);
|
||||
return new KeyPair(publicKey, privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ author: Flutter Authors <flutter-dev@googlegroups.com>
|
||||
description: Library for dealing with Flutter bundle (.flx) files
|
||||
homepage: http://flutter.io
|
||||
dependencies:
|
||||
sky_services: 0.0.38
|
||||
sky_services: ^0.0.40
|
||||
yaml: ^2.1.3
|
||||
asn1lib: ^0.4.1
|
||||
cipher: ^0.7.1
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@ -9,33 +9,18 @@ import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:mojo/core.dart';
|
||||
// TODO(mpcomplete): Remove this 'hide' when we remove the conflicting
|
||||
// UpdateService from activity.mojom.
|
||||
import 'package:flutter/services.dart' hide UpdateServiceProxy;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flx/bundle.dart';
|
||||
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 'bundle.dart';
|
||||
import 'pipe_to_file.dart';
|
||||
import 'version.dart';
|
||||
|
||||
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();
|
||||
shell.requestService(null, updateService);
|
||||
@ -51,22 +36,6 @@ Future<String> 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;
|
||||
@ -112,7 +81,6 @@ class UpdateTask {
|
||||
String bundlePath = path.join(_dataDir, kBundleFile);
|
||||
Bundle bundle = await Bundle.readHeader(bundlePath);
|
||||
_currentManifest = bundle.manifest;
|
||||
bundle.content.close();
|
||||
}
|
||||
|
||||
Future<yaml.YamlMap> _fetchManifest() async {
|
||||
@ -144,47 +112,8 @@ class UpdateTask {
|
||||
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.');
|
||||
if (!await bundle.verifyContent())
|
||||
throw new UpdateFailure('Invalid package signature or hash. This package has been tampered with.');
|
||||
}
|
||||
|
||||
Future _replaceBundle() async {
|
||||
@ -194,7 +123,6 @@ class UpdateTask {
|
||||
}
|
||||
|
||||
void main() {
|
||||
initCipher();
|
||||
UpdateTask task = new UpdateTask();
|
||||
task.run();
|
||||
}
|
||||
|
||||
@ -6,13 +6,14 @@ homepage: http://flutter.io
|
||||
dependencies:
|
||||
mojo: 0.3.0
|
||||
flutter: ">=0.0.3 <0.1.0"
|
||||
sky_services: any
|
||||
path: any
|
||||
yaml: any
|
||||
cipher: any
|
||||
asn1lib: any
|
||||
sky_services: ^0.0.40
|
||||
yaml: ^2.1.3
|
||||
path: ^1.3.0
|
||||
flx: 0.0.1
|
||||
dependency_overrides:
|
||||
flutter:
|
||||
path: ../sky
|
||||
flx:
|
||||
path: ../flx
|
||||
environment:
|
||||
sdk: '>=1.12.0 <2.0.0'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user