Why does this Java NaCl encryption not work with GitHub Actions Secrets

Viewed 192

I'm attempting to write a Java app that creates secrets in a GitHub repo to be consumed by GitHub Actions. There are many SodiumLib wrappers available, but they generally wrap up the native C library. I was looking for a pure Java implementation.

https://github.com/NeilMadden/salty-coffee appears to be what I need, and it does appear that the library will create encrypted string. The Groovy script below takes a key and input value, and generates an encrypted value:

@Grab(group='software.pando.crypto', module='salty-coffee', version='1.0.4')
@Grab(group='org.apache.commons', module='commons-lang3', version='3.12.0')
@Grab(group='commons-codec', module='commons-codec', version='1.15')

import software.pando.crypto.nacl.*
import org.apache.commons.lang3.*
import java.util.*
import org.apache.commons.codec.binary.Base64
import java.security.*
import java.nio.charset.*

def base64 = new Base64(3)

def key = base64.decode(args[0])
def value = StringUtils.defaultIfEmpty(args[1], "")

println "Encrypting " + value

def keyPair = CryptoBox.keyPair();
def githubPublicKey = CryptoBox.publicKey(key)

def box = CryptoBox.encrypt(keyPair.getPrivate(), githubPublicKey, value)
    
def out = new ByteArrayOutputStream()
box.writeTo(out);
out.flush();

def encryptedValue = new String(base64.encode(out.toByteArray()))

println encryptedValue

For example:

groovy encrypt.groovy 2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU= test

The problem is that the generated value is ignored when it is used to create a new GitHub API secret. You attempt to create the secret, and the HTTP requests works ok, but the secret is empty when you use it in a workflow.

Secrets created from this Python script that generates encrypted values works fine however, so I know I am making the correct HTTP calls and using the correct keys to generate a GitHub secret:

from base64 import b64encode
from nacl import encoding, public
import sys

def encrypt(public_key: str, secret_value: str) -> str:
  """Encrypt a Unicode string using the public key."""
  public_key = public.PublicKey(public_key.encode("utf-8"), encoding.Base64Encoder())
  sealed_box = public.SealedBox(public_key)
  encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
  return b64encode(encrypted).decode("utf-8")
  
print(encrypt(sys.argv[1], sys.argv[2]))

What am I doing wrong with the Java (or Groovy) example?

1 Answers

The Python code uses sealed boxes, the Java/Groovy code does not, so the two are not compatible.

Since the generated ciphertexts are not deterministic, a direct comparison is not possible. A reasonable test is to decrypt the ciphertexts of both codes using the same code.
The following Python code uses the posted code for encryption and then performs decryption with the supplemented code. This code will be used to test the Java code later:

from base64 import b64encode, b64decode
from nacl import encoding, public

def encrypt(public_key: str, secret_value: str) -> str:
  """Encrypt a Unicode string using the public key."""
  public_key = public.PublicKey(public_key.encode("utf-8"), encoding.Base64Encoder())
  sealed_box = public.SealedBox(public_key)
  encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
  return b64encode(encrypted).decode("utf-8")

pkB64 = 'xBC9lTyWdE/6EObv5NjryMbIvrviOzzPA+5XyM0QcHE='
skB64 = '0b9867Pq6sEdnxYM1ZscOhiMpruKn1Xg3xxB+wUF5eI='

# Encryption
encrypted = encrypt(pkB64, 'test')

# Decryption
secret_key = public.PrivateKey(skB64.encode("utf-8"), encoding.Base64Encoder())
unseal_box = public.SealedBox(secret_key)
plaintext = unseal_box.decrypt(b64decode(encrypted))
print(plaintext.decode('utf-8')) # test

Sealed boxes don't seem to be supported by salty-coffee (at least I haven't found a way). Therefore, and because I don't know of any pure Java library that supports sealed boxes, I use lazysodium (which is also a wrapper over the Libsodium library, though) to demonstrate the migration. For other libraries (even pure Java libraries, if there are any), this should be largely analogous:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HexFormat;

import com.goterl.lazysodium.LazySodiumJava;
import com.goterl.lazysodium.SodiumJava;
import com.goterl.lazysodium.utils.Key;
import com.goterl.lazysodium.utils.KeyPair;

....

SodiumJava sodium = new SodiumJava();
LazySodiumJava lazySodium = new LazySodiumJava(sodium, StandardCharsets.UTF_8);

Key secretKey = Key.fromBase64String("0b9867Pq6sEdnxYM1ZscOhiMpruKn1Xg3xxB+wUF5eI=");
Key publicKey = Key.fromBase64String("xBC9lTyWdE/6EObv5NjryMbIvrviOzzPA+5XyM0QcHE=");

// Encryption
KeyPair keyPair = new KeyPair(publicKey, secretKey);
String ciphertext = lazySodium.cryptoBoxSealEasy("test", publicKey);
System.out.println(Base64.getEncoder().encodeToString(HexFormat.of().parseHex(ciphertext)));

// Decryption
String decrypted = lazySodium.cryptoBoxSealOpenEasy(ciphertext, keyPair);
System.out.println(decrypted);

If a ciphertext generated with this code is used as ciphertext in the Python code, it can be successfully decrypted, which shows that the encryption of both codes is functionally identical.


Edit:

As an alternative to another library, salty-coffee can be extended to support sealed boxes.

If the sender uses a sealed box, basically the following happens:

  1. first, an ephemeral key pair is generated: ephemSK, ephemPK.
  2. let PK be the public key of the receiver. A 24 bytes nonce is determined as follows: nonce = Blake2b(ephemPK || PK)
  3. an encryption is performed with CryptoBox using ephemSK as secret key, PK as public key and the previously generated nonce.
  4. CryptoBox returns the concatenation of ciphertext and the 16 bytes MAC. ephemPK is prepended to the ciphertext. The concatenation of these 3 parts is the result of the sealed box.

salty-coffee provides all Libsodium functionalities needed for the implementation except Blake2b. For this you can use e.g. Bouncycastle.

A possible implmentation is:

import software.pando.crypto.nacl.*;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.util.Base64;
import org.bouncycastle.crypto.digests.Blake2bDigest;

...

byte[] plaintext = "The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8);
  
// Sender's secret key SK, receiver's public key PK 
byte[] SK = Base64.getDecoder().decode("0b9867Pq6sEdnxYM1ZscOhiMpruKn1Xg3xxB+wUF5eI=");
byte[] PK = Base64.getDecoder().decode("xBC9lTyWdE/6EObv5NjryMbIvrviOzzPA+5XyM0QcHE="); 

// Create an ephemeral keypair: ephemSK, ephemPK
KeyPair ephemKeyPair = CryptoBox.keyPair();
byte[] ephemSK_pkcs8 = ephemKeyPair.getPrivate().getEncoded();
byte[] ephemPK_x509 = ephemKeyPair.getPublic().getEncoded();
byte[] ephemSK = getRawKey(ephemSK_pkcs8);
byte[] ephemPK = getRawKey(ephemPK_x509);

// Create the nonce = Blake2b(ephemeralPK || PK))
byte[] noncematerial = new byte[64];
System.arraycopy(ephemPK, 0, noncematerial, 0, ephemPK.length);
System.arraycopy(PK, 0, noncematerial, ephemPK.length, PK.length);  
byte[] nonce = new byte[24];
Blake2bDigest dig = new Blake2bDigest(null, nonce.length, null, null);
dig.update(noncematerial, 0, noncematerial.length);
dig.doFinal(nonce, 0);

// Encrypt with CryptoBox using ephemSK, PK and the nonce
CryptoBox cryptobox = CryptoBox.encrypt(CryptoBox.privateKey(ephemSK), CryptoBox.publicKey(PK), nonce, plaintext);
byte[] ciphertextMAC = cryptobox.getCiphertextWithTag();

// Prepend ephemPK
byte[] secretBoxSealed = new byte[ephemPK.length + ciphertextMAC.length];
System.arraycopy(ephemPK, 0, secretBoxSealed, 0, ephemPK.length);
System.arraycopy(ciphertextMAC, 0, secretBoxSealed, ephemPK.length, ciphertextMAC.length);
String secretBoxSealedB64 = Base64.getEncoder().encodeToString(secretBoxSealed);
System.out.println(secretBoxSealedB64); 

with:

// The raw keys are the last 32 bytes in PKCS#8 and X.509 formatted keys respectively.
private static byte[] getRawKey(byte[] key) {
    byte[] result = new byte[32];
    System.arraycopy(key, key.length - result.length, result, 0, result.length);
    return result;
}

A ciphertext created with this code can be successfully decrypted by the Python code above, proving the compatability.

Related