Browse Source

E2E example in PHP and Python

Philipp Heckel 2 years ago
parent
commit
67da1e4922

+ 13 - 85
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")
-)
-
-// 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
+func DeriveKey(password string, topicURL string) []byte {
+	salt := sha256.Sum256([]byte(topicURL))
+	return pbkdf2.Key([]byte(password), salt[:], keyDerivIter, keyLenBytes, sha256.New)
 }
 
-// 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)
+func Encrypt(plaintext string, key []byte) (string, error) {
+	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
-}

+ 10 - 32
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"))
-	require.Nil(t, err)
-	plaintext, err := DecryptJWE(ciphertext, []byte("AES256Key-32Characters1234567890"))
-	require.Nil(t, err)
-	log.Println(ciphertext)
-	require.Equal(t, message, 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_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)
-	require.Equal(t, "MQS2K9l3G8YoLccJooY64kDeWjbkI3fAx4WcrYNtbz4p8Q==", ciphertext)
+	require.Equal(t, `{"message":"Secret!","priority":5}`, plaintext)
 }
 
-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)
+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, "meow!", plaintext)
+	require.Equal(t, `{"message":"Python says hi","tags":["secret"]}`, plaintext)
 }

+ 46 - 0
examples/publish-php/publish-encrypted.php

@@ -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), '+/', '-_'));
+}
+

+ 42 - 0
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()

+ 2 - 0
examples/publish-python/requirements.txt

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