import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:cryptography/cryptography.dart'; /// Read-only inspection of archives, certificates, keys and notebooks. /// Nothing is extracted, executed or sent anywhere. class ArchiveEntry { final String name; final int size; final int? compressed; final DateTime? modified; final bool directory; const ArchiveEntry( this.name, this.size, { this.compressed, this.modified, this.directory = false, }); } class ArchiveListing { final String format; final List entries; /// For a single compressed file (.gz): the uncompressed size. final int? uncompressed; const ArchiveListing(this.format, this.entries, {this.uncompressed}); } /// Archives larger than this are not unpacked in memory for listing. const archiveLimit = 64 * 1024 * 1024; class ArchiveUnsupported implements Exception { final String format; const ArchiveUnsupported(this.format); } Uint8List _gunzipBounded(Uint8List bytes) { final out = BytesBuilder(copy: false); final sink = ByteConversionSink.withCallback((chunk) { out.add(chunk); if (out.length > archiveLimit) throw const ArchiveUnsupported('tooLarge'); }); final decoder = gzip.decoder.startChunkedConversion(sink); decoder.add(bytes); decoder.close(); return out.takeBytes(); } /// A TAR header: "ustar" magic, or (old formats) a valid header checksum. bool _isTar(Uint8List b) { if (b.length < 512) return false; if (String.fromCharCodes(b.sublist(257, 262)) == 'ustar') return true; final field = String.fromCharCodes( b.sublist(148, 156), ).replaceAll(RegExp(r'[\x00 ]'), ''); final stored = int.tryParse(field, radix: 8); if (stored == null) return false; var sum = 0; for (var i = 0; i < 512; i++) { sum += i >= 148 && i < 156 ? 0x20 : b[i]; } return sum == stored; } /// ZIP stores DOS dates, TAR Unix seconds. List _entries(Archive a, {bool zip = false}) => [ for (final f in a.files) ArchiveEntry( f.name, f.size, modified: f.lastModTime == 0 ? null : zip ? f.lastModDateTime : DateTime.fromMillisecondsSinceEpoch(f.lastModTime * 1000), directory: !f.isFile, ), ]; /// Lists the content of a ZIP (also JAR/APK/IPA/AAB), TAR, GZIP, TAR.GZ, /// TAR.BZ2 or TAR.XZ archive. 7z and RAR are not decoded. ArchiveListing listArchive(String name, Uint8List bytes) { final lower = name.toLowerCase(); bool magic(List m) => bytes.length >= m.length && [for (var i = 0; i < m.length; i++) bytes[i] == m[i]].every((b) => b); if (lower.endsWith('.7z') || magic(const [0x37, 0x7a, 0xbc, 0xaf])) { throw const ArchiveUnsupported('7z'); } if (lower.endsWith('.rar') || magic(const [0x52, 0x61, 0x72, 0x21])) { throw const ArchiveUnsupported('RAR'); } if (magic(const [0x50, 0x4b])) { return ArchiveListing( 'ZIP', _entries(ZipDecoder().decodeBytes(bytes), zip: true), ); } if (magic(const [0x1f, 0x8b])) { final inner = _gunzipBounded(bytes); if (_isTar(inner)) { return ArchiveListing( 'TAR.GZ', _entries(TarDecoder().decodeBytes(inner)), uncompressed: inner.length, ); } final base = name.split('/').last; return ArchiveListing('GZIP', [ ArchiveEntry( base.toLowerCase().endsWith('.gz') ? base.substring(0, base.length - 3) : base, inner.length, compressed: bytes.length, ), ], uncompressed: inner.length); } if (magic(const [0x42, 0x5a, 0x68]) || magic(const [0xfd, 0x37, 0x7a])) { if (bytes.length > archiveLimit ~/ 4) { throw const ArchiveUnsupported('tooLarge'); } final inner = magic(const [0x42, 0x5a, 0x68]) ? BZip2Decoder().decodeBytes(bytes) : XZDecoder().decodeBytes(bytes); return ArchiveListing( magic(const [0x42, 0x5a, 0x68]) ? 'TAR.BZ2' : 'TAR.XZ', _entries(TarDecoder().decodeBytes(inner)), uncompressed: inner.length, ); } if (_isTar(bytes)) { return ArchiveListing('TAR', _entries(TarDecoder().decodeBytes(bytes))); } throw const ArchiveUnsupported('unknown'); } // --------------------------------------------------------------------------- // Certificates and keys class _Tlv { final int tag, start, length; final Uint8List data; const _Tlv(this.tag, this.start, this.length, this.data); Uint8List get value => Uint8List.sublistView(data, start, start + length); int get end => start + length; List<_Tlv> get children { final out = <_Tlv>[]; var at = start; while (at < end) { final t = _read(data, at); out.add(t); at = t.end; } return out; } } _Tlv _read(Uint8List d, int at) { if (at + 2 > d.length) throw const FormatException('DER'); final tag = d[at]; var length = d[at + 1]; var start = at + 2; if (length & 0x80 != 0) { final n = length & 0x7f; if (n == 0 || n > 4 || start + n > d.length) { throw const FormatException('DER length'); } length = 0; for (var i = 0; i < n; i++) { length = (length << 8) | d[start + i]; } start += n; } if (start + length > d.length) throw const FormatException('DER bounds'); return _Tlv(tag, start, length, d); } String _oid(Uint8List v) { if (v.isEmpty) return ''; final parts = [v[0] ~/ 40, v[0] % 40]; var n = 0; for (var i = 1; i < v.length; i++) { n = (n << 7) | (v[i] & 0x7f); if (v[i] & 0x80 == 0) { parts.add(n); n = 0; } } return parts.join('.'); } const _names = { '2.5.4.3': 'CN', '2.5.4.6': 'C', '2.5.4.7': 'L', '2.5.4.8': 'ST', '2.5.4.10': 'O', '2.5.4.11': 'OU', '1.2.840.113549.1.9.1': 'E', }; const _algorithms = { '1.2.840.113549.1.1.1': 'RSA', '1.2.840.10045.2.1': 'EC', '1.3.101.112': 'Ed25519', '1.3.101.113': 'Ed448', '1.2.840.10040.4.1': 'DSA', }; String _name(_Tlv seq) => [ for (final set in seq.children) for (final attr in set.children) if (attr.children.length >= 2) '${_names[_oid(attr.children[0].value)] ?? _oid(attr.children[0].value)}=' '${utf8.decode(attr.children[1].value, allowMalformed: true)}', ].join(', '); DateTime? _time(_Tlv t) { final s = ascii.decode(t.value, allowInvalid: true); try { if (t.tag == 0x17) { // UTCTime YYMMDDHHMMSSZ final y = int.parse(s.substring(0, 2)); return DateTime.utc( y < 50 ? 2000 + y : 1900 + y, int.parse(s.substring(2, 4)), int.parse(s.substring(4, 6)), int.parse(s.substring(6, 8)), int.parse(s.substring(8, 10)), int.parse(s.substring(10, 12)), ); } return DateTime.utc( int.parse(s.substring(0, 4)), int.parse(s.substring(4, 6)), int.parse(s.substring(6, 8)), int.parse(s.substring(8, 10)), int.parse(s.substring(10, 12)), int.parse(s.substring(12, 14)), ); } catch (_) { return null; } } String _hex(List b, [String sep = ':']) => b.map((x) => x.toRadixString(16).padLeft(2, '0').toUpperCase()).join(sep); /// One item found in a certificate or key file. Private keys are reported /// as present; their content is never shown here. class CryptoItem { final String type; final Map fields; final bool secret; const CryptoItem(this.type, this.fields, {this.secret = false}); } Future _certificate(Uint8List der) async { final cert = _read(der, 0); final tbs = cert.children.first; var parts = tbs.children; if (parts.isNotEmpty && parts.first.tag == 0xa0) parts = parts.sublist(1); final serial = parts[0].value; final issuer = parts[2], validity = parts[3], subject = parts[4]; final spki = parts[5]; final algorithm = _oid(spki.children.first.children.first.value); final times = validity.children; final from = _time(times[0]), to = _time(times[1]); final sha256 = await Sha256().hash(Uint8List.sublistView(der, 0, cert.end)); return CryptoItem('X.509 certificate', { 'subject': _name(subject), 'issuer': _name(issuer), 'serial': _hex(serial), 'validFrom': from?.toIso8601String() ?? '?', 'validTo': to?.toIso8601String() ?? '?', 'expired': to == null ? '?' : (to.isBefore(DateTime.now()) ? 'yes' : 'no'), 'publicKey': _algorithms[algorithm] ?? algorithm, 'sha256': _hex(sha256.bytes), }); } /// Inspects PEM, DER certificates, OpenSSH public keys and armored PGP. Future> inspectCrypto(Uint8List bytes) async { final text = utf8.decode(bytes, allowMalformed: true); final items = []; final pem = RegExp(r'-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----'); for (final m in pem.allMatches(text)) { final type = m.group(1)!; if (type.contains('PRIVATE KEY') || type == 'PGP PRIVATE KEY BLOCK') { items.add(CryptoItem(type, const {}, secret: true)); continue; } if (type == 'CERTIFICATE') { try { final body = m.group(2)!.replaceAll(RegExp(r'\s'), ''); items.add(await _certificate(base64Decode(body))); continue; } catch (_) { /* Shown as a plain block below. */ } } items.add(CryptoItem(type, const {})); } for (final line in const LineSplitter().convert(text)) { final parts = line.trim().split(RegExp(r'\s+')); if (parts.length >= 2 && RegExp(r'^(ssh-|ecdsa-|sk-)').hasMatch(parts[0])) { try { final blob = base64Decode(parts[1]); final hash = await Sha256().hash(blob); items.add( CryptoItem('OpenSSH public key', { 'type': parts[0], 'comment': parts.length > 2 ? parts.sublist(2).join(' ') : '', 'sha256': 'SHA256:${base64Encode(hash.bytes).replaceAll('=', '')}', }), ); } catch (_) { /* Not a key line. */ } } } if (items.isEmpty && bytes.isNotEmpty && bytes[0] == 0x30) { try { items.add(await _certificate(bytes)); } catch (_) { /* Not DER. */ } } return items; } // --------------------------------------------------------------------------- // Jupyter notebooks class NotebookCell { final String type, source; final List outputs; final List images; const NotebookCell(this.type, this.source, this.outputs, this.images); } String _joined(Object? v) => v is List ? v.map((e) => '$e').join() : (v?.toString() ?? ''); /// The cells of a Jupyter notebook; text and PNG outputs only. List parseNotebook(String text) { final json = jsonDecode(text) as Map; final cells = json['cells'] as List? ?? const []; return [ for (final c in cells.cast()) NotebookCell( c['cell_type']?.toString() ?? 'code', _joined(c['source']), [ for (final o in (c['outputs'] as List? ?? const []).cast()) if (o['text'] != null) _joined(o['text']) else if ((o['data'] as Map?)?['text/plain'] != null) _joined((o['data'] as Map)['text/plain']), ], [ for (final o in (c['outputs'] as List? ?? const []).cast()) if ((o['data'] as Map?)?['image/png'] != null) base64Decode( _joined((o['data'] as Map)['image/png']).replaceAll('\n', ''), ), ], ), ]; }