fix(shorebird_cli): support PKCS#1 private key format for patch signing (#3603)

This commit is contained in:
Eric Seidel
2026-02-06 11:36:09 -08:00
committed by GitHub
parent 95642729ae
commit eb05688a5d
3 changed files with 145 additions and 13 deletions
+73 -12
View File
@@ -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<RSAPrivateKey>(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<int>, 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<int> privateKeyBytes) {
var asn1Parser = ASN1Parser(Uint8List.fromList(privateKeyBytes));
static RSAPrivateKey from(
List<int> 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;
@@ -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-----
@@ -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', () {