E2E example in PHP and Python

This commit is contained in:
Philipp Heckel 2022-07-05 22:58:43 -04:00
parent 99e6c0ff97
commit 67da1e4922
5 changed files with 113 additions and 117 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -0,0 +1,46 @@
<?php
$message = [
"message" => "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), '+/', '-_'));
}

View File

@ -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()

View File

@ -0,0 +1,2 @@
requests
pycryptodome