mirror of
https://github.com/viktoruj/obsidian-cloud-kms.git
synced 2026-07-22 06:56:21 +00:00
262 lines
7.9 KiB
Bash
262 lines
7.9 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# ocke-decrypt.sh — Decrypt OCKE encrypted blocks/files using AWS CLI + Python
|
|
#
|
|
# Usage:
|
|
# ./ocke-decrypt.sh <file> # Decrypt and print to stdout
|
|
# ./ocke-decrypt.sh <file> -o <output> # Decrypt and write to file
|
|
#
|
|
# Requirements:
|
|
# - aws cli (configured with credentials that have kms:Decrypt)
|
|
# - python3 (for AES-256-GCM decryption and binary parsing)
|
|
#
|
|
# This script works WITHOUT Node.js or Obsidian.
|
|
# Useful for: disaster recovery, CI/CD pipelines, backup verification.
|
|
|
|
set -euo pipefail
|
|
|
|
if [ $# -lt 1 ]; then
|
|
echo "Usage: $0 <file> [-o output_file] [--vault-name NAME] [--file-path PATH]"
|
|
echo ""
|
|
echo "Decrypts OCKE encrypted content (markdown blocks or binary files)."
|
|
echo ""
|
|
echo "Options:"
|
|
echo " -o <file> Write output to file (default: stdout)"
|
|
echo " --vault-name NAME Obsidian vault name (folder name)"
|
|
echo " --file-path PATH Vault-relative file path used during encryption"
|
|
echo ""
|
|
echo "Environment (alternative to flags):"
|
|
echo " OCKE_VAULT_NAME Vault name for encryption context"
|
|
echo " OCKE_FILE_PATH File path for encryption context"
|
|
echo ""
|
|
echo "Requires: aws cli, python3, pip install cryptography"
|
|
exit 1
|
|
fi
|
|
|
|
INPUT_FILE="$1"
|
|
shift
|
|
|
|
OUTPUT_FILE=""
|
|
VAULT_NAME="${OCKE_VAULT_NAME:-}"
|
|
FILE_PATH="${OCKE_FILE_PATH:-}"
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-o) OUTPUT_FILE="$2"; shift 2 ;;
|
|
--vault-name) VAULT_NAME="$2"; shift 2 ;;
|
|
--file-path) FILE_PATH="$2"; shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
export OCKE_VAULT_NAME="$VAULT_NAME"
|
|
export OCKE_FILE_PATH="$FILE_PATH"
|
|
|
|
if [ ! -f "$INPUT_FILE" ]; then
|
|
echo "Error: File not found: $INPUT_FILE" >&2
|
|
exit 1
|
|
fi
|
|
|
|
python3 - "$INPUT_FILE" "$OUTPUT_FILE" << 'PYTHON_SCRIPT'
|
|
import sys
|
|
import os
|
|
import struct
|
|
import base64
|
|
import json
|
|
import subprocess
|
|
import re
|
|
from pathlib import Path
|
|
|
|
def aws_kms_decrypt(ciphertext_blob, encryption_context, region):
|
|
"""Call aws kms decrypt and return plaintext bytes."""
|
|
import tempfile
|
|
|
|
# Write ciphertext to temp file
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.bin') as f:
|
|
f.write(ciphertext_blob)
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
ctx_str = ','.join(f'{k}={v}' for k, v in encryption_context.items())
|
|
cmd = [
|
|
'aws', 'kms', 'decrypt',
|
|
'--ciphertext-blob', f'fileb://{tmp_path}',
|
|
'--encryption-context', ctx_str,
|
|
'--region', region,
|
|
'--output', 'json'
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
response = json.loads(result.stdout)
|
|
plaintext_b64 = response['Plaintext']
|
|
return base64.b64decode(plaintext_b64)
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
def aes_gcm_decrypt(key, nonce, ciphertext, auth_tag):
|
|
"""Decrypt AES-256-GCM using Python cryptography or PyCryptodome."""
|
|
try:
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
aesgcm = AESGCM(key)
|
|
# GCM expects ciphertext + tag concatenated
|
|
ct_with_tag = ciphertext + auth_tag
|
|
return aesgcm.decrypt(nonce, ct_with_tag, None)
|
|
except ImportError:
|
|
pass
|
|
|
|
try:
|
|
from Crypto.Cipher import AES
|
|
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
return cipher.decrypt_and_verify(ciphertext, auth_tag)
|
|
except ImportError:
|
|
pass
|
|
|
|
print("Error: Install 'cryptography' or 'pycryptodome':", file=sys.stderr)
|
|
print(" pip install cryptography", file=sys.stderr)
|
|
print(" # or", file=sys.stderr)
|
|
print(" pip install pycryptodome", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
def parse_ocke_binary(data):
|
|
"""Parse OCKE binary format and return fields."""
|
|
offset = 0
|
|
|
|
# Magic (4 bytes)
|
|
magic = data[offset:offset+4]
|
|
if magic != b'OCKE':
|
|
return None
|
|
offset += 4
|
|
|
|
# Version (uint16 BE)
|
|
version = struct.unpack('>H', data[offset:offset+2])[0]
|
|
offset += 2
|
|
|
|
# ProviderIdLen (1 byte) + ProviderId
|
|
provider_id_len = data[offset]
|
|
offset += 1
|
|
provider_id = data[offset:offset+provider_id_len].decode('ascii')
|
|
offset += provider_id_len
|
|
|
|
# CmkIdLen (uint16 BE) + CmkId
|
|
cmk_id_len = struct.unpack('>H', data[offset:offset+2])[0]
|
|
offset += 2
|
|
cmk_id = data[offset:offset+cmk_id_len].decode('utf-8')
|
|
offset += cmk_id_len
|
|
|
|
# WrappedDekLen (uint16 BE) + WrappedDek
|
|
wrapped_dek_len = struct.unpack('>H', data[offset:offset+2])[0]
|
|
offset += 2
|
|
wrapped_dek = data[offset:offset+wrapped_dek_len]
|
|
offset += wrapped_dek_len
|
|
|
|
# Nonce (12 bytes)
|
|
nonce = data[offset:offset+12]
|
|
offset += 12
|
|
|
|
# AuthTag (16 bytes)
|
|
auth_tag = data[offset:offset+16]
|
|
offset += 16
|
|
|
|
# CiphertextLen (uint32 BE) + Ciphertext
|
|
ciphertext_len = struct.unpack('>I', data[offset:offset+4])[0]
|
|
offset += 4
|
|
ciphertext = data[offset:offset+ciphertext_len]
|
|
|
|
return {
|
|
'version': version,
|
|
'provider_id': provider_id,
|
|
'cmk_id': cmk_id,
|
|
'wrapped_dek': wrapped_dek,
|
|
'nonce': nonce,
|
|
'auth_tag': auth_tag,
|
|
'ciphertext': ciphertext,
|
|
}
|
|
|
|
def extract_region_from_arn(arn):
|
|
"""Extract AWS region from KMS key ARN."""
|
|
parts = arn.split(':')
|
|
if len(parts) >= 6 and parts[0] == 'arn' and parts[2] == 'kms':
|
|
return parts[3]
|
|
return 'us-east-1'
|
|
|
|
def decrypt_record(record, file_path='', vault_name=''):
|
|
"""Decrypt a single OCKE record."""
|
|
region = extract_region_from_arn(record['cmk_id'])
|
|
|
|
encryption_context = {
|
|
'vaultName': vault_name,
|
|
'filePath': file_path,
|
|
'formatVersion': str(record['version']),
|
|
}
|
|
|
|
# Unwrap DEK via KMS
|
|
dek = aws_kms_decrypt(record['wrapped_dek'], encryption_context, region)
|
|
|
|
# Decrypt content via AES-256-GCM
|
|
plaintext = aes_gcm_decrypt(dek, record['nonce'], record['ciphertext'], record['auth_tag'])
|
|
|
|
return plaintext
|
|
|
|
def main():
|
|
input_file = sys.argv[1]
|
|
output_file = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else None
|
|
|
|
file_path = Path(input_file).name
|
|
vault_name = '' # Can be set via env var if needed
|
|
|
|
if os.environ.get('OCKE_VAULT_NAME'):
|
|
vault_name = os.environ['OCKE_VAULT_NAME']
|
|
|
|
with open(input_file, 'rb') as f:
|
|
raw_data = f.read()
|
|
|
|
# Check if it's a binary OCKE file (starts with magic bytes)
|
|
if raw_data[:4] == b'OCKE':
|
|
record = parse_ocke_binary(raw_data)
|
|
if not record:
|
|
print("Error: Failed to parse OCKE binary format", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
plaintext = decrypt_record(record, file_path, vault_name)
|
|
|
|
if output_file:
|
|
with open(output_file, 'wb') as f:
|
|
f.write(plaintext)
|
|
print(f"Decrypted: {input_file} -> {output_file}")
|
|
else:
|
|
sys.stdout.buffer.write(plaintext)
|
|
return
|
|
|
|
# It's a text file — look for ````ocke-v1 blocks
|
|
text = raw_data.decode('utf-8')
|
|
pattern = re.compile(r'````ocke-v1\n([\s\S]*?)\n````')
|
|
|
|
matches = list(pattern.finditer(text))
|
|
if not matches:
|
|
print("No encrypted blocks found in file", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
result = text
|
|
for match in matches:
|
|
b64_content = match.group(1).strip().replace('\n', '').replace('\r', '').replace(' ', '')
|
|
binary_data = base64.b64decode(b64_content)
|
|
record = parse_ocke_binary(binary_data)
|
|
|
|
if not record:
|
|
continue
|
|
|
|
plaintext = decrypt_record(record, file_path, vault_name)
|
|
plaintext_text = plaintext.decode('utf-8')
|
|
|
|
secret_block = f'%%secret-start%%\n{plaintext_text}\n%%secret-end%%'
|
|
result = result.replace(match.group(0), secret_block)
|
|
|
|
if output_file:
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
f.write(result)
|
|
print(f"Decrypted: {input_file} -> {output_file}")
|
|
else:
|
|
print(result)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
PYTHON_SCRIPT
|