mirror of
https://github.com/binwiederhier/ntfy.git
synced 2024-09-28 19:31:59 +02:00
E2E example in PHP and Python
This commit is contained in:
parent
99e6c0ff97
commit
67da1e4922
5 changed files with 113 additions and 117 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
46
examples/publish-php/publish-encrypted.php
Normal file
46
examples/publish-php/publish-encrypted.php
Normal 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), '+/', '-_'));
|
||||
}
|
||||
|
42
examples/publish-python/publish-encrypted.py
Executable file
42
examples/publish-python/publish-encrypted.py
Executable 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()
|
2
examples/publish-python/requirements.txt
Normal file
2
examples/publish-python/requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
requests
|
||||
pycryptodome
|
Loading…
Reference in a new issue