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
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
"crypto/sha256"
|
||||||
"crypto/cipher"
|
"golang.org/x/crypto/pbkdf2"
|
||||||
"crypto/rand"
|
"gopkg.in/square/go-jose.v2"
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
)
|
)
|
||||||
import "gopkg.in/square/go-jose.v2"
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
versionByte = 0x31 // "1"
|
jweEncryption = jose.A256GCM
|
||||||
gcmTagSize = 16
|
jweAlgorithm = jose.DIRECT
|
||||||
gcmNonceSize = 12
|
keyLenBytes = 32 // 256-bit for AES-256
|
||||||
|
keyDerivIter = 50000
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
func DeriveKey(password string, topicURL string) []byte {
|
||||||
errCiphertextTooShort = errors.New("ciphertext too short")
|
salt := sha256.Sum256([]byte(topicURL))
|
||||||
errCiphertextUnexpectedVersion = errors.New("unsupported ciphertext version")
|
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) {
|
func Encrypt(plaintext string, key []byte) (string, error) {
|
||||||
nonce := make([]byte, gcmNonceSize) // Never use more than 2^32 random nonces
|
enc, err := jose.NewEncrypter(jweEncryption, jose.Recipient{Algorithm: jweAlgorithm, Key: key}, nil)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
@ -94,7 +30,7 @@ func EncryptJWE(plaintext string, key []byte) (string, error) {
|
||||||
return jwe.CompactSerialize()
|
return jwe.CompactSerialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
func DecryptJWE(input string, key []byte) (string, error) {
|
func Decrypt(input string, key []byte) (string, error) {
|
||||||
jwe, err := jose.ParseEncrypted(input)
|
jwe, err := jose.ParseEncrypted(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
@ -105,11 +41,3 @@ func DecryptJWE(input string, key []byte) (string, error) {
|
||||||
}
|
}
|
||||||
return string(out), nil
|
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
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"log"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,40 +11,21 @@ func TestEncryptDecrypt(t *testing.T) {
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
plaintext, err := Decrypt(ciphertext, []byte("AES256Key-32Characters1234567890"))
|
plaintext, err := Decrypt(ciphertext, []byte("AES256Key-32Characters1234567890"))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
log.Println(ciphertext)
|
|
||||||
require.Equal(t, message, plaintext)
|
require.Equal(t, message, plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncryptDecryptJWE(t *testing.T) {
|
func TestEncryptDecrypt_FromPHP(t *testing.T) {
|
||||||
message := "this is a message or is it?"
|
ciphertext := "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..vbe1Qv_-mKYbUgce.EfmOUIUi7lxXZG_o4bqXZ9pmpr1Rzs4Y5QLE2XD2_aw_SQ.y2hadrN5b2LEw7_PJHhbcA"
|
||||||
ciphertext, err := EncryptJWE(message, []byte("AES256Key-32Characters1234567890"))
|
key := DeriveKey("secr3t password", "https://ntfy.sh/mysecret")
|
||||||
|
plaintext, err := Decrypt(ciphertext, key)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
plaintext, err := DecryptJWE(ciphertext, []byte("AES256Key-32Characters1234567890"))
|
require.Equal(t, `{"message":"Secret!","priority":5}`, plaintext)
|
||||||
require.Nil(t, err)
|
|
||||||
log.Println(ciphertext)
|
|
||||||
require.Equal(t, message, plaintext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncryptExpectedOutputxxxxx(t *testing.T) {
|
func TestEncryptDecrypt_FromPython(t *testing.T) {
|
||||||
// These values are taken from https://docs.pushbullet.com/#encryption
|
ciphertext := "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q"
|
||||||
// The following expected ciphertext from the site was used as a baseline:
|
key := DeriveKey("secr3t password", "https://ntfy.sh/mysecret")
|
||||||
// MQS2K9l3G8YoLccJooY64kDeWjbkI3fAx4WcrYNtbz4p8Q==
|
plaintext, err := Decrypt(ciphertext, key)
|
||||||
// 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)
|
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "MQS2K9l3G8YoLccJooY64kDeWjbkI3fAx4WcrYNtbz4p8Q==", ciphertext)
|
require.Equal(t, `{"message":"Python says hi","tags":["secret"]}`, 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)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "meow!", 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