singpolyma / openpgp-php

OpenPGP.php is a pure-PHP implementation of the OpenPGP Message Format (RFC 4880).
http://singpolyma.github.io/openpgp-php/
The Unlicense
179 stars 69 forks source link

Linebreaks in clearsigned message seem to break signature #136

Closed GMBrian closed 2 months ago

GMBrian commented 4 months ago

I'm trying to clearsign a message, which works fine until I add \n linebreaks into the message. After that the signature can't be verified anymore.

Most of my function is simply taken from the keygen, clearsign and verify examples. My goal is generate and return new private and public keys and clearsign a file with a valid signature.

function clearsign_file($name, $email, $file_contents, $passphrase = '')
{
    $key_length = 2048;

    // Generate a key pair
    $privateKey = RSA::createKey($key_length);
    $privateKeyComponents = PKCS1::load($privateKey->toString('PKCS1'));

    $secretKeyPacket = new OpenPGP_SecretKeyPacket(array(
        'n' => $privateKeyComponents["modulus"]->toBytes(),
        'e' => $privateKeyComponents["publicExponent"]->toBytes(),
        'd' => $privateKeyComponents["privateExponent"]->toBytes(),
        'p' => $privateKeyComponents["primes"][1]->toBytes(),
        'q' => $privateKeyComponents["primes"][2]->toBytes(),
        'u' => $privateKeyComponents["coefficients"][2]->toBytes()
    ));

    // Assemble packets for the private key
    $packets = array($secretKeyPacket);

    $wkey = new OpenPGP_Crypt_RSA($secretKeyPacket);
    $fingerprint = $wkey->key()->fingerprint;
    $key = $wkey->private_key();
    $key = $key->withHash('sha256');
    $keyid = substr($fingerprint, -16);

    // Add a user ID packet
    $uid = new OpenPGP_UserIDPacket("$name <$email>");
    $packets[] = $uid;

    // Add a signature packet to certify the binding between the user ID and the key
    $sig = new OpenPGP_SignaturePacket(new OpenPGP_Message(array($secretKeyPacket, $uid)), 'RSA', 'SHA256');
    $sig->signature_type = 0x13;
    $sig->hashed_subpackets[] = new OpenPGP_SignaturePacket_KeyFlagsPacket(array(0x01 | 0x02 | 0x04)); // Certify + sign + encrypt bits
    $sig->hashed_subpackets[] = new OpenPGP_SignaturePacket_IssuerPacket($keyid);
    $m = $wkey->sign_key_userid(array($secretKeyPacket, $uid, $sig));

    // Append the signature to the private key packets
    $packets[] = $m->packets[2];

    // Assemble packets for the public key
    $publicPackets = array(new OpenPGP_PublicKeyPacket($secretKeyPacket));
    $publicPackets[] = $uid;
    $publicPackets[] = $sig;

    // Encrypt the private key with a passphrase
    $encryptedSecretKeyPacket = OpenPGP_Crypt_Symmetric::encryptSecretKey($passphrase, $secretKeyPacket);

    // Assemble the private key message
    $privateMessage = new OpenPGP_Message($packets);
    $privateMessage[0] = $encryptedSecretKeyPacket;

    // Enarmor the private key message
    $privateEnarmorKey = OpenPGP::enarmor($privateMessage->to_bytes(), "PGP PRIVATE KEY BLOCK");

    // Assemble the public key message
    $publicMessage = new OpenPGP_Message($publicPackets);

    // Enarmor the public key message
    $publicEnarmorKey = OpenPGP::enarmor($publicMessage->to_bytes(), "PGP PUBLIC KEY BLOCK");

    $SecurityTxtAdmin = new Generate_Security_Txt_Admin();
    $string = $SecurityTxtAdmin->get_securitytxt_file_contents(); // This simply gets the file contents pasted below

    $m = $wkey->sign($string);

    /* Generate clearsigned data */
    $packets = $m->signatures()[0];

    $file_contents = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n";
    // Output normalised data.  You could convert line endings here
    // without breaking the signature, but do not add any
    // trailing whitespace to lines.
    $file_contents .= preg_replace("/^-/", "- -", $packets[0]->data) . "\n";
    $file_contents .= OpenPGP::enarmor($packets[1][0]->to_bytes(), "PGP SIGNATURE");

    $parsed_pubkey = OpenPGP_Message::parse($publicEnarmorKey);

    /* Parse signed message from file named "t" */
    $m = OpenPGP_Message::parse($file_contents);

    /* Create a verifier for the key */
    $verify = new OpenPGP_Crypt_RSA($parsed_pubkey);

    // We verify
    $verified = (bool)$verify->verify($m); // This does return TRUE in either case of linebreaks or not

    return array(
        'signed_message' => $file_contents,
        'public_key' => $publicEnarmorKey,
        'private_key' => $privateEnarmorKey,
        'verified' => $verified
    );
}

Contents from file $SecurityTxtAdmin->get_securitytxt_file_contents();

Contact: mailto:mail@testdomain.comExpires: 2025-04-29T00:00:00.000000ZPreferred-Languages: enEncryption: http://localhost:8114/pubkey.txtCanonical: http://localhost:8114/.well-known/security.txt

After clearsign

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

Contact: mailto:mail@testdomain.comExpires: 2025-04-29T00:00:00.000000ZPreferred-Languages: enEncryption: http://localhost:8114/pubkey.txtCanonical: http://localhost:8114/.well-known/security.txt
-----BEGIN PGP SIGNATURE-----

wv8AAAEkBAABCAAY/wAAAAUCZjDTHP8AAAAJEFp7e62h3skZAAAUpQf9FKUbEySWnV9SlrumgasP
lJOIQePVNkhWf3L6CQf/jX7iAtAvsGbBxCT+yjzLqxpZkv9LqMqKTYXXEbnTA2KHofhTWzdz4cp4
pJQIF06dHjdDWyrGZ22ufvAY5zw3OAE3U9ErVOy56faRJBcgQ6HslU9DmXq6GjRkT60wYr4Zet1b
Nm5hp8BPkSbZRQuWwhKCYoR6bRpH5WcMGwHBtE9LM3irrwQrJMzJGhdXEyq5GqxAJJ1EIZD/VhMc
TZ01bEZlBVKQNpcMxbMvP1KmaMayKI+ZnEUYrYjVwVaTszndw20GxnHiJe9k2BcCMNlc4YNOQwzU
7LvWO0U5fxB1qpzqoA==
=eBD6
-----END PGP SIGNATURE-----

The above signature can be verified correctly with a tool such as GPG Suite or https://pgptool.org/. However if I enter any \n linebreaks into this file, it will break the signature and these tools give a bad result. Linebreaks are essential for my implementation. The public key for this file is;

-----BEGIN PGP PUBLIC KEY BLOCK-----

xv8AAAENBGYw0xwBCACzuVN/GRscZQFsJboipVnG9vcw7e8de+9YzgmtOTVhJ2ILCSTa+1o7cPQY
GZylwrTtWteXRsZ9NdGl9oA94Ph1jqTrdKtJWRPar7Dmj6NR6OBeWzXLKCIh+V0/NgiQiyI0dbBI
PuU/VRe3Iq4ax+8+JdYXxnboRUiyiuUDJg5px+60B6j8M7l3vjxcL78/DU/7pRoDG2NqoddcNJRy
peOkQBzyiB6rn04TWEvt9BTbl/iAhb6TMrQ6clMquJjVAYfJFzuP3Nl1Tuyi1TiKp9crhwrsa8BL
ygogTCbfpgXhCfbTpz3Q6JsST7eSO00vxCT/GcVIcVyp9R85yzWsGDATABEBAAHN/wAAACltYWls
QHRlc3Rkb21haW4uY29tIDxtYWlsQHRlc3Rkb21haW4uY29tPsL/AAABKwQTAQgAH/8AAAAFAmYw
0xz/AAAAAhsH/wAAAAkQWnt7raHeyRkAAHU4B/91ODXFWMYtse3PF9K05j4dbfV+TMwxXI95HJb4
uM1Ko0BrTvnTIo3DiiM7B3Wn2iLlY/RuiMqhkjbdmoYypSVLhSk/9ELs8MBTrzY0NiONkzvY1Za8
Dgl+hO/it8BOpkmUojl8vul5wxYVYLdJwWFw8bosSbSsF+Wfb1mflBoMq844VVWFkJDuajhvUhew
Oo1LBM82Vic/AftYUXdF0mNPKMBXD316bR+pu95+g6m04wplSXtoRZga9imwxMrdxhCy8yRcipvu
f9BYPypyM4I0N13iJjZiAbD+vGTjIREBDrdNzcm2LaympZYminmcwP9RHM5CO0QeBmcCxC0zM9Ye
=tfQs
-----END PGP PUBLIC KEY BLOCK-----

After introducing linebreaks:

Contact: mailto:mail@testdomain.com
Expires: 2025-04-29T00:00:00.000000Z
Preferred-Languages: en
Encryption: http://localhost:8114/pubkey.txt
Canonical: http://localhost:8114/.well-known/security.txt
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

Contact: mailto:mail@testdomain.com
Expires: 2025-04-29T00:00:00.000000Z
Preferred-Languages: en
Encryption: http://localhost:8114/pubkey.txt
Canonical: http://localhost:8114/.well-known/security.txt

-----BEGIN PGP SIGNATURE-----

wv8AAAEkBAABCAAY/wAAAAUCZjDQdP8AAAAJEJ2u6xIjEcoGAAAL9wf8C/err/bnMsBdJPdrmx6g
cdPb3xyPIONKtM4vAHS//tErqhnINy2aqBzVE/ySLbZyEmmjNXgA6saObulhd9PrI9O4UiGx0KDk
eGFuKXLDV2mh0K0Vgkqv4Szx2+O/Y/M0W6OvchDvVnbf7n2JdKseqFcfIXJKvL8lAXcZB8Zyl+PQ
xOK+mvxKvOS4eK6shZ+6amqRlmZIANal1CeYqG3W9qDyySI68s0qHeDc5DJYcUYaVfuK1MDArAaE
YBpmboHkcz3nm2/TFRFOVOaWFQYweLrXjLZ2pjNHw+aYKbtHGZHsenhdImzoU36836/402crnBcJ
d3YtA6xlGgw2WkuiJg==
=Ajdv
-----END PGP SIGNATURE-----

The above signature is not valid according to the earlier mentioned GPG tools. The public key for this file is;

-----BEGIN PGP PUBLIC KEY BLOCK-----

xv8AAAENBGYw0HQBCADJBiLCcp7MbgK7hncBqCUUe6zW+Ke+s2e21Kt6r2n/gZA3JQUMLtYD1v+h
R5a6lb3lCc+c0kf0HY+ISQTarwrBPUT3P1Xfda9XmqjS1WAUBGn0j7JNMXzf1vvOde/Ijn74MWTL
6ak+c0kLqHwu805orVCo0H3jucTYyCA+YNnhD5HYbAyFcYIR/v/QUIBU+ShUaKoct7R/iykpYjqD
flEZ0lr3jyKXGJytJQJlYnKpNwfPtNjsJGVct9p5C0SMPv53MRPyxnW0E1hCLGGCoPacLlO2fxW3
7HB/KT4iJyYe7r3Gj+kkuk2uHccIQM/u93KXCTToEQ39NVhqlhhN/ULTABEBAAHN/wAAACltYWls
QHRlc3Rkb21haW4uY29tIDxtYWlsQHRlc3Rkb21haW4uY29tPsL/AAABKwQTAQgAH/8AAAAFAmYw
0HT/AAAAAhsH/wAAAAkQna7rEiMRygYAAGpsB/9qbESjfsNZ/0VZGYynwA1znZ1aTxVgQZeP96R0
6TBve82FVrlSgT61Hi5CIA0tLL03+D9exI98oplxFrdIbKkxCLFYS3lp62ATPYiXRQeLTL1Q/Pk9
DB0NIhFHCNQwoFQmvBHPOsyTjLKuXyuUNUDaCR/j2urD7y3a/EwD11il1DwPagfD+gojGcTjZRKh
1GCKjt+ZxpG/JhTil3hf1F1dxFLPTdusLcOzBS7Pm2+GGOojNSwRFgZn75JKpLWJiTlCZxqVr2xc
ALr49rj5wv21QnIDi4i2j0iiMttkc3YzkrecrQsoV/1oiOF8wJKuL5h32Ph4V2/Bqj/woo3mj8vJ
=TOaZ
-----END PGP PUBLIC KEY BLOCK-----

Please let me know if you need more information.

GMBrian commented 3 months ago

@singpolyma I wanted to follow up on this issue to see if there might be any updates or if there's anything I can do to help move it forward. Thanks

bwbroersma commented 2 months ago

Both security.txt as the PGP cleartext signature use the Internet standard newline <CR><LF> / \r\n. However security.txt allows 'some' LF's (see Line Separator), however cleartext signatures are still over the <CR><LF>:

a cleartext signature is calculated on the text using canonical <CR><LF> line endings

Source: RFC 4880 - OpenPGP Message Format - 7.1. Dash-Escaped Text.

So it's to be expected if openpgp-php is cleartext unaware (only OpenPGP_LiteralDataPacket normalize($clearsign=false) is), this will result in a different signature than a cleartext signature. Also see:

It took me a while to get your sample working, because of another <CR><LF>:

The line ending (i.e., the <CR><LF>) before the -----BEGIN PGP SIGNATURE----- line that terminates the signed text is not considered part of the signed text.

and

An implementation SHOULD add a line break after the cleartext, but MAY omit it if the cleartext ends with a line break. This is for visual clarity.

It results in this modified example that works for me (which I placed in the example directory of openpgp-php-0.6.0):

<?php

use phpseclib3\Crypt\RSA;
use phpseclib3\Crypt\RSA\Formats\Keys\PKCS1;

include_once dirname(__FILE__).'/../vendor/autoload.php';
require_once dirname(__FILE__).'/../lib/openpgp.php';
require_once dirname(__FILE__).'/../lib/openpgp_crypt_rsa.php';

$name = "dummy";
$email = "security@example.org";
$passphrase = "high-entropy-passphrase";

$key_length = 2048;
// Generate a key pair
$privateKey = RSA::createKey($key_length);
$privateKeyComponents = PKCS1::load($privateKey->toString('PKCS1'));

$secretKeyPacket = new OpenPGP_SecretKeyPacket(array(
    'n' => $privateKeyComponents["modulus"]->toBytes(),
    'e' => $privateKeyComponents["publicExponent"]->toBytes(),
    'd' => $privateKeyComponents["privateExponent"]->toBytes(),
    'p' => $privateKeyComponents["primes"][1]->toBytes(),
    'q' => $privateKeyComponents["primes"][2]->toBytes(),
    'u' => $privateKeyComponents["coefficients"][2]->toBytes()
));

// Assemble packets for the private key
$packets = array($secretKeyPacket);

$wkey = new OpenPGP_Crypt_RSA($secretKeyPacket);
$fingerprint = $wkey->key()->fingerprint;
$key = $wkey->private_key();
$key = $key->withHash('sha256');
$keyid = substr($fingerprint, -16);

// Add a user ID packet
$uid = new OpenPGP_UserIDPacket("$name <$email>");
$packets[] = $uid;

// Add a signature packet to certify the binding between the user ID and the key
$sig = new OpenPGP_SignaturePacket(new OpenPGP_Message(array($secretKeyPacket, $uid)), 'RSA', 'SHA256');
$sig->signature_type = 0x13;
$sig->hashed_subpackets[] = new OpenPGP_SignaturePacket_KeyFlagsPacket(array(0x01 | 0x02 | 0x04)); // Certify + sign + encrypt bits
$sig->hashed_subpackets[] = new OpenPGP_SignaturePacket_IssuerPacket($keyid);
$m = $wkey->sign_key_userid(array($secretKeyPacket, $uid, $sig));

// Append the signature to the private key packets
$packets[] = $m->packets[2];

// Assemble packets for the public key
$publicPackets = array(new OpenPGP_PublicKeyPacket($secretKeyPacket));
$publicPackets[] = $uid;
$publicPackets[] = $sig;

// Encrypt the private key with a passphrase
$encryptedSecretKeyPacket = OpenPGP_Crypt_Symmetric::encryptSecretKey($passphrase, $secretKeyPacket);

// Assemble the private key message
$privateMessage = new OpenPGP_Message($packets);
$privateMessage[0] = $encryptedSecretKeyPacket;

// Enarmor the private key message
$privateEnarmorKey = OpenPGP::enarmor($privateMessage->to_bytes(), "PGP PRIVATE KEY BLOCK");

// Assemble the public key message
$publicMessage = new OpenPGP_Message($publicPackets);

// Enarmor the public key message
$publicEnarmorKey = OpenPGP::enarmor($publicMessage->to_bytes(), "PGP PUBLIC KEY BLOCK");

$string = "Contact: mailto:$email\r\nExpires: 2025-04-29T00:00:00Z\r\nPreferred-Languages: en\r\nEncryption: https://www.example.org/publickey.txt\r\nCanonical: https://www.example.org/.well-known/security.txt";

$m = $wkey->sign($string);

/* Generate clearsigned data */
$packets = $m->signatures()[0];

$file_contents = "-----BEGIN PGP SIGNED MESSAGE-----\r\nHash: SHA256\r\n\r\n";
// Output normalised data. You could convert line endings here
// or add trailing whitespace (spaces and tabs) to lines without breaking the signature
$file_contents .= preg_replace("/^-/", "- -", $packets[0]->data) . "\r\n";
$file_contents .= OpenPGP::enarmor($packets[1][0]->to_bytes(), "PGP SIGNATURE");

$parsed_pubkey = OpenPGP_Message::parse($publicEnarmorKey);

/* Parse signed message from file named "t" */
$m = OpenPGP_Message::parse($file_contents);

/* Create a verifier for the key */
$verify = new OpenPGP_Crypt_RSA($parsed_pubkey);

// We verify
$verified = (bool)$verify->verify($m);

echo var_export(array(
    'signed_message' => $file_contents,
    'public_key' => $publicEnarmorKey,
    'private_key' => $privateEnarmorKey,
    'verified' => $verified
));

Which will result in a signed security.txt:

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

Contact: mailto:security@example.org
Expires: 2025-04-29T00:00:00Z
Preferred-Languages: en
Encryption: https://www.example.org/publickey.txt
Canonical: https://www.example.org/.well-known/security.txt
-----BEGIN PGP SIGNATURE-----

wv8AAAEkBAABCAAY/wAAAAUCZmi0d/8AAAAJEBzceu7GBetzAABTKwf/UyuI0a7vrh8A3ZBLlCEt
L5ngWDiahiUtwNEf+G/wwylO5ERlzYfBrotHueF4N9NX/2g9QlkCtdHgII7FXni+B9Tp4svydi8K
NZx48qdqEIB9LuUGfBaZ6imm7+2WQY3BvywZU03BTCXFS1bclvFwEzOUJJqqFinbRos5ACzFrRQ5
XIritbFtyql8H616nPhd5/8p1VDo3dseVK6eq6X7AAkoEPcKqd3GQ36aEWpwPpKUXnOnGPywNDCz
pNr+nRX7XoVVhsqfkQhiMTYQKzbk0i+IeWvii0iGejSAHH3afQlKacmTcBjbLrqG9F9k/98JWP/y
F59oKa3ULQQY5bYmUQ==
=ZDI9
-----END PGP SIGNATURE-----

with the publickey.txt:

-----BEGIN PGP PUBLIC KEY BLOCK-----

xv8AAAENBGZotHcBCAC/zCEyheqrkWrqqeMjMxhuav6g1LL3KIqmcV6zRNXzdP3SSWtrM7xSRQCv
C7HjvcGXbmT75eaBUhIWi28QX6exGR/kQURQK8MJg9e1GtU2hF/lpN6wG+QJo+jIEntuFpCfNxPD
s6/KQAltdrOFPSO0VYHrl9GvWNBYKBhtMUOc8cole3CwhvupGCExlgNqgza9R6d1bj1ZrqxleJ9v
u37IVzO0A5etj+sgjeV/i6hh0X3dxNJIgKc88mLkNqrnbzGSz5KM4JYAdRA4Uf9wwHjbWBGRg3iL
KcaSMyQ+kjesNsm6jl/KmEMUOU5jzdHgmocxSWZaE7DruMl9OpcsLA4zABEBAAHN/wAAABxkdW1t
eSA8c2VjdXJpdHlAZXhhbXBsZS5vcmc+wv8AAAErBBMBCAAf/wAAAAUCZmi0d/8AAAACGwf/AAAA
CRAc3HruxgXrcwAABhIH+wYSEUHAjLkwdBs/HwttMHvifBsKEleUgqQJh7odfhPWiAeOCzYsilVG
nLRVOcP7oBGjFXPPTK5of5ydLgtB+SYd+CKD/HvssBmvgeCYCYrzZB+L2i8QZ6w79eGkLqRNl0kA
cgIlHV5DjZIlY1wCNjp72N6oCUetr43i8rSmE9Oj1pj0FzQNaXgQQ632a0ClWPn7EIcw2/EEOj7n
jpTtNmgXWJBqygraAmYfxC6E5JDhPdzKPG1ZTqG2K41owKej+spDn7LU3jV8OF/2NUN7y60J+dZ3
+9Mr9fnqapYiLAdWhHBh6sMNN7QeYW1T6hMZfUNHh8Jm+kcoN3YCpsGpTOw=
=K6ye
-----END PGP PUBLIC KEY BLOCK-----

which validates in gpg:

$ gpg --no-default-keyring --keyring tmp.gpg --import publickey.txt && gpg --keyring tmp.gpg --verify security.txt 
gpg: key 1CDC7AEEC605EB73: public key "dummy <security@example.org>" imported
gpg: Total number processed: 1
gpg:               imported: 1
gpg: Signature made Tue 11 Jun 2024 10:32:55 PM CEST
gpg:                using RSA key 1CDC7AEEC605EB73
gpg: Good signature from "dummy <security@example.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 4BA6 5740 4364 D93F 1F87  3681 1CDC 7AEE C605 EB73

Also https://pgptool.org/ validates.

GMBrian commented 2 months ago

@bwbroersma I totally missed the <CR><LF> discrepancy. Thanks for your elaborate answer. My first tests are validating just fine as well. Great help