feat(open_aot_patch_tools): add compact AOT patch artifact tools with AES-GCM encryption
ci / 📄 License Check (push) Has been cancelled
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / 🔎 Verify ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
Shorebird CI / artifact_proxy (push) Has been cancelled
Shorebird CI / dex (push) Has been cancelled
Shorebird CI / discord_gcp_alerts (push) Has been cancelled
Shorebird CI / flutter_version_resolver (push) Has been cancelled
Shorebird CI / jwt (push) Has been cancelled
Shorebird CI / scoped_deps (push) Has been cancelled
Shorebird CI / shorebird_build_trace (push) Has been cancelled
Shorebird CI / shorebird_ci (push) Has been cancelled
Shorebird CI / shorebird_cli (push) Has been cancelled
Shorebird CI / shorebird_code_push_client (push) Has been cancelled
Shorebird CI / shorebird_code_push_protocol (push) Has been cancelled
Shorebird CI / shorebird_redis_client (push) Has been cancelled
Shorebird CI / stripe_api (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled
ci / 📄 License Check (push) Has been cancelled
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / 🔎 Verify ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
Shorebird CI / artifact_proxy (push) Has been cancelled
Shorebird CI / dex (push) Has been cancelled
Shorebird CI / discord_gcp_alerts (push) Has been cancelled
Shorebird CI / flutter_version_resolver (push) Has been cancelled
Shorebird CI / jwt (push) Has been cancelled
Shorebird CI / scoped_deps (push) Has been cancelled
Shorebird CI / shorebird_build_trace (push) Has been cancelled
Shorebird CI / shorebird_ci (push) Has been cancelled
Shorebird CI / shorebird_cli (push) Has been cancelled
Shorebird CI / shorebird_code_push_client (push) Has been cancelled
Shorebird CI / shorebird_code_push_protocol (push) Has been cancelled
Shorebird CI / shorebird_redis_client (push) Has been cancelled
Shorebird CI / stripe_api (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled
- Introduced a new package `open_aot_patch_tools` for creating compact, flavor-aware AOT patch artifacts. - Implemented runtime model for AOT patches, ensuring compatibility with iOS constraints. - Added delivery security using AES-256-GCM for encrypted patch artifacts. - Created compact payload formats including empty, binary diff, and full snapshot. - Developed linking and encryption functionalities for patch artifacts. - Added tests for verifying encryption, metadata validation, and artifact reconstruction. - Included documentation for the design and usage of the patch tools.
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:open_aot_patch_tools/open_aot_patch_tools.dart';
|
||||
|
||||
void main(List<String> args) {
|
||||
if (args.isEmpty || args.first == '--help' || args.first == '-h') {
|
||||
_usage();
|
||||
return;
|
||||
}
|
||||
|
||||
final command = args.first;
|
||||
final options = _parse(args.skip(1).toList());
|
||||
switch (command) {
|
||||
case 'link':
|
||||
_link(options);
|
||||
case 'encrypt':
|
||||
_encrypt(options);
|
||||
case 'verify':
|
||||
_verify(options);
|
||||
case 'dump-blobs':
|
||||
_dumpBlobs(options);
|
||||
case 'compile-patch':
|
||||
_compilePatch(options);
|
||||
default:
|
||||
stderr.writeln('Unknown command: $command');
|
||||
_usage();
|
||||
exitCode = 64;
|
||||
}
|
||||
}
|
||||
|
||||
void _link(Map<String, String> options) {
|
||||
final base = File(_required(options, 'base')).readAsBytesSync();
|
||||
final patch = File(_required(options, 'patch')).readAsBytesSync();
|
||||
final metadata = PatchMetadata(
|
||||
appId: _required(options, 'app-id'),
|
||||
appBuildId: _required(options, 'app-build-id'),
|
||||
baseFlavorId: options['base-flavor-id'],
|
||||
baseLicenseType: options['base-license-type'],
|
||||
flavorId: _required(options, 'flavor-id'),
|
||||
licenseType: _required(options, 'license-type'),
|
||||
sdkHash: _required(options, 'sdk-hash'),
|
||||
baseSnapshotHash: sha256Hex(base),
|
||||
patchSnapshotHash: sha256Hex(patch),
|
||||
targetOs: _required(options, 'target-os'),
|
||||
targetArch: _required(options, 'target-arch'),
|
||||
obfuscationMapHash: options['obfuscation-map-hash'],
|
||||
);
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: base,
|
||||
patchSnapshot: patch,
|
||||
metadata: metadata,
|
||||
forceFullSnapshot: _boolOption(options, 'full-snapshot'),
|
||||
);
|
||||
File(_required(options, 'output')).writeAsStringSync(
|
||||
const JsonEncoder.withIndent(' ').convert(artifact.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
void _encrypt(Map<String, String> options) {
|
||||
final artifact = PatchArtifact.fromJson(
|
||||
readJsonFile(File(_required(options, 'input'))),
|
||||
);
|
||||
final encrypted = encryptArtifact(
|
||||
artifact: artifact,
|
||||
keyId: _required(options, 'key-id'),
|
||||
key: readKey(_required(options, 'key-hex')),
|
||||
nonce: readNonce(_required(options, 'nonce-hex')),
|
||||
);
|
||||
File(_required(options, 'output')).writeAsStringSync(
|
||||
const JsonEncoder.withIndent(' ').convert(encrypted.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
void _verify(Map<String, String> options) {
|
||||
final input = File(_required(options, 'input'));
|
||||
final inputBytes = input.readAsBytesSync();
|
||||
_verifyArtifactHash(inputBytes, options['artifact-sha256']);
|
||||
final encrypted = EncryptedPatchArtifact.fromJson(
|
||||
(jsonDecode(utf8.decode(inputBytes)) as Map).cast<String, Object?>(),
|
||||
);
|
||||
verifyMetadata(
|
||||
encrypted.metadata,
|
||||
baseFlavorId: options['base-flavor-id'],
|
||||
baseLicenseType: options['base-license-type'],
|
||||
flavorId: _required(options, 'flavor-id'),
|
||||
licenseType: _required(options, 'license-type'),
|
||||
);
|
||||
final decrypted = encrypted.decrypt(readKey(_required(options, 'key-hex')));
|
||||
final basePath = options['base'];
|
||||
if (basePath != null) {
|
||||
final reconstructed = decrypted.reconstruct(
|
||||
File(basePath).readAsBytesSync(),
|
||||
);
|
||||
final reconstructedHash = sha256Hex(reconstructed);
|
||||
if (reconstructedHash != decrypted.metadata.patchSnapshotHash) {
|
||||
throw StateError(
|
||||
'Reconstructed patch hash $reconstructedHash does not match metadata '
|
||||
'${decrypted.metadata.patchSnapshotHash}.',
|
||||
);
|
||||
}
|
||||
} else if (decrypted.payloadKind == payloadKindBinaryDiff) {
|
||||
stderr.writeln(
|
||||
'warning: compact binary diff decrypted, but --base was not provided; '
|
||||
'skipping reconstructed patch hash verification.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _verifyArtifactHash(List<int> bytes, String? expectedHash) {
|
||||
if (expectedHash == null) {
|
||||
return;
|
||||
}
|
||||
final actualHash = sha256Hex(bytes);
|
||||
if (actualHash != expectedHash) {
|
||||
throw StateError(
|
||||
'Artifact hash $actualHash does not match expected $expectedHash.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _dumpBlobs(Map<String, String> options) {
|
||||
final input = File(_required(options, 'input'));
|
||||
final bytes = input.readAsBytesSync();
|
||||
Map<String, Object?> report;
|
||||
try {
|
||||
final json = (jsonDecode(utf8.decode(bytes)) as Map)
|
||||
.cast<String, Object?>();
|
||||
final format = json['format'];
|
||||
if (format == artifactFormat) {
|
||||
final artifact = PatchArtifact.fromJson(json);
|
||||
final reconstructed = _maybeReconstruct(
|
||||
artifact,
|
||||
basePath: options['base'],
|
||||
outputPath: options['output'],
|
||||
);
|
||||
report = {
|
||||
'path': input.path,
|
||||
'format': artifactFormat,
|
||||
'artifact_sha256': sha256Hex(bytes),
|
||||
'metadata': artifact.metadata.toJson(),
|
||||
'payload_kind': artifact.payloadKind,
|
||||
'payload_size': artifact.payload.length,
|
||||
'payload_sha256': sha256Hex(artifact.payload),
|
||||
if (artifact.reconstructedSize != null)
|
||||
'reconstructed_size': artifact.reconstructedSize,
|
||||
if (reconstructed != null)
|
||||
'reconstructed_patch_size': reconstructed.length,
|
||||
if (reconstructed != null)
|
||||
'reconstructed_patch_sha256': sha256Hex(reconstructed),
|
||||
if (options['output'] != null) 'output': options['output'],
|
||||
};
|
||||
} else if (format == encryptedArtifactFormat) {
|
||||
final encrypted = EncryptedPatchArtifact.fromJson(json);
|
||||
report = {
|
||||
'path': input.path,
|
||||
'format': encryptedArtifactFormat,
|
||||
'artifact_sha256': sha256Hex(bytes),
|
||||
'metadata': encrypted.metadata.toJson(),
|
||||
'payload_kind': encrypted.payloadKind,
|
||||
'encrypted_payload_size': encrypted.encryptedPayload.length,
|
||||
'payload_sha256': encrypted.payloadSha256,
|
||||
'key_id': encrypted.keyId,
|
||||
if (encrypted.reconstructedSize != null)
|
||||
'reconstructed_size': encrypted.reconstructedSize,
|
||||
};
|
||||
final keyHex = options['key-hex'];
|
||||
if (keyHex != null) {
|
||||
final decrypted = encrypted.decrypt(readKey(keyHex));
|
||||
final reconstructed = _maybeReconstruct(
|
||||
decrypted,
|
||||
basePath: options['base'],
|
||||
outputPath: options['output'],
|
||||
);
|
||||
report = {
|
||||
...report,
|
||||
'decrypted_payload_size': decrypted.payload.length,
|
||||
'decrypted_payload_sha256': sha256Hex(decrypted.payload),
|
||||
if (reconstructed != null)
|
||||
'reconstructed_patch_size': reconstructed.length,
|
||||
if (reconstructed != null)
|
||||
'reconstructed_patch_sha256': sha256Hex(reconstructed),
|
||||
if (options['output'] != null) 'output': options['output'],
|
||||
};
|
||||
} else if (options['output'] != null) {
|
||||
throw const FormatException(
|
||||
'Encrypted artifacts require --key-hex when --output is used.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw const FormatException('not an open AOT patch artifact');
|
||||
}
|
||||
} on Object {
|
||||
report = {
|
||||
'path': input.path,
|
||||
'format': 'raw',
|
||||
'size': bytes.length,
|
||||
'sha256': sha256Hex(bytes),
|
||||
};
|
||||
}
|
||||
stdout.writeln(const JsonEncoder.withIndent(' ').convert(report));
|
||||
}
|
||||
|
||||
List<int>? _maybeReconstruct(
|
||||
PatchArtifact artifact, {
|
||||
required String? basePath,
|
||||
required String? outputPath,
|
||||
}) {
|
||||
if (basePath == null && artifact.payloadKind != payloadKindFullSnapshot) {
|
||||
if (outputPath != null) {
|
||||
throw const FormatException(
|
||||
'Compact artifacts require --base when --output is used.',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final base = basePath == null
|
||||
? const <int>[]
|
||||
: File(basePath).readAsBytesSync();
|
||||
final reconstructed = artifact.reconstruct(base);
|
||||
if (sha256Hex(reconstructed) != artifact.metadata.patchSnapshotHash) {
|
||||
throw StateError(
|
||||
'Reconstructed patch hash does not match metadata '
|
||||
'${artifact.metadata.patchSnapshotHash}.',
|
||||
);
|
||||
}
|
||||
if (outputPath != null) {
|
||||
File(outputPath).writeAsBytesSync(reconstructed);
|
||||
}
|
||||
return reconstructed;
|
||||
}
|
||||
|
||||
void _compilePatch(Map<String, String> options) {
|
||||
final genSnapshot = _required(options, 'gen-snapshot');
|
||||
final kernel = _required(options, 'kernel');
|
||||
final output = _required(options, 'output');
|
||||
final snapshotKind = options['snapshot-kind'] ?? 'app-aot-elf';
|
||||
final args = <String>['--snapshot-kind=$snapshotKind'];
|
||||
switch (snapshotKind) {
|
||||
case 'app-aot-elf':
|
||||
args.add('--elf=$output');
|
||||
case 'app-aot-macho-dylib':
|
||||
args.add('--macho=$output');
|
||||
final machoObject = options['macho-object'];
|
||||
if (machoObject != null) {
|
||||
args.add('--macho-object=$machoObject');
|
||||
}
|
||||
case 'app-aot-assembly':
|
||||
args.add('--assembly=$output');
|
||||
default:
|
||||
throw ArgumentError('Unsupported --snapshot-kind=$snapshotKind');
|
||||
}
|
||||
if (_boolOption(options, 'strip', defaultValue: true)) {
|
||||
args.add('--strip');
|
||||
}
|
||||
final loadObfuscationMap = options['load-obfuscation-map'];
|
||||
final saveObfuscationMap = options['save-obfuscation-map'];
|
||||
if (_boolOption(options, 'obfuscate') ||
|
||||
loadObfuscationMap != null ||
|
||||
saveObfuscationMap != null) {
|
||||
args.add('--obfuscate');
|
||||
}
|
||||
if (loadObfuscationMap != null) {
|
||||
args.add('--load-obfuscation-map=$loadObfuscationMap');
|
||||
}
|
||||
if (saveObfuscationMap != null) {
|
||||
args.add('--save-obfuscation-map=$saveObfuscationMap');
|
||||
}
|
||||
args.add(kernel);
|
||||
|
||||
final result = Process.runSync(genSnapshot, args);
|
||||
stdout.write(result.stdout);
|
||||
stderr.write(result.stderr);
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
genSnapshot,
|
||||
args,
|
||||
'gen_snapshot failed',
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _parse(List<String> args) {
|
||||
final parsed = <String, String>{};
|
||||
for (final arg in args) {
|
||||
if (!arg.startsWith('--') || !arg.contains('=')) {
|
||||
throw ArgumentError('Expected --name=value, got $arg');
|
||||
}
|
||||
final separator = arg.indexOf('=');
|
||||
parsed[arg.substring(2, separator)] = arg.substring(separator + 1);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
String _required(Map<String, String> options, String name) {
|
||||
final value = options[name];
|
||||
if (value == null || value.isEmpty) {
|
||||
throw ArgumentError('Missing --$name');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool _boolOption(
|
||||
Map<String, String> options,
|
||||
String name, {
|
||||
bool defaultValue = false,
|
||||
}) {
|
||||
final value = options[name];
|
||||
if (value == null) return defaultValue;
|
||||
return value == 'true' || value == '1' || value == 'yes';
|
||||
}
|
||||
|
||||
void _usage() {
|
||||
stdout.writeln('''
|
||||
Usage:
|
||||
open_aot_patch_tools dump-blobs --input=<file> [--key-hex=<64 hex chars>] [--base=<file>] [--output=<vmcode>]
|
||||
open_aot_patch_tools compile-patch --gen-snapshot=<path> --kernel=<dill> --output=<file> [--snapshot-kind=app-aot-elf|app-aot-macho-dylib|app-aot-assembly] [--macho-object=<file>] [--obfuscate=true] [--load-obfuscation-map=<file>] [--save-obfuscation-map=<file>]
|
||||
open_aot_patch_tools link --base=<file> --patch=<file> --output=<file> --app-id=<id> --app-build-id=<id> --flavor-id=<id> --license-type=<type> --sdk-hash=<hash> --target-os=<os> --target-arch=<arch> [--base-flavor-id=<id>] [--base-license-type=<type>] [--obfuscation-map-hash=<sha256>] [--full-snapshot=true]
|
||||
open_aot_patch_tools encrypt --input=<file> --output=<file> --key-id=<id> --key-hex=<64 hex chars> --nonce-hex=<24 hex chars>
|
||||
open_aot_patch_tools verify --input=<file> --key-hex=<64 hex chars> --flavor-id=<id> --license-type=<type> [--base=<file>] [--artifact-sha256=<sha256>] [--base-flavor-id=<id>] [--base-license-type=<type>]
|
||||
''');
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# Open AOT Patch Design
|
||||
|
||||
This package produces compact, flavor-aware AOT patch artifacts without relying
|
||||
on `DART_DYNAMIC_MODULES` or the Dart bytecode interpreter.
|
||||
|
||||
## Runtime Model
|
||||
|
||||
Patches are AOT snapshot artifacts. The engine selects patched isolate snapshot
|
||||
data and instructions before isolate startup, matching Flutter's existing
|
||||
snapshot mapping flow. The VM data and VM instructions remain from the base
|
||||
runtime.
|
||||
|
||||
This keeps the design compatible with iOS constraints:
|
||||
|
||||
- no JIT dependency
|
||||
- no writable executable memory requirement
|
||||
- no KBC interpreter dependency
|
||||
- patch metadata must bind the artifact to platform, architecture, SDK hash,
|
||||
app build id, flavor/license id, base snapshot hash, patch snapshot hash, and
|
||||
obfuscation map hash when obfuscation is used
|
||||
|
||||
## Delivery Security
|
||||
|
||||
AES-256-GCM protects downloaded patch artifacts in transit and at rest. The
|
||||
metadata is authenticated as AAD, and the decrypted compact payload is
|
||||
hash-checked. When a base snapshot is supplied, `verify --base=<file>` also
|
||||
reconstructs the patch snapshot and checks it against the metadata
|
||||
`patch_snapshot_hash`.
|
||||
|
||||
AES is not the compatibility boundary. Apps should still pin or sign patch
|
||||
metadata and enforce app id, build id, flavor id, license type, SDK hash, base
|
||||
snapshot hash, target OS, and target architecture before accepting a patch.
|
||||
For license upgrades, artifacts may also include `base_flavor_id` and
|
||||
`base_license_type`; verifiers can require those fields to prove the patch is a
|
||||
specific transition such as `free -> pro` rather than a generic pro artifact.
|
||||
`verify --artifact-sha256=<hash>` adds hash pinning for the whole encrypted
|
||||
delivery artifact, which should be used alongside transport security or a
|
||||
separate signing layer.
|
||||
|
||||
## Compact Payloads
|
||||
|
||||
By default, `link` chooses the smallest v1 payload representation:
|
||||
|
||||
- `empty` when base and patch snapshots are identical
|
||||
- `binary-diff-v1` when changed byte ranges are smaller than the full patch
|
||||
- `full-snapshot` when a diff would be larger
|
||||
|
||||
For runtime handoff tests and simple embedders, pass
|
||||
`link --full-snapshot=true`. This forces the encrypted payload to decrypt to a
|
||||
directly loadable `.vmcode` file. That file can be supplied as the first
|
||||
application library path and mapped by the engine patch cache before isolate
|
||||
startup.
|
||||
|
||||
The binary diff format is deterministic and reconstructs the full patched
|
||||
snapshot from the base snapshot before runtime loading. Future SDK-integrated
|
||||
linkers can replace this with changed isolate code/data sections while keeping
|
||||
the same metadata and encryption envelope.
|
||||
|
||||
`dump-blobs --key-hex=<key> --base=<base.vmcode> --output=<patch.vmcode>` is
|
||||
the open updater handoff: it decrypts the delivery artifact, reconstructs the
|
||||
loadable VM-code payload, verifies the reconstructed hash against metadata, and
|
||||
writes the file that the engine patch cache can map before isolate startup.
|
||||
|
||||
The host proof app is `testapps/license_flavor_patch_test`. Run:
|
||||
|
||||
```powershell
|
||||
dart-sdk-new\tools\sdks\dart-sdk\bin\dart.exe testapps\license_flavor_patch_test\tool\verify_aot_patch.dart
|
||||
```
|
||||
|
||||
It compiles a `free` base AOT snapshot and a `pro` patch snapshot, reuses the
|
||||
base obfuscation map for the patch build, encrypts the patch artifact, verifies
|
||||
wrong-key and wrong-flavor failures, reconstructs the patched `.vmcode`, and
|
||||
runs it to confirm `license:pro` and `pro-feature:enabled`.
|
||||
|
||||
## iOS JIT-Disabled Handoff
|
||||
|
||||
iOS release/profile patch artifacts should be generated as Mach-O AOT snapshot
|
||||
objects, not bytecode and not dynamic modules. Build the patch snapshot on
|
||||
macOS with the iOS `gen_snapshot` that matches the app's Flutter/Dart SDK:
|
||||
|
||||
```sh
|
||||
dart run open_aot_patch_tools compile-patch \
|
||||
--gen-snapshot=/path/to/ios/gen_snapshot_arm64 \
|
||||
--kernel=build/patch_app.dill \
|
||||
--output=build/patch.vmcode \
|
||||
--snapshot-kind=app-aot-macho-dylib \
|
||||
--macho-object=build/patch.o \
|
||||
--obfuscate=true \
|
||||
--load-obfuscation-map=build/base.obfuscation.json
|
||||
|
||||
dart run open_aot_patch_tools link \
|
||||
--base=build/base.vmcode \
|
||||
--patch=build/patch.vmcode \
|
||||
--output=build/patch.json \
|
||||
--app-id=com.example.licenseFlavorPatchTest \
|
||||
--app-build-id=ios-build-123 \
|
||||
--base-flavor-id=free \
|
||||
--base-license-type=free \
|
||||
--flavor-id=pro \
|
||||
--license-type=pro \
|
||||
--sdk-hash=<flutter-engine-or-dart-sdk-hash> \
|
||||
--target-os=ios \
|
||||
--target-arch=arm64 \
|
||||
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
||||
--full-snapshot=true
|
||||
```
|
||||
|
||||
The encrypted artifact is delivered the same way as the Windows host proof:
|
||||
`encrypt` wraps it with AES-256-GCM, and the app-owned updater/key callback must
|
||||
validate metadata before decrypting. After decryption, use
|
||||
`dump-blobs --key-hex=<key> --base=build/base.vmcode --output=patch.vmcode` to
|
||||
write the loadable patch file into the updater's next-boot patch location.
|
||||
|
||||
The Flutter engine patch cache is prepared for this handoff:
|
||||
|
||||
- `shorebird_enable_aot_patching` defaults on for iOS, while the legacy
|
||||
`SHOREBIRD_USE_INTERPRETER` path is opt-in and disabled by default.
|
||||
- The first `.vmcode` application library path is treated as the patch. For iOS
|
||||
it may contain a raw Mach-O snapshot or a compact linker header followed by a
|
||||
Mach-O snapshot.
|
||||
- VM snapshot symbols continue to resolve from the signed base App.framework;
|
||||
only isolate data and isolate instructions are replaced by the patch.
|
||||
- The loader uses Dart's Mach-O snapshot loader on Apple platforms, so the VM
|
||||
does not need JIT, writable executable memory, KBC, or `DART_DYNAMIC_MODULES`.
|
||||
|
||||
The macOS device/simulator verification pass should build the same
|
||||
`license_flavor_patch_test` flavor pair with `--target-os=ios` and
|
||||
`--target-arch=arm64`, install the encrypted `free -> pro` patch, confirm the
|
||||
status label changes to `license:pro` / `pro-feature:enabled`, and confirm
|
||||
wrong-key and wrong-flavor artifacts fail before the isolate starts.
|
||||
|
||||
## Obfuscation
|
||||
|
||||
Release and patch builds should both use Dart `--obfuscate`. Patch builds should
|
||||
pass the release build's obfuscation map through
|
||||
`gen_snapshot --load-obfuscation-map=<file>` so existing symbols keep their
|
||||
release names and new patch-only symbols receive fresh names without collisions.
|
||||
|
||||
## Control-Flow Obfuscation Plan
|
||||
|
||||
Control-flow obfuscation should be a separate compiler flag from name
|
||||
obfuscation and AOT patching. The first safe milestone is an opt-in annotation
|
||||
or pragma for selected functions, followed by a precompiler pass that:
|
||||
|
||||
- splits basic blocks at safe boundaries after AOT optimizations
|
||||
- introduces opaque predicates that are stable across deterministic builds
|
||||
- preserves safepoint, deoptimization, exception, and stack-map correctness
|
||||
- avoids FFI callbacks, async suspension stubs, recognized intrinsics, and
|
||||
functions with patch entry points until each category has targeted tests
|
||||
|
||||
The pass should start disabled by default and only graduate after size,
|
||||
performance, symbolication, and patch compatibility tests exist for host,
|
||||
Android, and iOS AOT builds.
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Open-source helpers for compact AOT patch artifacts.
|
||||
library;
|
||||
|
||||
export 'src/patch_artifact.dart';
|
||||
@@ -0,0 +1,688 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
/// JSON format marker for an unencrypted compact AOT patch artifact.
|
||||
const artifactFormat = 'open-aot-vmcode-v1';
|
||||
|
||||
/// JSON format marker for an AES-GCM encrypted compact AOT patch artifact.
|
||||
const encryptedArtifactFormat = 'open-aot-vmcode-encrypted-v1';
|
||||
|
||||
/// Payload kind used when the patch snapshot is identical to the base.
|
||||
const payloadKindEmpty = 'empty';
|
||||
|
||||
/// Payload kind used when the artifact stores the full patch snapshot.
|
||||
const payloadKindFullSnapshot = 'full-snapshot';
|
||||
|
||||
/// Payload kind used when the artifact stores an open binary diff.
|
||||
const payloadKindBinaryDiff = 'binary-diff-v1';
|
||||
|
||||
/// Compatibility metadata bound to a compact AOT patch artifact.
|
||||
class PatchMetadata {
|
||||
/// Creates patch metadata for one application build and flavor.
|
||||
const PatchMetadata({
|
||||
required this.appId,
|
||||
required this.appBuildId,
|
||||
required this.flavorId,
|
||||
required this.licenseType,
|
||||
required this.sdkHash,
|
||||
required this.baseSnapshotHash,
|
||||
required this.patchSnapshotHash,
|
||||
required this.targetOs,
|
||||
required this.targetArch,
|
||||
this.baseFlavorId,
|
||||
this.baseLicenseType,
|
||||
this.obfuscationMapHash,
|
||||
});
|
||||
|
||||
/// Reads metadata from the JSON representation used by patch artifacts.
|
||||
factory PatchMetadata.fromJson(Map<String, Object?> json) => PatchMetadata(
|
||||
appId: _string(json, 'app_id'),
|
||||
appBuildId: _string(json, 'app_build_id'),
|
||||
baseFlavorId: json['base_flavor_id'] as String?,
|
||||
baseLicenseType: json['base_license_type'] as String?,
|
||||
flavorId: _string(json, 'flavor_id'),
|
||||
licenseType: _string(json, 'license_type'),
|
||||
sdkHash: _string(json, 'sdk_hash'),
|
||||
baseSnapshotHash: _string(json, 'base_snapshot_hash'),
|
||||
patchSnapshotHash: _string(json, 'patch_snapshot_hash'),
|
||||
targetOs: _string(json, 'target_os'),
|
||||
targetArch: _string(json, 'target_arch'),
|
||||
obfuscationMapHash: json['obfuscation_map_hash'] as String?,
|
||||
);
|
||||
|
||||
/// Stable application identifier.
|
||||
final String appId;
|
||||
|
||||
/// Application build identifier that produced the base snapshot.
|
||||
final String appBuildId;
|
||||
|
||||
/// Optional source flavor this patch is allowed to replace.
|
||||
final String? baseFlavorId;
|
||||
|
||||
/// Optional source license type this patch is allowed to replace.
|
||||
final String? baseLicenseType;
|
||||
|
||||
/// Target flavor after the patch is applied.
|
||||
final String flavorId;
|
||||
|
||||
/// Target license type after the patch is applied.
|
||||
final String licenseType;
|
||||
|
||||
/// Hash identifying the Dart/Flutter SDK build.
|
||||
final String sdkHash;
|
||||
|
||||
/// SHA-256 hash of the base snapshot bytes.
|
||||
final String baseSnapshotHash;
|
||||
|
||||
/// SHA-256 hash of the reconstructed patch snapshot bytes.
|
||||
final String patchSnapshotHash;
|
||||
|
||||
/// Target operating system, for example `windows` or `ios`.
|
||||
final String targetOs;
|
||||
|
||||
/// Target CPU architecture, for example `x64` or `arm64`.
|
||||
final String targetArch;
|
||||
|
||||
/// Optional hash of the obfuscation map used by base and patch builds.
|
||||
final String? obfuscationMapHash;
|
||||
|
||||
/// Converts this metadata to the stable JSON map used as AES-GCM AAD.
|
||||
Map<String, Object?> toJson() => {
|
||||
'app_id': appId,
|
||||
'app_build_id': appBuildId,
|
||||
if (baseFlavorId != null) 'base_flavor_id': baseFlavorId,
|
||||
if (baseLicenseType != null) 'base_license_type': baseLicenseType,
|
||||
'flavor_id': flavorId,
|
||||
'license_type': licenseType,
|
||||
'sdk_hash': sdkHash,
|
||||
'base_snapshot_hash': baseSnapshotHash,
|
||||
'patch_snapshot_hash': patchSnapshotHash,
|
||||
'target_os': targetOs,
|
||||
'target_arch': targetArch,
|
||||
if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash,
|
||||
};
|
||||
}
|
||||
|
||||
/// Unencrypted compact patch artifact.
|
||||
class PatchArtifact {
|
||||
/// Creates an unencrypted compact patch artifact.
|
||||
const PatchArtifact({
|
||||
required this.metadata,
|
||||
required this.payload,
|
||||
required this.payloadKind,
|
||||
required this.reconstructedSize,
|
||||
});
|
||||
|
||||
/// Reads and validates an unencrypted patch artifact JSON map.
|
||||
factory PatchArtifact.fromJson(Map<String, Object?> json) {
|
||||
final format = json['format'];
|
||||
if (format != artifactFormat) {
|
||||
throw FormatException('Unsupported artifact format: $format');
|
||||
}
|
||||
final payloadKind =
|
||||
json['payload_kind'] as String? ?? payloadKindFullSnapshot;
|
||||
final payload = base64Decode(_string(json, 'payload_base64'));
|
||||
final payloadHash = json['payload_sha256'] as String?;
|
||||
if (payloadHash != null && payloadHash != sha256Hex(payload)) {
|
||||
throw const FormatException('Patch payload hash mismatch.');
|
||||
}
|
||||
return PatchArtifact(
|
||||
metadata: PatchMetadata.fromJson(
|
||||
(json['metadata']! as Map).cast<String, Object?>(),
|
||||
),
|
||||
payload: payload,
|
||||
payloadKind: payloadKind,
|
||||
reconstructedSize: (json['reconstructed_size'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Metadata used to validate app, SDK, flavor, and snapshot compatibility.
|
||||
final PatchMetadata metadata;
|
||||
|
||||
/// Compact payload bytes, whose interpretation depends on [payloadKind].
|
||||
final List<int> payload;
|
||||
|
||||
/// One of the `payloadKind*` constants.
|
||||
final String payloadKind;
|
||||
|
||||
/// Expected byte length of the reconstructed patch snapshot.
|
||||
final int? reconstructedSize;
|
||||
|
||||
/// Converts this artifact to its JSON representation.
|
||||
Map<String, Object?> toJson() => {
|
||||
'format': artifactFormat,
|
||||
'metadata': metadata.toJson(),
|
||||
'payload_kind': payloadKind,
|
||||
if (reconstructedSize != null) 'reconstructed_size': reconstructedSize,
|
||||
'payload_sha256': sha256Hex(payload),
|
||||
'payload_base64': base64Encode(payload),
|
||||
};
|
||||
|
||||
/// Reconstructs the final patch snapshot from [baseSnapshot].
|
||||
List<int> reconstruct(List<int> baseSnapshot) => reconstructPatchSnapshot(
|
||||
baseSnapshot: baseSnapshot,
|
||||
payload: payload,
|
||||
payloadKind: payloadKind,
|
||||
reconstructedSize: reconstructedSize,
|
||||
);
|
||||
}
|
||||
|
||||
/// AES-256-GCM encrypted compact patch artifact.
|
||||
class EncryptedPatchArtifact {
|
||||
/// Creates an encrypted compact patch artifact.
|
||||
const EncryptedPatchArtifact({
|
||||
required this.metadata,
|
||||
required this.encryptedPayload,
|
||||
required this.payloadKind,
|
||||
required this.reconstructedSize,
|
||||
required this.keyId,
|
||||
required this.nonce,
|
||||
required this.tag,
|
||||
required this.aadSha256,
|
||||
required this.payloadSha256,
|
||||
});
|
||||
|
||||
/// Reads and validates an encrypted patch artifact JSON map.
|
||||
factory EncryptedPatchArtifact.fromJson(Map<String, Object?> json) {
|
||||
final format = json['format'];
|
||||
if (format != encryptedArtifactFormat) {
|
||||
throw FormatException('Unsupported encrypted artifact format: $format');
|
||||
}
|
||||
final encryption = (json['encryption']! as Map).cast<String, Object?>();
|
||||
if (encryption['algorithm'] != 'AES-256-GCM') {
|
||||
throw FormatException(
|
||||
'Unsupported encryption algorithm: ${encryption['algorithm']}',
|
||||
);
|
||||
}
|
||||
return EncryptedPatchArtifact(
|
||||
metadata: PatchMetadata.fromJson(
|
||||
(json['metadata']! as Map).cast<String, Object?>(),
|
||||
),
|
||||
encryptedPayload: base64Decode(_string(json, 'encrypted_payload_base64')),
|
||||
payloadKind: json['payload_kind'] as String? ?? payloadKindFullSnapshot,
|
||||
reconstructedSize: (json['reconstructed_size'] as num?)?.toInt(),
|
||||
keyId: _string(encryption, 'key_id'),
|
||||
nonce: base64Decode(_string(encryption, 'nonce_base64')),
|
||||
tag: base64Decode(_string(encryption, 'tag_base64')),
|
||||
aadSha256: _string(encryption, 'aad_sha256'),
|
||||
payloadSha256: _string(json, 'payload_sha256'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Metadata used to validate app, SDK, flavor, and snapshot compatibility.
|
||||
final PatchMetadata metadata;
|
||||
|
||||
/// AES-GCM ciphertext bytes without the authentication tag.
|
||||
final List<int> encryptedPayload;
|
||||
|
||||
/// One of the `payloadKind*` constants.
|
||||
final String payloadKind;
|
||||
|
||||
/// Expected byte length of the reconstructed patch snapshot.
|
||||
final int? reconstructedSize;
|
||||
|
||||
/// Key identifier supplied to the app-owned key callback.
|
||||
final String keyId;
|
||||
|
||||
/// AES-GCM nonce bytes.
|
||||
final List<int> nonce;
|
||||
|
||||
/// AES-GCM authentication tag bytes.
|
||||
final List<int> tag;
|
||||
|
||||
/// SHA-256 hash of the canonical metadata AAD.
|
||||
final String aadSha256;
|
||||
|
||||
/// SHA-256 hash of the decrypted compact payload bytes.
|
||||
final String payloadSha256;
|
||||
|
||||
/// Converts this encrypted artifact to its JSON representation.
|
||||
Map<String, Object?> toJson() => {
|
||||
'format': encryptedArtifactFormat,
|
||||
'metadata': metadata.toJson(),
|
||||
'payload_kind': payloadKind,
|
||||
if (reconstructedSize != null) 'reconstructed_size': reconstructedSize,
|
||||
'payload_sha256': payloadSha256,
|
||||
'encrypted_payload_base64': base64Encode(encryptedPayload),
|
||||
'encryption': {
|
||||
'algorithm': 'AES-256-GCM',
|
||||
'key_id': keyId,
|
||||
'nonce_base64': base64Encode(nonce),
|
||||
'tag_base64': base64Encode(tag),
|
||||
'aad_sha256': aadSha256,
|
||||
},
|
||||
};
|
||||
|
||||
/// Decrypts and authenticates the compact payload with [key].
|
||||
PatchArtifact decrypt(List<int> key) {
|
||||
final aad = canonicalJson(metadata.toJson());
|
||||
final actualAadSha = sha256Hex(utf8.encode(aad));
|
||||
if (actualAadSha != aadSha256) {
|
||||
throw const FormatException('Artifact metadata AAD hash mismatch.');
|
||||
}
|
||||
final cipherTextAndTag = Uint8List.fromList([...encryptedPayload, ...tag]);
|
||||
final cipher = GCMBlockCipher(AESEngine())
|
||||
..init(
|
||||
false,
|
||||
AEADParameters(
|
||||
KeyParameter(Uint8List.fromList(key)),
|
||||
tag.length * 8,
|
||||
Uint8List.fromList(nonce),
|
||||
Uint8List.fromList(utf8.encode(aad)),
|
||||
),
|
||||
);
|
||||
final Uint8List payload;
|
||||
try {
|
||||
payload = cipher.process(cipherTextAndTag);
|
||||
} on Object catch (error) {
|
||||
throw FormatException('Patch decryption failed: $error');
|
||||
}
|
||||
if (sha256Hex(payload) != payloadSha256) {
|
||||
throw const FormatException('Decrypted payload hash mismatch.');
|
||||
}
|
||||
return PatchArtifact(
|
||||
metadata: metadata,
|
||||
payload: payload,
|
||||
payloadKind: payloadKind,
|
||||
reconstructedSize: reconstructedSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Links base and patch snapshot bytes into a compact patch artifact.
|
||||
PatchArtifact linkArtifacts({
|
||||
required List<int> baseSnapshot,
|
||||
required List<int> patchSnapshot,
|
||||
required PatchMetadata metadata,
|
||||
bool forceFullSnapshot = false,
|
||||
}) {
|
||||
final compact = forceFullSnapshot
|
||||
? _CompactPayload(
|
||||
kind: payloadKindFullSnapshot,
|
||||
payload: List<int>.of(patchSnapshot),
|
||||
reconstructedSize: patchSnapshot.length,
|
||||
)
|
||||
: _compactPayload(baseSnapshot, patchSnapshot);
|
||||
return PatchArtifact(
|
||||
metadata: metadata,
|
||||
payload: compact.payload,
|
||||
payloadKind: compact.kind,
|
||||
reconstructedSize: compact.reconstructedSize,
|
||||
);
|
||||
}
|
||||
|
||||
/// Encrypts a compact patch artifact for secure delivery.
|
||||
EncryptedPatchArtifact encryptArtifact({
|
||||
required PatchArtifact artifact,
|
||||
required String keyId,
|
||||
required List<int> key,
|
||||
required List<int> nonce,
|
||||
}) {
|
||||
if (key.length != 32) {
|
||||
throw ArgumentError.value(
|
||||
key.length,
|
||||
'key.length',
|
||||
'AES-256 needs 32 bytes',
|
||||
);
|
||||
}
|
||||
if (nonce.length != 12) {
|
||||
throw ArgumentError.value(
|
||||
nonce.length,
|
||||
'nonce.length',
|
||||
'AES-GCM nonce must be 12 bytes',
|
||||
);
|
||||
}
|
||||
final aad = canonicalJson(artifact.metadata.toJson());
|
||||
final cipher = GCMBlockCipher(AESEngine())
|
||||
..init(
|
||||
true,
|
||||
AEADParameters(
|
||||
KeyParameter(Uint8List.fromList(key)),
|
||||
128,
|
||||
Uint8List.fromList(nonce),
|
||||
Uint8List.fromList(utf8.encode(aad)),
|
||||
),
|
||||
);
|
||||
final sealed = cipher.process(Uint8List.fromList(artifact.payload));
|
||||
return EncryptedPatchArtifact(
|
||||
metadata: artifact.metadata,
|
||||
encryptedPayload: sealed.sublist(0, sealed.length - 16),
|
||||
payloadKind: artifact.payloadKind,
|
||||
reconstructedSize: artifact.reconstructedSize,
|
||||
keyId: keyId,
|
||||
nonce: nonce,
|
||||
tag: sealed.sublist(sealed.length - 16),
|
||||
aadSha256: sha256Hex(utf8.encode(aad)),
|
||||
payloadSha256: sha256Hex(artifact.payload),
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that metadata matches the expected target and optional base state.
|
||||
void verifyMetadata(
|
||||
PatchMetadata metadata, {
|
||||
required String flavorId,
|
||||
required String licenseType,
|
||||
String? baseFlavorId,
|
||||
String? baseLicenseType,
|
||||
}) {
|
||||
if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) {
|
||||
throw StateError(
|
||||
'Patch base flavor "${metadata.baseFlavorId}" does not match '
|
||||
'"$baseFlavorId".',
|
||||
);
|
||||
}
|
||||
if (baseLicenseType != null && metadata.baseLicenseType != baseLicenseType) {
|
||||
throw StateError(
|
||||
'Patch base license "${metadata.baseLicenseType}" does not match '
|
||||
'"$baseLicenseType".',
|
||||
);
|
||||
}
|
||||
if (metadata.flavorId != flavorId) {
|
||||
throw StateError(
|
||||
'Patch flavor "${metadata.flavorId}" does not match "$flavorId".',
|
||||
);
|
||||
}
|
||||
if (metadata.licenseType != licenseType) {
|
||||
throw StateError(
|
||||
'Patch license "${metadata.licenseType}" does not match "$licenseType".',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs final patch snapshot bytes from a compact payload.
|
||||
List<int> reconstructPatchSnapshot({
|
||||
required List<int> baseSnapshot,
|
||||
required List<int> payload,
|
||||
required String payloadKind,
|
||||
required int? reconstructedSize,
|
||||
}) {
|
||||
switch (payloadKind) {
|
||||
case payloadKindEmpty:
|
||||
return List<int>.of(baseSnapshot);
|
||||
case payloadKindFullSnapshot:
|
||||
return List<int>.of(payload);
|
||||
case payloadKindBinaryDiff:
|
||||
if (reconstructedSize == null) {
|
||||
throw const FormatException(
|
||||
'Binary diff payload is missing reconstructed_size.',
|
||||
);
|
||||
}
|
||||
return _applyBinaryDiff(
|
||||
baseSnapshot: baseSnapshot,
|
||||
diff: payload,
|
||||
reconstructedSize: reconstructedSize,
|
||||
);
|
||||
default:
|
||||
throw FormatException('Unsupported payload kind: $payloadKind');
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes [value] as deterministic JSON with sorted map keys.
|
||||
String canonicalJson(Object? value) {
|
||||
Object? normalize(Object? input) {
|
||||
if (input is Map) {
|
||||
final keys = input.keys.cast<String>().toList()..sort();
|
||||
return {for (final key in keys) key: normalize(input[key])};
|
||||
}
|
||||
if (input is Iterable) {
|
||||
return input.map(normalize).toList();
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
return jsonEncode(normalize(value));
|
||||
}
|
||||
|
||||
/// Computes a lowercase hexadecimal SHA-256 digest for [bytes].
|
||||
String sha256Hex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
/// Reads a 32-byte AES key from hexadecimal text.
|
||||
List<int> readKey(String keyHex) {
|
||||
return readHexBytes(keyHex, expectedBytes: 32, name: 'AES-256 key');
|
||||
}
|
||||
|
||||
/// Reads a 12-byte AES-GCM nonce from hexadecimal text.
|
||||
List<int> readNonce(String nonceHex) {
|
||||
return readHexBytes(nonceHex, expectedBytes: 12, name: 'AES-GCM nonce');
|
||||
}
|
||||
|
||||
/// Reads exactly [expectedBytes] bytes from hexadecimal text.
|
||||
List<int> readHexBytes(
|
||||
String hex, {
|
||||
required int expectedBytes,
|
||||
required String name,
|
||||
}) {
|
||||
final normalized = hex.trim();
|
||||
if (normalized.length != expectedBytes * 2) {
|
||||
throw ArgumentError('$name must be ${expectedBytes * 2} hex characters.');
|
||||
}
|
||||
return [
|
||||
for (var i = 0; i < normalized.length; i += 2)
|
||||
int.parse(normalized.substring(i, i + 2), radix: 16),
|
||||
];
|
||||
}
|
||||
|
||||
/// Reads a JSON object from [file].
|
||||
Map<String, Object?> readJsonFile(File file) =>
|
||||
(jsonDecode(file.readAsStringSync()) as Map).cast<String, Object?>();
|
||||
|
||||
String _string(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! String || value.isEmpty) {
|
||||
throw FormatException('Expected non-empty string field "$key".');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
_CompactPayload _compactPayload(
|
||||
List<int> baseSnapshot,
|
||||
List<int> patchSnapshot,
|
||||
) {
|
||||
if (_listEquals(baseSnapshot, patchSnapshot)) {
|
||||
return _CompactPayload(
|
||||
kind: payloadKindEmpty,
|
||||
payload: const [],
|
||||
reconstructedSize: patchSnapshot.length,
|
||||
);
|
||||
}
|
||||
|
||||
final diff = _encodeBinaryDiff(
|
||||
baseSnapshot: baseSnapshot,
|
||||
patchSnapshot: patchSnapshot,
|
||||
);
|
||||
if (diff.length < patchSnapshot.length) {
|
||||
return _CompactPayload(
|
||||
kind: payloadKindBinaryDiff,
|
||||
payload: diff,
|
||||
reconstructedSize: patchSnapshot.length,
|
||||
);
|
||||
}
|
||||
return _CompactPayload(
|
||||
kind: payloadKindFullSnapshot,
|
||||
payload: List<int>.of(patchSnapshot),
|
||||
reconstructedSize: patchSnapshot.length,
|
||||
);
|
||||
}
|
||||
|
||||
class _CompactPayload {
|
||||
const _CompactPayload({
|
||||
required this.kind,
|
||||
required this.payload,
|
||||
required this.reconstructedSize,
|
||||
});
|
||||
|
||||
final String kind;
|
||||
final List<int> payload;
|
||||
final int reconstructedSize;
|
||||
}
|
||||
|
||||
List<int> _encodeBinaryDiff({
|
||||
required List<int> baseSnapshot,
|
||||
required List<int> patchSnapshot,
|
||||
}) {
|
||||
const magic = [0x4f, 0x41, 0x50, 0x44, 0x31]; // OAPD1.
|
||||
const equalGapToKeep = 16;
|
||||
final chunks = <_DiffChunk>[];
|
||||
var cursor = 0;
|
||||
while (cursor < patchSnapshot.length) {
|
||||
final baseByte = cursor < baseSnapshot.length ? baseSnapshot[cursor] : null;
|
||||
if (baseByte == patchSnapshot[cursor]) {
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
final start = cursor;
|
||||
var lastDifferent = cursor;
|
||||
var equalRun = 0;
|
||||
cursor++;
|
||||
while (cursor < patchSnapshot.length) {
|
||||
final baseAtCursor = cursor < baseSnapshot.length
|
||||
? baseSnapshot[cursor]
|
||||
: null;
|
||||
if (baseAtCursor == patchSnapshot[cursor]) {
|
||||
equalRun++;
|
||||
if (equalRun > equalGapToKeep) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
equalRun = 0;
|
||||
lastDifferent = cursor;
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
|
||||
final end = lastDifferent + 1;
|
||||
chunks.add(_DiffChunk(start, patchSnapshot.sublist(start, end)));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
final size =
|
||||
magic.length +
|
||||
8 +
|
||||
4 +
|
||||
chunks.fold<int>(
|
||||
0,
|
||||
(total, chunk) => total + 8 + 4 + chunk.bytes.length,
|
||||
);
|
||||
final output = Uint8List(size);
|
||||
var offset = 0;
|
||||
output.setRange(offset, offset + magic.length, magic);
|
||||
offset += magic.length;
|
||||
_writeUint64(output, offset, patchSnapshot.length);
|
||||
offset += 8;
|
||||
_writeUint32(output, offset, chunks.length);
|
||||
offset += 4;
|
||||
for (final chunk in chunks) {
|
||||
_writeUint64(output, offset, chunk.offset);
|
||||
offset += 8;
|
||||
_writeUint32(output, offset, chunk.bytes.length);
|
||||
offset += 4;
|
||||
output.setRange(offset, offset + chunk.bytes.length, chunk.bytes);
|
||||
offset += chunk.bytes.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
List<int> _applyBinaryDiff({
|
||||
required List<int> baseSnapshot,
|
||||
required List<int> diff,
|
||||
required int reconstructedSize,
|
||||
}) {
|
||||
const magic = [0x4f, 0x41, 0x50, 0x44, 0x31]; // OAPD1.
|
||||
if (diff.length < magic.length + 8 + 4) {
|
||||
throw const FormatException('Binary diff payload is too short.');
|
||||
}
|
||||
for (var i = 0; i < magic.length; i++) {
|
||||
if (diff[i] != magic[i]) {
|
||||
throw const FormatException('Invalid binary diff magic.');
|
||||
}
|
||||
}
|
||||
var cursor = magic.length;
|
||||
final targetSize = _readUint64(diff, cursor);
|
||||
cursor += 8;
|
||||
if (targetSize != reconstructedSize) {
|
||||
throw const FormatException('Binary diff target size mismatch.');
|
||||
}
|
||||
final chunkCount = _readUint32(diff, cursor);
|
||||
cursor += 4;
|
||||
|
||||
final output = Uint8List(reconstructedSize);
|
||||
final copied = baseSnapshot.length < reconstructedSize
|
||||
? baseSnapshot.length
|
||||
: reconstructedSize;
|
||||
output.setRange(0, copied, baseSnapshot);
|
||||
|
||||
for (var i = 0; i < chunkCount; i++) {
|
||||
if (cursor + 12 > diff.length) {
|
||||
throw const FormatException('Truncated binary diff chunk header.');
|
||||
}
|
||||
final chunkOffset = _readUint64(diff, cursor);
|
||||
cursor += 8;
|
||||
final chunkLength = _readUint32(diff, cursor);
|
||||
cursor += 4;
|
||||
if (chunkOffset < 0 ||
|
||||
chunkLength < 0 ||
|
||||
chunkOffset + chunkLength > reconstructedSize ||
|
||||
cursor + chunkLength > diff.length) {
|
||||
throw const FormatException('Invalid binary diff chunk bounds.');
|
||||
}
|
||||
output.setRange(
|
||||
chunkOffset,
|
||||
chunkOffset + chunkLength,
|
||||
diff,
|
||||
cursor,
|
||||
);
|
||||
cursor += chunkLength;
|
||||
}
|
||||
if (cursor != diff.length) {
|
||||
throw const FormatException('Unexpected trailing binary diff data.');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
class _DiffChunk {
|
||||
const _DiffChunk(this.offset, this.bytes);
|
||||
|
||||
final int offset;
|
||||
final List<int> bytes;
|
||||
}
|
||||
|
||||
void _writeUint32(Uint8List output, int offset, int value) {
|
||||
output[offset] = value & 0xff;
|
||||
output[offset + 1] = (value >> 8) & 0xff;
|
||||
output[offset + 2] = (value >> 16) & 0xff;
|
||||
output[offset + 3] = (value >> 24) & 0xff;
|
||||
}
|
||||
|
||||
void _writeUint64(Uint8List output, int offset, int value) {
|
||||
for (var i = 0; i < 8; i++) {
|
||||
output[offset + i] = (value >> (8 * i)) & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
int _readUint32(List<int> input, int offset) {
|
||||
return input[offset] |
|
||||
(input[offset + 1] << 8) |
|
||||
(input[offset + 2] << 16) |
|
||||
(input[offset + 3] << 24);
|
||||
}
|
||||
|
||||
int _readUint64(List<int> input, int offset) {
|
||||
var value = 0;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
value |= input[offset + i] << (8 * i);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool _listEquals(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
name: open_aot_patch_tools
|
||||
description: Open compact AOT patch artifact tools with flavor-aware AES-GCM wrapping.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
resolution: workspace
|
||||
|
||||
dependencies:
|
||||
args: ^2.7.0
|
||||
crypto: ^3.0.6
|
||||
path: ^1.9.1
|
||||
pointycastle: ^4.0.0
|
||||
|
||||
dev_dependencies:
|
||||
test: ^1.25.15
|
||||
very_good_analysis: ^10.2.0
|
||||
|
||||
executables:
|
||||
open_aot_patch_tools:
|
||||
@@ -0,0 +1,397 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:open_aot_patch_tools/open_aot_patch_tools.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
const key = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
const wrongKey = [
|
||||
31,
|
||||
30,
|
||||
29,
|
||||
28,
|
||||
27,
|
||||
26,
|
||||
25,
|
||||
24,
|
||||
23,
|
||||
22,
|
||||
21,
|
||||
20,
|
||||
19,
|
||||
18,
|
||||
17,
|
||||
16,
|
||||
15,
|
||||
14,
|
||||
13,
|
||||
12,
|
||||
11,
|
||||
10,
|
||||
9,
|
||||
8,
|
||||
7,
|
||||
6,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
];
|
||||
const nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
|
||||
PatchMetadata metadata() => const PatchMetadata(
|
||||
appId: 'app.test',
|
||||
appBuildId: '1.0.0+1',
|
||||
baseFlavorId: 'free',
|
||||
baseLicenseType: 'free',
|
||||
flavorId: 'pro',
|
||||
licenseType: 'pro',
|
||||
sdkHash: 'sdk',
|
||||
baseSnapshotHash: 'base',
|
||||
patchSnapshotHash: 'patch',
|
||||
targetOs: 'windows',
|
||||
targetArch: 'x64',
|
||||
obfuscationMapHash: 'obfuscated',
|
||||
);
|
||||
|
||||
test('encrypts and decrypts flavor-aware patch payloads', () {
|
||||
final base = utf8.encode('free');
|
||||
final patch = utf8.encode('pro');
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: base,
|
||||
patchSnapshot: patch,
|
||||
metadata: metadata(),
|
||||
);
|
||||
final encrypted = encryptArtifact(
|
||||
artifact: artifact,
|
||||
keyId: 'test-key',
|
||||
key: key,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
verifyMetadata(encrypted.metadata, flavorId: 'pro', licenseType: 'pro');
|
||||
verifyMetadata(
|
||||
encrypted.metadata,
|
||||
baseFlavorId: 'free',
|
||||
baseLicenseType: 'free',
|
||||
flavorId: 'pro',
|
||||
licenseType: 'pro',
|
||||
);
|
||||
expect(encrypted.decrypt(key).reconstruct(base), patch);
|
||||
});
|
||||
|
||||
test('wrong AES key fails before payload is accepted', () {
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: utf8.encode('free'),
|
||||
patchSnapshot: utf8.encode('pro'),
|
||||
metadata: metadata(),
|
||||
);
|
||||
final encrypted = encryptArtifact(
|
||||
artifact: artifact,
|
||||
keyId: 'test-key',
|
||||
key: key,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
expect(() => encrypted.decrypt(wrongKey), throwsFormatException);
|
||||
});
|
||||
|
||||
test('wrong flavor metadata is rejected', () {
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: utf8.encode('free'),
|
||||
patchSnapshot: utf8.encode('pro'),
|
||||
metadata: metadata(),
|
||||
);
|
||||
final encrypted = encryptArtifact(
|
||||
artifact: artifact,
|
||||
keyId: 'test-key',
|
||||
key: key,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => verifyMetadata(
|
||||
encrypted.metadata,
|
||||
flavorId: 'free',
|
||||
licenseType: 'pro',
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test('wrong base flavor metadata is rejected', () {
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: utf8.encode('free'),
|
||||
patchSnapshot: utf8.encode('pro'),
|
||||
metadata: metadata(),
|
||||
);
|
||||
final encrypted = encryptArtifact(
|
||||
artifact: artifact,
|
||||
keyId: 'test-key',
|
||||
key: key,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => verifyMetadata(
|
||||
encrypted.metadata,
|
||||
baseFlavorId: 'enterprise',
|
||||
baseLicenseType: 'free',
|
||||
flavorId: 'pro',
|
||||
licenseType: 'pro',
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test('artifact json round trips', () {
|
||||
final base = utf8.encode('free');
|
||||
final patch = utf8.encode('pro');
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: base,
|
||||
patchSnapshot: patch,
|
||||
metadata: metadata(),
|
||||
);
|
||||
final decoded = PatchArtifact.fromJson(
|
||||
(jsonDecode(jsonEncode(artifact.toJson())) as Map).cast(),
|
||||
);
|
||||
|
||||
expect(decoded.metadata.flavorId, 'pro');
|
||||
expect(decoded.reconstruct(base), patch);
|
||||
});
|
||||
|
||||
test('artifact json rejects payload hash mismatch', () {
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: utf8.encode('free'),
|
||||
patchSnapshot: utf8.encode('pro'),
|
||||
metadata: metadata(),
|
||||
).toJson()..['payload_sha256'] = 'not-the-real-hash';
|
||||
|
||||
expect(() => PatchArtifact.fromJson(artifact), throwsFormatException);
|
||||
});
|
||||
|
||||
test('binary diff payload reconstructs patch snapshot', () {
|
||||
final base = List<int>.filled(1024, 7);
|
||||
final patch = List<int>.of(base)
|
||||
..[512] = 9
|
||||
..[513] = 10;
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: base,
|
||||
patchSnapshot: patch,
|
||||
metadata: metadata(),
|
||||
);
|
||||
|
||||
expect(artifact.payloadKind, payloadKindBinaryDiff);
|
||||
expect(artifact.payload.length, lessThan(patch.length));
|
||||
expect(artifact.reconstruct(base), patch);
|
||||
});
|
||||
|
||||
test('full snapshot mode keeps payload directly loadable', () {
|
||||
final base = List<int>.filled(1024, 7);
|
||||
final patch = List<int>.of(base)..[512] = 9;
|
||||
final artifact = linkArtifacts(
|
||||
baseSnapshot: base,
|
||||
patchSnapshot: patch,
|
||||
metadata: metadata(),
|
||||
forceFullSnapshot: true,
|
||||
);
|
||||
|
||||
expect(artifact.payloadKind, payloadKindFullSnapshot);
|
||||
expect(artifact.payload, patch);
|
||||
expect(artifact.reconstruct(base), patch);
|
||||
});
|
||||
|
||||
test('cli links encrypts verifies and dumps compact patch', () async {
|
||||
final tempDir = Directory.systemTemp.createTempSync(
|
||||
'open_aot_patch_tools.',
|
||||
);
|
||||
try {
|
||||
final baseFile = File('${tempDir.path}/base.vmcode')
|
||||
..writeAsBytesSync(List<int>.filled(1024, 7));
|
||||
final patchBytes = List<int>.filled(1024, 7)..[512] = 9;
|
||||
final patchFile = File('${tempDir.path}/patch.vmcode')
|
||||
..writeAsBytesSync(patchBytes);
|
||||
final artifactFile = File('${tempDir.path}/patch.json');
|
||||
final encryptedFile = File('${tempDir.path}/patch.enc.json');
|
||||
final reconstructedFile = File('${tempDir.path}/reconstructed.vmcode');
|
||||
const keyHex =
|
||||
'000102030405060708090a0b0c0d0e0f'
|
||||
'101112131415161718191a1b1c1d1e1f';
|
||||
const nonceHex = '000102030405060708090a0b';
|
||||
|
||||
await _runTool([
|
||||
'link',
|
||||
'--base=${baseFile.path}',
|
||||
'--patch=${patchFile.path}',
|
||||
'--output=${artifactFile.path}',
|
||||
'--app-id=app.test',
|
||||
'--app-build-id=1.0.0+1',
|
||||
'--base-flavor-id=free',
|
||||
'--base-license-type=free',
|
||||
'--flavor-id=pro',
|
||||
'--license-type=pro',
|
||||
'--sdk-hash=sdk',
|
||||
'--target-os=windows',
|
||||
'--target-arch=x64',
|
||||
]);
|
||||
final artifactJson = readJsonFile(artifactFile);
|
||||
expect(artifactJson['payload_kind'], payloadKindBinaryDiff);
|
||||
|
||||
await _runTool([
|
||||
'link',
|
||||
'--base=${baseFile.path}',
|
||||
'--patch=${patchFile.path}',
|
||||
'--output=${artifactFile.path}',
|
||||
'--app-id=app.test',
|
||||
'--app-build-id=1.0.0+1',
|
||||
'--base-flavor-id=free',
|
||||
'--base-license-type=free',
|
||||
'--flavor-id=pro',
|
||||
'--license-type=pro',
|
||||
'--sdk-hash=sdk',
|
||||
'--target-os=windows',
|
||||
'--target-arch=x64',
|
||||
'--full-snapshot=true',
|
||||
]);
|
||||
final fullArtifactJson = readJsonFile(artifactFile);
|
||||
expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot);
|
||||
|
||||
await _runTool([
|
||||
'encrypt',
|
||||
'--input=${artifactFile.path}',
|
||||
'--output=${encryptedFile.path}',
|
||||
'--key-id=test-key',
|
||||
'--key-hex=$keyHex',
|
||||
'--nonce-hex=$nonceHex',
|
||||
]);
|
||||
|
||||
await _runTool([
|
||||
'verify',
|
||||
'--input=${encryptedFile.path}',
|
||||
'--key-hex=$keyHex',
|
||||
'--artifact-sha256=${sha256Hex(encryptedFile.readAsBytesSync())}',
|
||||
'--base-flavor-id=free',
|
||||
'--base-license-type=free',
|
||||
'--flavor-id=pro',
|
||||
'--license-type=pro',
|
||||
'--base=${baseFile.path}',
|
||||
]);
|
||||
|
||||
await _runToolExpectFailure([
|
||||
'verify',
|
||||
'--input=${encryptedFile.path}',
|
||||
'--key-hex=$keyHex',
|
||||
'--artifact-sha256=bad',
|
||||
'--base-flavor-id=free',
|
||||
'--base-license-type=free',
|
||||
'--flavor-id=pro',
|
||||
'--license-type=pro',
|
||||
'--base=${baseFile.path}',
|
||||
]);
|
||||
|
||||
final dump = await _runTool([
|
||||
'dump-blobs',
|
||||
'--input=${encryptedFile.path}',
|
||||
'--key-hex=$keyHex',
|
||||
'--base=${baseFile.path}',
|
||||
'--output=${reconstructedFile.path}',
|
||||
]);
|
||||
final dumpJson = (jsonDecode(dump.stdout as String) as Map)
|
||||
.cast<String, Object?>();
|
||||
expect(
|
||||
dumpJson['artifact_sha256'],
|
||||
sha256Hex(encryptedFile.readAsBytesSync()),
|
||||
);
|
||||
expect(dumpJson['reconstructed_patch_sha256'], sha256Hex(patchBytes));
|
||||
expect(reconstructedFile.readAsBytesSync(), patchBytes);
|
||||
} finally {
|
||||
tempDir.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<ProcessResult> _runTool(List<String> args) async {
|
||||
final packageDir = _packageDir();
|
||||
final result = await Process.run(
|
||||
Platform.resolvedExecutable,
|
||||
['bin/open_aot_patch_tools.dart', ...args],
|
||||
workingDirectory: packageDir.path,
|
||||
);
|
||||
if (result.exitCode != 0) {
|
||||
final output = [
|
||||
result.exitCode,
|
||||
'stdout:',
|
||||
result.stdout,
|
||||
'stderr:',
|
||||
result.stderr,
|
||||
].join('\n');
|
||||
fail('open_aot_patch_tools ${args.join(' ')} failed with $output');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<ProcessResult> _runToolExpectFailure(List<String> args) async {
|
||||
final packageDir = _packageDir();
|
||||
final result = await Process.run(
|
||||
Platform.resolvedExecutable,
|
||||
['bin/open_aot_patch_tools.dart', ...args],
|
||||
workingDirectory: packageDir.path,
|
||||
);
|
||||
if (result.exitCode == 0) {
|
||||
fail('open_aot_patch_tools ${args.join(' ')} unexpectedly succeeded');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Directory _packageDir() {
|
||||
final direct = Directory.current;
|
||||
if (File('${direct.path}/bin/open_aot_patch_tools.dart').existsSync()) {
|
||||
return direct;
|
||||
}
|
||||
final workspaceMember = Directory(
|
||||
'${direct.path}/packages/open_aot_patch_tools',
|
||||
);
|
||||
final workspaceTool = File(
|
||||
'${workspaceMember.path}/bin/open_aot_patch_tools.dart',
|
||||
);
|
||||
if (workspaceTool.existsSync()) {
|
||||
return workspaceMember;
|
||||
}
|
||||
fail('Unable to locate open_aot_patch_tools package directory.');
|
||||
}
|
||||
@@ -8,6 +8,7 @@ workspace:
|
||||
- packages/discord_gcp_alerts
|
||||
- packages/flutter_version_resolver
|
||||
- packages/jwt
|
||||
- packages/open_aot_patch_tools
|
||||
- packages/redis_client
|
||||
- packages/scoped_deps
|
||||
- packages/shorebird_build_trace
|
||||
|
||||
Reference in New Issue
Block a user