fix(shorebird_cli): support PKCS#1 private key format for patch signing (#3603)
This commit is contained in:
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user