From 67da1e4922caad9f91c4d367921915df2739d9bb Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 5 Jul 2022 22:58:43 -0400 Subject: [PATCH] E2E example in PHP and Python --- crypto/crypto.go | 98 +++----------------- crypto/crypto_test.go | 42 ++------- examples/publish-php/publish-encrypted.php | 46 +++++++++ examples/publish-python/publish-encrypted.py | 42 +++++++++ examples/publish-python/requirements.txt | 2 + 5 files changed, 113 insertions(+), 117 deletions(-) create mode 100644 examples/publish-php/publish-encrypted.php create mode 100755 examples/publish-python/publish-encrypted.py create mode 100644 examples/publish-python/requirements.txt diff --git a/crypto/crypto.go b/crypto/crypto.go index febbf282..4ddcc2da 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -1,89 +1,25 @@ package crypto import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "errors" - "io" + "crypto/sha256" + "golang.org/x/crypto/pbkdf2" + "gopkg.in/square/go-jose.v2" ) -import "gopkg.in/square/go-jose.v2" const ( - versionByte = 0x31 // "1" - gcmTagSize = 16 - gcmNonceSize = 12 + jweEncryption = jose.A256GCM + jweAlgorithm = jose.DIRECT + keyLenBytes = 32 // 256-bit for AES-256 + keyDerivIter = 50000 ) -var ( - errCiphertextTooShort = errors.New("ciphertext too short") - errCiphertextUnexpectedVersion = errors.New("unsupported ciphertext version") -) +func DeriveKey(password string, topicURL string) []byte { + salt := sha256.Sum256([]byte(topicURL)) + return pbkdf2.Key([]byte(password), salt[:], keyDerivIter, keyLenBytes, sha256.New) +} -// Encrypt encrypts the given plaintext with the given key using AES-GCM, -// and encodes the (version, tag, nonce, ciphertext) set as base64. -// -// The output format is (|| means concatenate): -// "1" || tag (128 bits) || IV/nonce (96 bits) || ciphertext (remaining) -// -// This format is compatible with Pushbullet's encryption format. -// See https://docs.pushbullet.com/#encryption for details. func Encrypt(plaintext string, key []byte) (string, error) { - nonce := make([]byte, gcmNonceSize) // Never use more than 2^32 random nonces - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - return encryptWithNonce(plaintext, nonce, key) -} - -func encryptWithNonce(plaintext string, nonce, key []byte) (string, error) { - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - ciphertextWithTag := aesgcm.Seal(nil, nonce, []byte(plaintext), nil) - tagIndex := len(ciphertextWithTag) - gcmTagSize - ciphertext, tag := ciphertextWithTag[:tagIndex], ciphertextWithTag[tagIndex:] - output := appendSlices([]byte{versionByte}, tag, nonce, ciphertext) - return base64.StdEncoding.EncodeToString(output), nil -} - -// Decrypt decodes and decrypts a message that was encrypted with the Encrypt function. -func Decrypt(input string, key []byte) (string, error) { - inputBytes, err := base64.StdEncoding.DecodeString(input) - if err != nil { - return "", err - } - if len(inputBytes) < 1+gcmTagSize+gcmNonceSize { - return "", errCiphertextTooShort - } - version, tag, nonce, ciphertext := inputBytes[0], inputBytes[1:gcmTagSize+1], inputBytes[1+gcmTagSize:1+gcmTagSize+gcmNonceSize], inputBytes[1+gcmTagSize+gcmNonceSize:] - if version != versionByte { - return "", errCiphertextUnexpectedVersion - } - cipherTextWithTag := append(ciphertext, tag...) - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - plaintext, err := aesgcm.Open(nil, nonce, cipherTextWithTag, nil) - if err != nil { - return "", err - } - return string(plaintext), nil -} - -func EncryptJWE(plaintext string, key []byte) (string, error) { - enc, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{Algorithm: jose.DIRECT, Key: key}, nil) + enc, err := jose.NewEncrypter(jweEncryption, jose.Recipient{Algorithm: jweAlgorithm, Key: key}, nil) if err != nil { return "", err } @@ -94,7 +30,7 @@ func EncryptJWE(plaintext string, key []byte) (string, error) { return jwe.CompactSerialize() } -func DecryptJWE(input string, key []byte) (string, error) { +func Decrypt(input string, key []byte) (string, error) { jwe, err := jose.ParseEncrypted(input) if err != nil { return "", err @@ -105,11 +41,3 @@ func DecryptJWE(input string, key []byte) (string, error) { } return string(out), nil } - -func appendSlices(s ...[]byte) []byte { - var output []byte - for _, r := range s { - output = append(output, r...) - } - return output -} diff --git a/crypto/crypto_test.go b/crypto/crypto_test.go index 4d5fb342..79a51756 100644 --- a/crypto/crypto_test.go +++ b/crypto/crypto_test.go @@ -1,10 +1,7 @@ package crypto import ( - "encoding/base64" - "encoding/hex" "github.com/stretchr/testify/require" - "log" "testing" ) @@ -14,40 +11,21 @@ func TestEncryptDecrypt(t *testing.T) { require.Nil(t, err) plaintext, err := Decrypt(ciphertext, []byte("AES256Key-32Characters1234567890")) require.Nil(t, err) - log.Println(ciphertext) require.Equal(t, message, plaintext) } -func TestEncryptDecryptJWE(t *testing.T) { - message := "this is a message or is it?" - ciphertext, err := EncryptJWE(message, []byte("AES256Key-32Characters1234567890")) +func TestEncryptDecrypt_FromPHP(t *testing.T) { + ciphertext := "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..vbe1Qv_-mKYbUgce.EfmOUIUi7lxXZG_o4bqXZ9pmpr1Rzs4Y5QLE2XD2_aw_SQ.y2hadrN5b2LEw7_PJHhbcA" + key := DeriveKey("secr3t password", "https://ntfy.sh/mysecret") + plaintext, err := Decrypt(ciphertext, key) require.Nil(t, err) - plaintext, err := DecryptJWE(ciphertext, []byte("AES256Key-32Characters1234567890")) - require.Nil(t, err) - log.Println(ciphertext) - require.Equal(t, message, plaintext) + require.Equal(t, `{"message":"Secret!","priority":5}`, plaintext) } -func TestEncryptExpectedOutputxxxxx(t *testing.T) { - // These values are taken from https://docs.pushbullet.com/#encryption - // The following expected ciphertext from the site was used as a baseline: - // MQS2K9l3G8YoLccJooY64kDeWjbkI3fAx4WcrYNtbz4p8Q== - // 31 04b62bd9771bc6282dc709a2863ae240 de5a36e42377c0c7859cad83 6d6f3e29f1 - // v tag nonce ciphertext - message := "meow!" - key, _ := base64.StdEncoding.DecodeString("1sW28zp7CWv5TtGjlQpDHHG4Cbr9v36fG5o4f74LsKg=") - nonce, _ := hex.DecodeString("de5a36e42377c0c7859cad83") - ciphertext, err := encryptWithNonce(message, nonce, key) +func TestEncryptDecrypt_FromPython(t *testing.T) { + ciphertext := "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q" + key := DeriveKey("secr3t password", "https://ntfy.sh/mysecret") + plaintext, err := Decrypt(ciphertext, key) require.Nil(t, err) - require.Equal(t, "MQS2K9l3G8YoLccJooY64kDeWjbkI3fAx4WcrYNtbz4p8Q==", ciphertext) -} - -func TestEncryptExpectedOutput(t *testing.T) { - // These values are taken from https://docs.pushbullet.com/#encryption, meaning that - // all of this is compatible with how Pushbullet encrypts - encryptedMessage := "MSfJxxY5YdjttlfUkCaKA57qU9SuCN8+ZhYg/xieI+lDnQ==" - key, _ := base64.StdEncoding.DecodeString("1sW28zp7CWv5TtGjlQpDHHG4Cbr9v36fG5o4f74LsKg=") - plaintext, err := Decrypt(encryptedMessage, key) - require.Nil(t, err) - require.Equal(t, "meow!", plaintext) + require.Equal(t, `{"message":"Python says hi","tags":["secret"]}`, plaintext) } diff --git a/examples/publish-php/publish-encrypted.php b/examples/publish-php/publish-encrypted.php new file mode 100644 index 00000000..3dc5b453 --- /dev/null +++ b/examples/publish-php/publish-encrypted.php @@ -0,0 +1,46 @@ + "Secret!", + "priority" => 5 +]; +$plaintext = json_encode($message); +$key = deriveKey("secr3t password", "https://ntfy.sh/mysecret"); +$ciphertext = encrypt($plaintext, $key); + +file_get_contents('https://ntfy.sh/mysecret', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + "Content-Type: text/plain\r\n" . + "Encryption: jwe", + 'content' => $ciphertext + ] +])); + +function deriveKey($password, $topicUrl) +{ + $salt = hex2bin(hash("sha256", $topicUrl)); + return openssl_pbkdf2($password, $salt, 32, 50000, "sha256"); +} + +function encrypt(string $plaintext, string $key): string +{ + $encodedHeader = base64url_encode(json_encode(["alg" => "dir", "enc" => "A256GCM"])); + $iv = openssl_random_pseudo_bytes(12); // GCM is used with a 96-bit IV + $aad = $encodedHeader; + $tag = null; + $content = openssl_encrypt($plaintext, "aes-256-gcm", $key, OPENSSL_RAW_DATA, $iv, $tag, $aad); + return + $encodedHeader . "." . + "." . // No content encryption key (CEK) in "dir" mode + base64url_encode($iv) . "." . + base64url_encode($content) . "." . + base64url_encode($tag); +} + +function base64url_encode($input) +{ + return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); +} + diff --git a/examples/publish-python/publish-encrypted.py b/examples/publish-python/publish-encrypted.py new file mode 100755 index 00000000..8869e9d1 --- /dev/null +++ b/examples/publish-python/publish-encrypted.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +import requests + +import json +from base64 import b64encode, urlsafe_b64encode, b64decode +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Protocol.KDF import PBKDF2 +from Crypto.Hash import SHA256 +from Crypto.Random import get_random_bytes + +def derive_key(password, topic_url): + salt = SHA256.new(data=topic_url.encode('utf-8')).digest() + return PBKDF2(password, salt, 32, count=50000, hmac_hash_module=SHA256) + +def encrypt(plaintext, key): + encoded_header = b64urlencode('{"alg":"dir","enc":"A256GCM"}'.encode('utf-8')) + iv = get_random_bytes(12) # GCM is used with a 96-bit IV + aad = encoded_header + cipher = AES.new(key, AES.MODE_GCM, nonce=iv) + cipher.update(aad.encode('utf-8')) + ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8')) + return "{header}..{iv}.{ciphertext}.{tag}".format( + header = encoded_header, + iv = b64urlencode(iv), + ciphertext = b64urlencode(ciphertext), + tag = b64urlencode(tag) + ) + +def b64urlencode(b): + return urlsafe_b64encode(b).decode('utf-8').replace("=", "") + +key = derive_key("secr3t password", "https://ntfy.sh/mysecret") +ciphertext = encrypt('{"message":"Python says hi","tags":["secret"]}', key) + +resp = requests.post("https://ntfy.sh/mysecret", + data=ciphertext, + headers={ + "Encryption": "jwe" + }) +resp.raise_for_status() diff --git a/examples/publish-python/requirements.txt b/examples/publish-python/requirements.txt new file mode 100644 index 00000000..ded8e007 --- /dev/null +++ b/examples/publish-python/requirements.txt @@ -0,0 +1,2 @@ +requests +pycryptodome