[openpgp] Interop for "Text Signature (Type ID 0x01) of a Canonical Document"

Heiko Schäfer <heiko.schaefer@posteo.de> Sun, 25 October 2026 17:51 UTC

Received: from mout02.posteo.de (mout02.posteo.de [185.67.36.66]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) (No client certificate requested) by mx.ietf.org (Postfix) with ESMTPS id 2429F46 for <openpgp@ietf.org>; Sun, 25 Oct 2026 17:51:04 +0000 (UTC)
Authentication-Results: mx.ietf.org; dkim=pass header.d=posteo.de header.s=1984.8680eb header.b=DT+SeIdU; dmarc=pass (policy=none) header.from=posteo.de; spf=pass (mx.ietf.org: domain of heiko.schaefer@posteo.de designates 185.67.36.66 as permitted sender) smtp.mailfrom=heiko.schaefer@posteo.de
Received: from submission (posteo.de [185.67.36.169]) by mout02.posteo.de (Postfix) with ESMTPS id A5B06240101 for <openpgp@ietf.org>; Sun, 25 Oct 2026 18:42:27 +0100 (CET)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=posteo.de; s=1984.8680eb; t=1792950147; bh=s+nr+8hH0eVa8Ytz5aTOWbUzvL+Gwc7K4NPDfA+wn80=; h=Message-ID:Date:MIME-Version:To:From:Subject:Content-Type: Content-Transfer-Encoding:From; b=DT+SeIdU1Ns0UrJvPCDyMuNw+/9150+l3cpOv5f5AYADu32Ik6SnFM5L3t0VpIt+I IrscWnXDYeYJWP0Rmom31gf6PJRJOpsOz7WiOMZCayg4PixHhc+aThHGnyJ1Oz00IA nvi6YQSPsP+BtFc5+mprnfdQMCtnZD6yCvNgZdPGT+HAAAVnJKnjE7flWJfQCObVja RzK9ZAgIXhZyLMrY+MtEx05dCIQ8/u1jtiJN6F+ENaZzVXAZHcXpwf/+PbIB21bKDc mwQIyZq2niD2rH1cV/q2BUZ0xjpVsojvzZDGuu/vcCtmWvP8d/I7z/aKSRWjsgIj4r M6usW4ccdjKIA==
Received: from customer (localhost [127.0.0.1]) by submission (posteo.de) with ESMTPSA id 4jCPGW2hbyz9rxL for <openpgp@ietf.org>; Sun, 25 Oct 2026 18:42:27 +0100 (CET)
Received: from services.empire.hs (services.foundation.hs [192.168.21.4]) by mail.foundation.hs (Postfix) with ESMTP id E7AEC705C5 for <openpgp@ietf.org>; Sun, 25 Oct 2026 18:42:26 +0100 (CET)
Received: from [192.168.21.158] (_gateway [192.168.21.1]) by services.empire.hs (Postfix) with ESMTPSA id CC812C22D2C for <openpgp@ietf.org>; Sun, 25 Oct 2026 18:42:26 +0100 (CET)
Message-ID: <cc793301-bdd6-45dc-8e41-3cb66aef12f2@posteo.de>
Date: Sun, 25 Oct 2026 17:42:27 +0000
MIME-Version: 1.0
To: openpgp@ietf.org
Content-Language: en-US
From: Heiko Schäfer <heiko.schaefer@posteo.de>
Content-Type: text/plain; charset="UTF-8"; format="flowed"
Content-Transfer-Encoding: 8bit
X-Spamd-Bar: -------------------------
Message-ID-Hash: RE7QOAYGMR6LWL46N5ZS6O7KTL7EWEP3
X-Message-ID-Hash: RE7QOAYGMR6LWL46N5ZS6O7KTL7EWEP3
X-MailFrom: heiko.schaefer@posteo.de
X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; header-match-openpgp.ietf.org-0; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header
X-Mailman-Version: 3.3.10
Precedence: list
Subject: [openpgp] Interop for "Text Signature (Type ID 0x01) of a Canonical Document"
List-Id: "Ongoing discussion of OpenPGP issues." <openpgp.ietf.org>
Archived-At: <https://mailarchive.ietf.org/arch/msg/openpgp/cUpg7qSKhVv9myuA01r1lF37PLE>
List-Archive: <https://mailarchive.ietf.org/arch/browse/openpgp>
List-Help: <mailto:openpgp-request@ietf.org?subject=help>
List-Owner: <mailto:openpgp-owner@ietf.org>
List-Post: <mailto:openpgp@ietf.org>
List-Subscribe: <mailto:openpgp-join@ietf.org>
List-Unsubscribe: <mailto:openpgp-leave@ietf.org>

Hello all,

I've spent some time trying to understand how different implementations 
handle normalization when calculating digests for "text signatures" (as 
specified in 5.2.1.1. of RFC 9580).

This topic has also been raised in discussion about a (not yet adopted) 
draft by Andrew, in [1].


My observation is that the implementations I looked at fall into 2.5 
groups, in terms of their behavior.
I'll outline my experiments and conclusions below (mainly to persist 
them for anyone who is interested).

In the following, I'll use two SOP CLI tools [2]: gosop and sqop.
The two act as representatives of families with equivalent behavior.
I'll also show tests with (current) GnuPG.

I've also done some testing with the historical PGP-6.5.8 software 
(using a Linux binary from 1999), but in the interest of not going even 
further overboard, I will only mention results of those tests in passing.


Based on this writeup, it should be easy to replicate my results, and/or 
do additional experimentation.
If anyone does, and comes to different conclusions, or finds additional 
distinctions between implementations that I have not noticed, I'd enjoy 
hearing about it.


# Setup

To set up the experiment, I make a pair of Transferable Secret Key and 
Certificate with gosop (it doesn't matter which software is user to 
create them), and import the Certificate into GnuPG:

$ gosop generate-key alice > tsk
$ gosop extract-cert < tsk > cert

$ gpg --import cert


Then I generate two text mode signatures with gosop:

$ echo -ne "a\na" | gosop sign --as text tsk > alfa.sig
$ echo -ne "a\ra" | gosop sign --as text tsk > acra.sig


Each of these two payloads is 3 characters long.
They contain the following data, respectively:

- "a" + LF + "a"
- "a" + CR + "a"


The gosop family of tools normalizes LF into CR+LF, but leaves 
freestanding CR characters unchanged. So what actually gets hashed by 
gosop for these two signatures is the two following data:

- "a" + CR + LF + "a"
- "a" + CR + "a"


# Verifying the a+LF+a signature

$ echo -ne "a\na" | gosop verify alfa.sig cert
2026-10-25T15:34:14Z F89CD9D6B2AD806050B30D8788A5E974AE4551B9 
F89CD9D6B2AD806050B30D8788A5E974AE4551B9 mode:text

$ echo -ne "a\ra" | gosop verify alfa.sig cert
Code 3: No acceptable signatures found ("gosop verify")

$ echo -ne "a\r\na" | gosop verify alfa.sig cert
2026-10-25T15:34:14Z F89CD9D6B2AD806050B30D8788A5E974AE4551B9 
F89CD9D6B2AD806050B30D8788A5E974AE4551B9 mode:text


$ echo -ne "a\na" | sqop verify alfa.sig cert
2026-10-25T15:34:14Z F89CD9D6B2AD806050B30D8788A5E974AE4551B9 
F89CD9D6B2AD806050B30D8788A5E974AE4551B9 mode:text

$ echo -ne "a\ra" | sqop verify alfa.sig cert
2026-10-25T15:34:14Z F89CD9D6B2AD806050B30D8788A5E974AE4551B9 
F89CD9D6B2AD806050B30D8788A5E974AE4551B9 mode:text

$ echo -ne "a\r\na" | sqop verify alfa.sig cert
2026-10-25T15:34:14Z F89CD9D6B2AD806050B30D8788A5E974AE4551B9 
F89CD9D6B2AD806050B30D8788A5E974AE4551B9 mode:text


$ echo -ne "a\na" | gpg --verify alfa.sig -
gpg: Good signature from "alice" [unknown]

$ echo -ne "a\ra" | gpg --verify alfa.sig -
gpg: BAD signature from "alice" [unknown]

$ echo -ne "a\r\na" | gpg --verify alfa.sig -
gpg: Good signature from "alice" [unknown]


All three implementations hash "LF" as "CR+LF", for this case. 
Interoperability is no problem.

The alternate input "a\ra" is accepted as a valid variant by the sqop 
family of tools, which seems neither harmful nor useful.


# Verifying the a+CR+a signature

$ echo -ne "a\ra" | gosop verify acra.sig cert
2026-10-25T15:34:01Z F89CD9D6B2AD806050B30D8788A5E974AE4551B9 
F89CD9D6B2AD806050B30D8788A5E974AE4551B9 mode:text

$ echo -ne "a\na" | gosop verify acra.sig cert
Code 3: No acceptable signatures found ("gosop verify")

$ echo -ne "a\r\na" | gosop verify acra.sig cert
Code 3: No acceptable signatures found ("gosop verify")


$ echo -ne "a\ra" | sqop verify acra.sig cert
            No acceptable signatures found

$ echo -ne "a\na" | sqop verify acra.sig cert
            No acceptable signatures found

$ echo -ne "a\r\na" | sqop verify acra.sig cert
            No acceptable signatures found


$ echo -ne "a\ra" | gpg --verify acra.sig -
gpg: Good signature from "alice" [unknown]

$ echo -ne "a\na" | gpg --verify acra.sig -
gpg: BAD signature from "alice" [unknown]

$ echo -ne "a\r\na" | gpg --verify acra.sig -
gpg: BAD signature from "alice" [unknown]


These results show two schools of thought - not all implementations 
interoperate for this type of input:

The gosop family and GnuPG are aligned, both in the inputs they accept 
as valid, and the inputs they reject as invalid.

sqop rejects the signature that both gosop and GnuPG accept.
Conversely, if we used sqop to sign the same input ("a\ra"), the 
resulting signature would be rejected by both gosop and GnuPG.


# GnuPG doesn't hash trailing CR and zero-bytes

There is an additional confounding factor:

While GnuPG is mostly hashing the same data as the gosop family of 
tools, it exhibits a variation for one class of inputs. Trailing CR 
bytes in a payload (as well as trailing 0x00 bytes) appear to not get 
hashed by GnuPG:

$ echo -ne "a\ra\r" | gpg --verify acra.sig -
gpg: Good signature from "alice" [unknown]

$ echo -ne "a\ra\0" | gpg --verify acra.sig -
gpg: Good signature from "alice" [unknown]


This is odd, but seems benign when using GnuPG as the verifier. 
Additional variants of an input are accepted as valid, but only ones 
that vary by trailing CR or zero bytes.

When using GnuPG as signature issuer, this behavior is more problematic:
For the impacted inputs, GnuPG produces signatures that no other 
implementation will verify as correct.

E.g., when signing the input "a\ra\r" with GnuPG, what gets hashed into 
the signature is "a\ra".
No SOP implementation that I have tested verifies that signature as 
correct for the input "a\ra\r" - both the gosop and sqop families of 
tools reject such signatures.


I can't find any justification for this hashing behavior in the OpenPGP 
RFCs.
In addition, I have verified that the historical PGP-6.5.8 software does 
not exhibit this kind of hashing behavior either.

So I can find neither written guidance that motivates what GnuPG does 
here, nor historical precedent. I conclude that this behavior is 
erroneous (and imagine it might be unintentional).


In fact, GnuPG will ignore a mix of CR and trailing zero bytes when 
hashing, such as:

$ echo -ne "a\ra\0\r\0\r\0" | gpg --verify acra.sig -
gpg: Good signature from "alice" [unknown]


The input message variations that GnuPG hashes into the same message 
digest can differ by significantly more than one byte.

I've tested an altered input with ~10.000 additional trailing CR 
characters, which was verified as correctly signed.
However, in a test with an altered input with ~800.000 added trailing CR 
characters, GnuPG rejected the signature as invalid.

So it seems GnuPG ignores trailing CR and zero bytes up to some buffer 
length, when hashing.


# Historical discussions

While researching this topic, I looked for historical discussions on 
this list.
One thread from 2005 seems most relevant [3]. It touches on hashing for 
text mode signatures, even though its main focus are cleartext signed 
messages.


# Conclusion

I have observed three classes of behavior/interop characteristics:


1. The gosop family of tools

- Hashing matches one reasonable interpretation of the language in RFC 
9580 (and its predecessors).
- It is (mostly) aligned with GnuPG's behavior, so it is compatible with 
many historical artifacts.
- It doesn't implement GnuPG's handling of trailing 0x0d and 0x00 bytes. 
This seems sensible - I don't see how any guidance would motivate not 
hashing such data.

I think gosop-style behavior is well motivated by RFC language, and 
overall the least surprising. It matches the behavior described in [1].


2. GnuPG

Like the gosop family, GnuPG normalizes LF to CR+LF and leaves 
freestanding CR (mostly) unchanged. It mostly matches the behavior 
described in [1].

GnuPG and the gosop family interoperate for most inputs, including those 
that contain freestanding CR characters - as long as they don't occur at 
the very end of the data stream.

GnuPG's behavior of not hashing trailing 0x0d and 0x00 bytes is 
baffling, and leads to interop failure for some (unusual) inputs.
As I write above, I am aware of neither textual guidance nor historical 
justification for not hashing trailing CR or zero bytes.

I consider this behavior an error.


3. The sqop family of tools

- Hashing also matches one reasonable interpretation of the language in 
RFC 9580 (and its predecessors)
- However, it diverges from gosop and GnuPG's behavior for any artifact 
that contains CR characters outside of a CR+LF pair.
- As a side-note, the (even more historical) PGP-6.5.8 Linux binary 
performs normalization of both LF and CR to CR+LF, just like sqop. 
However, this precedent probably has limited practical relevance (the 
PGP.com software has not seen significant use in the last ~20 years, as 
far as I know).

sqop-style behavior is well motivated by RFC language, and internally 
consistent.
But it is in tension with existing artifacts, and for some types of 
input data it doesn't interop with the two other implementation families.


Besides documenting my findings, this mail is also a very long way to 
say that I agree with [1].

Apologies for the long mail. I hope it is of some interest to some readers.

Thanks,
Heiko


[1]: https://gitlab.com/andrewgdotcom/openpgp-messages/-/merge_requests/1
[2]: 
https://www.ietf.org/archive/id/draft-dkg-openpgp-stateless-cli-16.html#name-known-implementations 
(List of SOP implementations)
[3]: 
https://mailarchive.ietf.org/arch/msg/openpgp/Nu-kYPUgdVVLdXWBofoPs4DePd4/