Skip to content

Instantly share code, notes, and snippets.

@pbnjay
Last active December 20, 2024 04:34
Show Gist options
  • Save pbnjay/5f31d67f5b4041c1769d6ae9850d56bd to your computer and use it in GitHub Desktop.
Save pbnjay/5f31d67f5b4041c1769d6ae9850d56bd to your computer and use it in GitHub Desktop.
Simple, but reasonably secure approach to storing secrets within an OpenAPI document using AES-256 GCM for en/decryption, and PBKDF2 to derive a key from a supplied passphrase.
package secure_strings
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/pbkdf2"
)
func encrypt(plaintext, key, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ciphertext := aesgcm.Seal(nil, iv, plaintext, nil)
return ciphertext, nil
}
func decrypt(ciphertext, key, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plaintext, err := aesgcm.Open(nil, iv, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
func IsEncrypted(val string) bool {
return strings.HasPrefix(val, "$ag2$v=6a$iv=")
}
func DecryptValue(passphrase, encrypted string) (string, error) {
parts := strings.Split(encrypted, "$")
if len(parts) != 6 || parts[0] != "" || parts[1] != "ag2" || parts[2] != "v=6a" || !strings.HasPrefix(parts[3], "iv=") {
return "", fmt.Errorf("invalid value")
}
iv, err := base64.StdEncoding.DecodeString(parts[3][3:])
if err != nil {
return "", fmt.Errorf("invalid iv")
}
salt, err := base64.StdEncoding.DecodeString(parts[4])
if err != nil {
return "", fmt.Errorf("invalid salt")
}
ciphertext, err := base64.StdEncoding.DecodeString(parts[5])
if err != nil {
return "", fmt.Errorf("invalid ciphertext")
}
key := pbkdf2.Key([]byte(passphrase), salt, 600000, 32, sha256.New)
result, err := decrypt(ciphertext, key, iv)
if err != nil {
return "", fmt.Errorf("decryption failed: %e", err)
}
return string(result), nil
}
func EncryptValue(passphrase, plaintext string) (string, error) {
salt := make([]byte, 18)
_, err := rand.Read(salt)
if err != nil {
return "", err
}
iv := make([]byte, 12)
_, err = rand.Read(iv)
if err != nil {
return "", err
}
key := pbkdf2.Key([]byte(passphrase), salt, 600000, 32, sha256.New)
result, err := encrypt([]byte(plaintext), key, iv)
if err != nil {
return "", err
}
return ("$ag2$v=6a$iv=" +
base64.StdEncoding.EncodeToString(iv) +
"$" +
base64.StdEncoding.EncodeToString(salt) +
"$" +
base64.StdEncoding.EncodeToString(result)), nil
}
# pip3 install pycryptodome
import base64
import os
from Crypto.Cipher import AES
from Crypto.Hash import SHA256
from Crypto.Protocol.KDF import PBKDF2
def encrypt(plaintext, key, iv):
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8'))
return ciphertext + tag
def decrypt(ciphertext, key, iv):
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
actual_ciphertext, tag = ciphertext[:-16], ciphertext[-16:]
plaintext = cipher.decrypt_and_verify(actual_ciphertext, tag).decode('utf-8')
return plaintext
def is_encrypted(val):
return val.startswith("$ag2$v=6a$iv=")
def decrypt_value(passphrase, encrypted):
parts = encrypted.split("$")
if len(parts) != 6 or parts[0] != "" or parts[1] != "ag2" or parts[2] != "v=6a" or not parts[3].startswith("iv="):
raise ValueError("Invalid value")
iv = base64.b64decode(parts[3][3:])
salt = base64.b64decode(parts[4])
ciphertext = base64.b64decode(parts[5])
key = PBKDF2(passphrase.encode('utf-8'), salt, dkLen=32, count=600000, hmac_hash_module=SHA256)
try:
result = decrypt(ciphertext, key, iv)
except Exception as e:
raise ValueError(f"Decryption failed: {e}")
return result
def encrypt_value(passphrase, plaintext):
salt = os.urandom(18)
iv = os.urandom(12)
key = PBKDF2(passphrase.encode('utf-8'), salt, dkLen=32, count=600000, hmac_hash_module=SHA256)
ciphertext = encrypt(plaintext, key, iv)
return (
"$ag2$v=6a$iv=" +
base64.b64encode(iv).decode('utf-8') + "$" +
base64.b64encode(salt).decode('utf-8') + "$" +
base64.b64encode(ciphertext).decode('utf-8')
)
import CryptoJS from "crypto-js"
/** Returns a crypto-JS compatible Word Array, based on the byte array provided */
const toWordArray = (bytes: Uint8Array) => {
const words: number[] = []
for (let j = 0; j < bytes.length; j++) {
words[j >>> 2] |= bytes[j] << (24 - 8 * (j % 4))
}
return CryptoJS.lib.WordArray.create(words, bytes.length)
}
/** Returns a byte array, based on the crypto-JS compatible word array provided */
const fromWordArray = (wordArray: CryptoJS.lib.WordArray): Uint8Array => {
const bytes = new Uint8Array(wordArray.sigBytes)
for (let j = 0; j < wordArray.sigBytes; j++) {
bytes[j] = (wordArray.words[j >>> 2] >>> (24 - 8 * (j % 4))) & 0xff
}
return bytes
}
export const md5 = (s: string): string => {
return CryptoJS.MD5(s).toString()
}
export const base64encode = (s: string | ArrayBuffer): string => {
if (typeof s === "string") {
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(s))
} else {
return CryptoJS.enc.Base64.stringify(toWordArray(new Uint8Array(s)))
}
}
export const base64decodeString = (s: string): string => {
return CryptoJS.enc.Utf8.stringify(CryptoJS.enc.Base64.parse(s))
}
export const base64decodeArray = (s: string): Uint8Array => {
const res = CryptoJS.enc.Base64.parse(s)
return fromWordArray(res)
}
////////////////////////////////////
const getKeyFromPassphrase = async (passphrase: string, salt: Uint8Array) => {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(passphrase),
"PBKDF2",
false,
["deriveBits", "deriveKey"]
)
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 600000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
)
return key
}
export const encryptString = async (
value: string,
passphrase: string
): Promise<string> => {
const salt = crypto.getRandomValues(new Uint8Array(18))
const key = await getKeyFromPassphrase(passphrase, salt)
const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
tagLength: 128,
},
key,
new TextEncoder().encode(value)
)
// Trying to at least look like a PHC crypt format
// ag2 = AES-GCM 256bit
// v=6a -- this combo of passphrase => PBKDF2 key derivation
// iv = AES init vector
return (
"$ag2$v=6a$iv=" +
base64encode(iv.buffer) +
"$" +
base64encode(salt.buffer) +
"$" +
base64encode(ciphertext)
)
}
export const decryptString = async (
value: string,
passphrase: string
): Promise<string> => {
if (!value.startsWith("$ag2$v=6a$iv=")) {
throw "unknown encryption prefix"
}
const parts = value.split("$")
const iv = base64decodeArray(parts[3].slice(3))
const salt = base64decodeArray(parts[4])
const ciphertext = base64decodeArray(parts[5])
const key = await getKeyFromPassphrase(passphrase, salt)
const plaintext = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
tagLength: 128,
},
key,
ciphertext.buffer
)
return new TextDecoder().decode(plaintext)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment