From eb05688a5d5cd3783e54803b80bcd934b9b61c49 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Fri, 6 Feb 2026 11:36:09 -0800 Subject: [PATCH] fix(shorebird_cli): support PKCS#1 private key format for patch signing (#3603) --- .../shorebird_cli/lib/src/code_signer.dart | 85 ++++++++++++++++--- .../test/fixtures/crypto/private_pkcs1.pem | 27 ++++++ .../test/src/code_signer_test.dart | 46 +++++++++- 3 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 packages/shorebird_cli/test/fixtures/crypto/private_pkcs1.pem diff --git a/packages/shorebird_cli/lib/src/code_signer.dart b/packages/shorebird_cli/lib/src/code_signer.dart index d3bd6b90..67877ce8 100644 --- a/packages/shorebird_cli/lib/src/code_signer.dart +++ b/packages/shorebird_cli/lib/src/code_signer.dart @@ -1,4 +1,4 @@ -// cspell:words dgst +// cspell:words dgst genrsa genpkey import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -13,6 +13,17 @@ final codeSignerRef = create(CodeSigner.new); /// The [CodeSigner] instance available in the current zone. CodeSigner get codeSigner => read(codeSignerRef); +/// The format of a PEM-encoded private key. +enum PrivateKeyFormat { + /// PKCS#8 format with header "-----BEGIN PRIVATE KEY-----". + /// Generated by: openssl genpkey -algorithm RSA + pkcs8, + + /// PKCS#1 format with header "-----BEGIN RSA PRIVATE KEY-----". + /// Generated by: openssl genrsa + pkcs1, +} + /// {@template code_signer} /// Manages code signing operations. /// {@endtemplate} @@ -22,12 +33,13 @@ class CodeSigner { /// /// This is the equivalent of: /// $ openssl dgst -sha256 -sign privateKey.pem -out signature message + /// + /// Supports both PKCS#8 ("BEGIN PRIVATE KEY") and PKCS#1 + /// ("BEGIN RSA PRIVATE KEY") formats. String sign({required String message, required File privateKeyPemFile}) { - final privateKeyData = _pemBytes( - pemFile: privateKeyPemFile, - type: PemLabel.privateKey, - ); - final privateKey = _RSAPrivateKeyFromBytes.from(privateKeyData); + final pemString = privateKeyPemFile.readAsStringSync(); + final (keyBytes, format) = _parsePrivateKeyPem(pemString); + final privateKey = _RSAPrivateKeyFromBytes.from(keyBytes, format); final signer = Signer('SHA-256/RSA') ..init(true, PrivateKeyParameter(privateKey)); @@ -58,20 +70,69 @@ class CodeSigner { final privateKeyString = pemFile.readAsStringSync(); return PemCodec(type).decode(privateKeyString); } + + /// Parses a PEM-encoded private key string and returns the key bytes along + /// with the detected format. + /// + /// Supports both PKCS#8 and PKCS#1 formats. + (List, PrivateKeyFormat) _parsePrivateKeyPem(String pemString) { + // Try PKCS#8 format first (BEGIN PRIVATE KEY) - natively supported. + final pkcs8Blocks = decodePemBlocks(PemLabel.privateKey, pemString); + if (pkcs8Blocks.isNotEmpty) { + return (pkcs8Blocks.first, PrivateKeyFormat.pkcs8); + } + + // Try PKCS#1 format (BEGIN RSA PRIVATE KEY). + // The pem package only supports RFC 7468 labels and doesn't include + // "RSA PRIVATE KEY", so we validate the label ourselves and use + // unsafeIgnoreLabel for parsing. + // See: https://github.com/google/dart-neats/issues/198 + if (pemString.contains('-----BEGIN RSA PRIVATE KEY-----')) { + final blocks = decodePemBlocks( + PemLabel.privateKey, + pemString, + unsafeIgnoreLabel: true, + ); + if (blocks.isNotEmpty) { + return (blocks.first, PrivateKeyFormat.pkcs1); + } + } + + throw const FormatException( + 'No valid PEM private key found. Expected "BEGIN PRIVATE KEY" (PKCS#8) ' + 'or "BEGIN RSA PRIVATE KEY" (PKCS#1) format.', + ); + } } extension _RSAPrivateKeyFromBytes on RSAPrivateKey { - /// Converts an RSA private key bytes to a pointycastle [RSAPrivateKey]. + /// Converts RSA private key bytes to a pointycastle [RSAPrivateKey]. + /// + /// Supports both PKCS#8 and PKCS#1 formats based on the [format] parameter. /// /// Based on https://github.com/konstantinullrich/crypton/blob/trunk/lib/src/rsa/private_key.dart - static RSAPrivateKey from(List privateKeyBytes) { - var asn1Parser = ASN1Parser(Uint8List.fromList(privateKeyBytes)); + static RSAPrivateKey from( + List privateKeyBytes, + PrivateKeyFormat format, + ) { + final asn1Parser = ASN1Parser(Uint8List.fromList(privateKeyBytes)); final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence; - final privateKey = topLevelSeq.elements![2]; - asn1Parser = ASN1Parser(privateKey.valueBytes); - final pkSeq = asn1Parser.nextObject() as ASN1Sequence; + final ASN1Sequence pkSeq; + switch (format) { + case PrivateKeyFormat.pkcs8: + // PKCS#8: The RSA key is wrapped in an envelope, extract from + // element[2]. + final privateKeyOctet = topLevelSeq.elements![2]; + final innerParser = ASN1Parser(privateKeyOctet.valueBytes); + pkSeq = innerParser.nextObject() as ASN1Sequence; + case PrivateKeyFormat.pkcs1: + // PKCS#1: The RSA key is directly at the top level + pkSeq = topLevelSeq; + } + // RSAPrivateKey structure (same for both formats at this point): + // version, modulus, publicExponent, privateExponent, prime1, prime2, ... final modulus = pkSeq.elements![1] as ASN1Integer; final privateExponent = pkSeq.elements![3] as ASN1Integer; final p = pkSeq.elements![4] as ASN1Integer; diff --git a/packages/shorebird_cli/test/fixtures/crypto/private_pkcs1.pem b/packages/shorebird_cli/test/fixtures/crypto/private_pkcs1.pem new file mode 100644 index 00000000..0b98207d --- /dev/null +++ b/packages/shorebird_cli/test/fixtures/crypto/private_pkcs1.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA2wdpEGbuvlPsb9i0qYrfMefJnEw1BHTi8SYZTKrXOvJWmEpP +E1hWfbkvYzXu5a96gV1yocF3DMwn04VmRlKhC4AhsD0NL0UNhYhotbKG91Kwi1vA +XpHhCdz5gQEBw0K1uB4Jz+zK6WK+31PryYpwLwbyXNqXoY8IAAUQ4STsHYV5w+BM +Si8pepWMRd7DR9RHcbNOZlJvdBQ5NxvB4JN4dRMq8cC73ez1P9d7Dfwv3TWY+he9 +EmuXLT2UivZSlHIrGBa7MFfqyUe2ro0F7Te/B0si12itBbWIqycvqcXjeOPNn6WE +pqN7IWjb9LUh162JyYaz5Lb/VeeJX8LKtElccwIDAQABAoIBACPq8K3vEJ/8jOLV +44vZXqWamu4QdIdGZcA28mInXUPo4WN4QsRrhu93Z/p51IiCQMbTHLfC9/NiIqEm +GJytSISQHrYoei+HauK8I7OxfZQ0UV2LuR2pCCwrumy5zgtdDAYP7aWqcVIEI+86 +ngl/CXY6H5IzieHFXgH8KAWhHj6Zda7qpp+wGWRwOt4MNQvqZIRShIoT5lYhVXOV +vH8lDv23wu20p4vrFAlxtLw8sieKRqGTg3tyTA7DFMpfxSywicru/IaT2vDsjY7N +63WHQ6TbojJWNGL0OHjdseVnCjbSnuOlX/K9jR93mNCUay+0GChM03T2WiVCHJsn +R/6xFuECgYEA8NovC+gSkRMoCpxFifiizGcQCjnZaeIJRl949e3MeT0Cpo8uh4vE +0QTK59bXj3lk3LHFZfFfwQBKKwitxyV2UMW9PkrU91D7GkmK6Bcs+WQUu+zLAgSE +J63ykb8n0Q0Ng6dOU3O5doqGsUro8V/q2FE85E9tj5ARJZEyGQwLN+ECgYEA6M3c +xilYxG1H2OzeepwN/38sZxV+4Cn/+DEgxqWvkQco14/mJxxiNVhPelQeOLHfONeO +o86vYuBWco3BXh7W64M63zWDvZsQpdvK1+aw6f4EPUFuWw5kBb5eJSlVbOfZYGX+ +pWtE0ut3xFHEsxA0DJGjLaJqlnxp1Rf0PTl8DtMCgYBrkaatdKrGLH4Xrw/HvUpI +bEdXqdqWVaO60G0fuWM3Sq5iYVRwqVXv3Goa/XMs2xTXFHaLPzB67HwzmB608OBU +1tvcdGOFSZ+8b9ggLCmkALTZ8boBXq7/YcsSmQNZdLDyecibsPXGIbxidohvNV2X +20CM34SZN8jQtHZhhWWFQQKBgQCJ1ajLIpvN4K3TP5sRmOeoyBDwD4gJDJTiy6OO +OXbTNRv3VNl9eo6i6uBTmw4xnw58xxNK5QpuTkigllFQeo3g7teiZDrVvcODj7hv +T8SbJaw44E6IVuLdi7KN6gUL79GCA6FbK0GaZ5OHhqohIozOjEPCtxDIfGzkn8QZ +o7lRxQKBgQDBee40zmcCf+ujDamCYsGXV/VcUy21+GBgUnlouV/qk+QFbClJUess +QsywcSTA15+O9kZDbq7HHWpLyN557YfD1Obq/bhShHGLDvRz7b5LkmbEPFRDd3zK +/F3Wk7R99HhesSqjUHQjM6p1plozNFE/wf7FCmVLsWd2NjR+fQWviQ== +-----END RSA PRIVATE KEY----- diff --git a/packages/shorebird_cli/test/src/code_signer_test.dart b/packages/shorebird_cli/test/src/code_signer_test.dart index f94c6cd6..6b6bfcbe 100644 --- a/packages/shorebird_cli/test/src/code_signer_test.dart +++ b/packages/shorebird_cli/test/src/code_signer_test.dart @@ -11,9 +11,14 @@ void main() { CodeSigner, () { final cryptoFixturesBasePath = p.join('test', 'fixtures', 'crypto'); + // PKCS#8 format (BEGIN PRIVATE KEY) final privateKeyFile = File( p.join(cryptoFixturesBasePath, 'private.pem'), ); + // PKCS#1 format (BEGIN RSA PRIVATE KEY) + final privateKeyPkcs1File = File( + p.join(cryptoFixturesBasePath, 'private_pkcs1.pem'), + ); final publicKeyFile = File(p.join(cryptoFixturesBasePath, 'public.pem')); late CodeSigner codeSigner; @@ -26,7 +31,7 @@ void main() { const message = '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b'; - test('signature matches openssl output', () async { + test('signature matches openssl output with PKCS#8 key', () async { final outputDir = Directory.systemTemp.createTempSync(); final messageFile = File(p.join(outputDir.path, 'message')) ..writeAsStringSync(message); @@ -50,6 +55,45 @@ void main() { ); expect(actualSignature, equals(expectedSignature)); }); + + test('signature matches openssl output with PKCS#1 key', () async { + final outputDir = Directory.systemTemp.createTempSync(); + final messageFile = File(p.join(outputDir.path, 'message')) + ..writeAsStringSync(message); + final signatureFile = File(p.join(outputDir.path, 'signature')); + await Process.run('openssl', [ + 'dgst', + '-sha256', + '-sign', + privateKeyPkcs1File.path, + '-out', + signatureFile.path, + messageFile.path, + ]); + + final expectedSignature = base64Encode( + signatureFile.readAsBytesSync(), + ); + final actualSignature = codeSigner.sign( + message: message, + privateKeyPemFile: privateKeyPkcs1File, + ); + expect(actualSignature, equals(expectedSignature)); + }); + + test('PKCS#1 and PKCS#8 keys produce identical signatures', () { + // Both keys are derived from the same RSA key pair, so they should + // produce the same signature. + final pkcs8Signature = codeSigner.sign( + message: message, + privateKeyPemFile: privateKeyFile, + ); + final pkcs1Signature = codeSigner.sign( + message: message, + privateKeyPemFile: privateKeyPkcs1File, + ); + expect(pkcs1Signature, equals(pkcs8Signature)); + }); }); group('base64PublicKey', () {