Dwolla/fs2-pgp

Feature Request: Support decryption of recipient-less messages

Closed this issue · 5 comments

Thank you for this library!

I'm adding this issue in the hopes that I can submit a PR to address it, but the workflow I would like to have is this:

  1. You encrypt a message using gpg -R on the command line i.e.
❯ cat test.txt
foobarfoobarfoobar
❯ gpg -e -a -R 'Connor James Smith <cjs.connor.smith@gmail.com>' -o test3.txt.pgp test.txt
File 'test3.txt.pgp' exists. Overwrite? (y/N) y
❯ cat test3.txt.pgp
-----BEGIN PGP MESSAGE-----

hQIMAwAAAAAAAAAAAQ/9HgFExBedlOndKW8bOQ9m4uiNqJnm7UNNCe1IQpJoytHN
FYO6IFXbHCnQVS5EcYJVQO4n9tglqWAT8hwOjAGKwb1XxltY3y62dMRSi+CyPeR+
6dMw/taAHQmw+2xv25KRWEH3xPjTsvI3MVT92PANRsHLRs2L2TOIFUoOBwe7Pgzj
NafR2/R1sIpbDvDnvpJHdwLZrUiyjvug8jxs7Oto8TRv1u8tzgNg8Z6WD9myqM+N
HQWxvMOPJ/iSn5pelD7722eHeL/3JSwtJF30Te+H7809wPw9i9Xaeo5p3SHfLXdu
egr5xGrr5aoRHfQwkhbhAYOrX63N/FUsCqCfr2e2XcUKSpxwXLz+J9L+94Es02KX
tL+FZXDSB/sBV5qmanfVs+XGB+rZQ7VjS+PcxLc0I3EuLQr2hgJEsRd5DYNjArH1
2072S9eF1Kmq/uQE231KUSqv1UUDm3oX8Lg53Pt1f/F07go2QOFdmHjONSf5VUcb
2uAOyHS05iLnj/Uciqu1QQyvJz8YF5FCWlpCXZSsDfXqx7esEZWvztkAkwQa3E/n
VRc/UkwT31axtrYXp1KAbrx0Hg61Cqdejn5K2hnNz4JUlcNCwYRSPPbwmbKcYB/k
v8xeUKgx4DS/RzJFZUH/SIclCi1MvJo6ILiFKoVz2GU570b6R4XNWE9bREm7GvDS
TAFzoBx/OP/AICMaKbyOT0rHZmgvLbZsqo496q7dSsC3Cn6HvLG5xqvxt4afon+I
PUrwTuf/XJN2/lFEk1yTpk9ebFDDSECWDts6jtc=
=IeYl
-----END PGP MESSAGE-----
  1. Inside a unit test, be able to decrypt with hard-coded private key
test("decrypt works") {
  val input = "foobarfoobarfoobar"
  val encryptedInput: String = ??? //omitted
  val privateKeyString: String = ??? //omitted
  implicit val logger: Logger[IO] = Slf4jLogger.getLogger

  for {
    resultString <- PGPKeyAlg[IO].readPrivateKey(privateKeyString).attempt.flatMap {
      case Right(k) if k.getKeyID > 0 =>
        (for {
          crypto <- Stream.resource(CryptoAlg[IO])
          output <- Stream
            .emits(encryptedInput.getBytes())
            .through(crypto.decrypt(k))
            .through(utf8.decode)
            .attempt
        } yield output).compile.foldMonoid
      case Right(_) => new Throwable("shouldn't happen!").asLeft[String].pure[IO]
      case Left(e) => e.asLeft[String].pure[IO]
    }
  } yield {
    resultString match {
      case Right(s) => assertEquals(s, input)
      case Left(e) => fail(s"should never happen: ${e.getMessage}")
    }
  }
}

What is happening when I try this right now is this:

should never happen: Cannot decrypt message with key 0 because it requires key <key omitted>

Thanks again.

I think I was able to reproduce this with the private key that's part of the test suite of this library.

$ export GNUPGHOME="$(mktemp -d)/.gnupg"
$ mkdir -m 0700 -p "${GNUPGHOME}"
$ pbpaste | gpg --import
gpg: invalid armor header: lQVYBGDozbkBDACt63OSGFh4pVHSpVcPZXot2ZcHepkPXSJOFE+PnLOAvcMK8O9s\n
gpg: /var/folders/zl/t99c6ptn4p78vg6l6f24xwjc0000gq/T/tmp.b1L1ozoY/.gnupg/trustdb.gpg: trustdb created
gpg: key 52AFF6B5A43D6EBB: public key "key 1 <key1@dwolla.com>" imported
gpg: key 52AFF6B5A43D6EBB: secret key imported
gpg: Total number processed: 1
gpg:               imported: 1
gpg:       secret keys read: 1
gpg:   secret keys imported: 1
$ echo "Hello World" | gpg --encrypt --armor --hidden-recipient "key 1 <key1@dwolla.com>"
gpg: 54E4844CFE7C68E1: There is no assurance this key belongs to the named user

sub  rsa3072/54E4844CFE7C68E1 2021-07-09 key 1 <key1@dwolla.com>
 Primary key fingerprint: 7EEE F61D E397 A6B1 350B  6F93 52AF F6B5 A43D 6EBB
      Subkey fingerprint: C4BD 9D01 85E9 B333 4D8D  D55D 54E4 844C FE7C 68E1

It is NOT certain that the key belongs to the person named
in the user ID.  If you *really* know what you are doing,
you may answer the next question with yes.

Use this key anyway? (y/N) y
-----BEGIN PGP MESSAGE-----

hQGMAwAAAAAAAAAAAQv/QQ8RnRGF6jaPTUpuBoPollIBvPIzqokTGzuTaVD4bKsg
GGt4ooPpcTkxn0MRLs3rNJfZjaULSkWtUxc4NsSbqmrl8g3smnwJWk/UIR097zlC
s30/o3WlmSodAGbEuP5Y+mbAErwGbCs1e7cn1LqQO3BrSZ3m7djif9fiWRdb3AZ4
YPX5dmmOZLZoNQO5zLNu3iolrTXyimQLcS7VoFQ+Nbj9hOS+vDzcg6Kycaky7U+M
arfyyaqWan8hVygDthMT+n3Au0l7lBzN99aZmC13OP2fhuBBXvrGF+njFS+RkEOs
LToMlpFVYWlEFSnYlIQjsxKBzMKThNudKM7r4Kc1yw88DQ9C/rWZxmMxTyLAA4C7
QZKdO+zYfzSCYq3bO+YdN8vUGZPS63YN8Pp6qWvIXOZ3oecxmidqjGsItLpxJ0KK
zJ0IWVsQj1Zc/2zSojw8edcMh86PFbQsC4aPpMK54KiU4YKXcUnaDeQ48BGv27Po
Qx9PqZi+1ROo+anCVWrn0kcB/+g0TzSpG+nwMI4gxNTTAuybzEscK2ifkA76Df45
cSypFoj4OIRtTZ8iSGhfgt0fCn1qUrEs7Vw+iNYSqpl9/ue3u1icCQ==
=kHjl
-----END PGP MESSAGE-----

Then I added a test to CryptoAlgSpec:

test("CryptoAlg should decrypt a fixed value encrypted with our test key") {
  val crypto = resource()

  val message =
    """-----BEGIN PGP MESSAGE-----
      |
      |hQGMAwAAAAAAAAAAAQv/QQ8RnRGF6jaPTUpuBoPollIBvPIzqokTGzuTaVD4bKsg
      |GGt4ooPpcTkxn0MRLs3rNJfZjaULSkWtUxc4NsSbqmrl8g3smnwJWk/UIR097zlC
      |s30/o3WlmSodAGbEuP5Y+mbAErwGbCs1e7cn1LqQO3BrSZ3m7djif9fiWRdb3AZ4
      |YPX5dmmOZLZoNQO5zLNu3iolrTXyimQLcS7VoFQ+Nbj9hOS+vDzcg6Kycaky7U+M
      |arfyyaqWan8hVygDthMT+n3Au0l7lBzN99aZmC13OP2fhuBBXvrGF+njFS+RkEOs
      |LToMlpFVYWlEFSnYlIQjsxKBzMKThNudKM7r4Kc1yw88DQ9C/rWZxmMxTyLAA4C7
      |QZKdO+zYfzSCYq3bO+YdN8vUGZPS63YN8Pp6qWvIXOZ3oecxmidqjGsItLpxJ0KK
      |zJ0IWVsQj1Zc/2zSojw8edcMh86PFbQsC4aPpMK54KiU4YKXcUnaDeQ48BGv27Po
      |Qx9PqZi+1ROo+anCVWrn0kcB/+g0TzSpG+nwMI4gxNTTAuybzEscK2ifkA76Df45
      |cSypFoj4OIRtTZ8iSGhfgt0fCn1qUrEs7Vw+iNYSqpl9/ue3u1icCQ==
      |=kHjl
      |-----END PGP MESSAGE-----
      |""".stripMargin

  for {
    key <- PGPKeyAlg[IO].readSecretKeyCollection(TestKey())
    text <- Stream
      .emit(message)
      .through(text.utf8.encode)
      .through(crypto.decrypt(key))
      .through(text.utf8.decode)
      .compile
      .lastOrError
  } yield {
    assertEquals(text, "Hello World")
  }
}

which fails with

com.dwolla.security.crypto.KeyRingMissingKeyException: Cannot decrypt message with the passed keyring because it requires key 0, but the ring does not contain that key

If I change it to use PGPKeyAlg[IO].readPrivateKey(TestKey()) instead of readSecretKeyCollection, it still fails, but with

com.dwolla.security.crypto.KeyMismatchException: Cannot decrypt message with key 0 because it requires key 5958252092039458491

Does that seem like a correct reproduction, @CJSmith-0141?

Yes, exactly. Reading through RFC 4880 It's not clear to me if this is compliant with OpenPGP or is a GPG extension.

EDIT: What I mean by saying that is if you tag this "will-not-do" I would totally understand.

@CJSmith-0141 could you try the code in the hidden-recipients branch (what's in #77) and let me know if it works for you? If so, we should be able to merge it and cut a new release.

hello @bpholt sorry for the delay. I was able to build this branch, publishLocal and switch my project to use the snapshot version.

I have a unit test that is set up to fail in the case where this feature is implemented, and it failed. Which means the feature works!

test("FUTURE FEATURE: decrypt does not work with gpg encrypted message, anonymous recipient") {
    val input = "foobarfoobarfoobar\n"
    val encryptedInput = ??? //omitted for brevity

    (for {
      resultString <- PgpService.decryptString[IO](unitTestOnlyPrivateKeyString, encryptedInput)
    } yield resultString).unsafeRunSync() match {
        case Right(s) =>
          fail(
            s"Decryption with anonymous recipient worked, unexpectedly.\n" +
              s"Result String: ${s}\n" +
              s"input: ${input}"
          )
        case Left(e) =>
          assert(
            e.getMessage.contains(
              "Cannot decrypt message with the passed keyring because it requires key 0"
            )
          )
    }
  }

and the result:

[info] - FUTURE FEATURE: decrypt does not work with gpg encrypted message, anonymous recipient *** FAILED ***
[info]   Decryption with anonymous recipient worked, unexpectedly.
[info]   Result String: foobarfoobarfoobar
[info]
[info]   input: foobarfoobarfoobar (PgpServiceSpec.scala:560)

Thank you for implementing this!

Excellent! I'll get the PR merged and cut a release!