290 lines
8.8 KiB
Bash
Executable File
290 lines
8.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Digital Sarcophagus - LUKS2 container manager for Ubuntu 20.04+
|
|
|
|
readonly PROGNAME="${0##*/}"
|
|
readonly VERSION="1.3.0"
|
|
readonly DEFAULT_SIZE="1G"
|
|
readonly DEFAULT_FS="ext4"
|
|
readonly PBKDF_MEMORY="1048576" # 1 GB RAM for Argon2id
|
|
readonly PBKDF_PARALLEL="4" # CPU threads for KDF
|
|
readonly CIPHER="aes-xts-plain64"
|
|
readonly KEY_SIZE="512"
|
|
readonly SECTOR_SIZE="4096"
|
|
|
|
log() { printf '%s [%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$PROGNAME" "$*" >&2; }
|
|
error() { log "ERROR: $*" >&2; exit 1; }
|
|
warn() { log "WARN: $*" >&2; }
|
|
debug() { [[ "${DEBUG:-0}" == "1" ]] && log "DEBUG: $*" >&2 || true; }
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Core utilities
|
|
# -----------------------------------------------------------------------------
|
|
|
|
check_dependencies() {
|
|
local cmd
|
|
for cmd in cryptsetup dd losetup mkfs.ext4 sync; do
|
|
command -v "$cmd" >/dev/null || error "Missing dependency: $cmd"
|
|
done
|
|
|
|
local ver
|
|
ver=$(cryptsetup --version | grep -oP '\d+\.\d+\.\d+' | head -1)
|
|
[[ "$ver" =~ ^2\. ]] || error "Need cryptsetup >= 2.0 (LUKS2). Found: $ver"
|
|
}
|
|
|
|
require_root() {
|
|
[[ $EUID -eq 0 ]] || error "Need root privileges (use sudo)"
|
|
}
|
|
|
|
validate_size() {
|
|
[[ "$1" =~ ^[0-9]+[KMGT]?$ ]] || error "Invalid size: $1 (use 100M, 1G, 10G)"
|
|
}
|
|
|
|
validate_path() {
|
|
local path="$1" action="$2"
|
|
|
|
case "$action" in
|
|
create)
|
|
[[ ! -e "$path" ]] || error "File exists: $path"
|
|
[[ -d "$(dirname "$path")" ]] || error "Parent directory missing"
|
|
;;
|
|
mount|umount|status)
|
|
[[ -f "$path" ]] || error "Container not found: $path"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
read_password() {
|
|
local prompt="${1:-Password: }" confirm="${2:-}"
|
|
local pw1 pw2
|
|
|
|
while true; do
|
|
read -rsp "$prompt" pw1 </dev/tty || error "Failed to read password"
|
|
echo >&2
|
|
[[ -n "$pw1" ]] && break
|
|
warn "Password cannot be empty"
|
|
done
|
|
|
|
if [[ "${CONFIRM:-1}" == "1" && "$confirm" == "confirm" ]]; then
|
|
read -rsp "Confirm: " pw2 </dev/tty || error "Failed to read confirmation"
|
|
echo >&2
|
|
[[ "$pw1" == "$pw2" ]] || error "Passwords do not match"
|
|
fi
|
|
|
|
printf '%s' "$pw1"
|
|
}
|
|
|
|
get_loop_device() {
|
|
losetup --find --show --nooverlap "$1" 2>/dev/null || error "Cannot allocate loop device"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# LUKS operations
|
|
# -----------------------------------------------------------------------------
|
|
|
|
luks_format() {
|
|
local img="$1" size="$2"
|
|
log "Creating container: $img ($size)"
|
|
|
|
log "→ Allocating sparse file"
|
|
truncate -s "$size" "$img"
|
|
sync
|
|
|
|
log "→ Formatting LUKS2 (cipher=$CIPHER, pbkdf=argon2id)"
|
|
{
|
|
read_password "New password: " confirm
|
|
} | cryptsetup luksFormat \
|
|
--type luks2 \
|
|
--cipher "$CIPHER" \
|
|
--key-size "$KEY_SIZE" \
|
|
--sector-size "$SECTOR_SIZE" \
|
|
--pbkdf argon2id \
|
|
--pbkdf-memory "$PBKDF_MEMORY" \
|
|
--pbkdf-parallel "$PBKDF_PARALLEL" \
|
|
--force-password \
|
|
"$img" - || error "LUKS format failed"
|
|
|
|
log "✓ LUKS2 header created"
|
|
}
|
|
|
|
create_filesystem() {
|
|
local mapper="$1" fs="$2"
|
|
log "→ Creating $fs filesystem"
|
|
|
|
case "$fs" in
|
|
ext4)
|
|
mkfs.ext4 -q -E lazy_itable_init=0,lazy_journal_init=0 "/dev/mapper/$mapper"
|
|
tune2fs -m 0 "/dev/mapper/$mapper" >/dev/null 2>&1 || true
|
|
;;
|
|
*) error "Unsupported filesystem: $fs" ;;
|
|
esac
|
|
|
|
log "✓ Filesystem ready"
|
|
}
|
|
|
|
mount_container() {
|
|
local img="$1" mountpoint="$2"
|
|
local mapper_name="${3:-${img%.img}}"
|
|
mapper_name="${mapper_name##*/}" # basename without path
|
|
|
|
validate_path "$img" mount
|
|
[[ -d "$mountpoint" ]] || mkdir -p "$mountpoint"
|
|
|
|
mountpoint -q "$mountpoint" 2>/dev/null && error "Mountpoint in use: $mountpoint"
|
|
[[ ! -b "/dev/mapper/$mapper_name" ]] || error "Mapper already exists: $mapper_name"
|
|
|
|
local loopdev
|
|
loopdev=$(get_loop_device "$img")
|
|
debug "Loop device: $loopdev"
|
|
|
|
log "→ Opening LUKS container: $img → /dev/mapper/$mapper_name"
|
|
{
|
|
read_password "Password for $img: "
|
|
} | cryptsetup open --type luks "$loopdev" "$mapper_name" --key-file - || {
|
|
losetup -d "$loopdev" 2>/dev/null || true
|
|
error "LUKS unlock failed"
|
|
}
|
|
|
|
log "→ Mounting to $mountpoint"
|
|
mount -o noatime,nodiratime,nodev,nosuid "/dev/mapper/$mapper_name" "$mountpoint" || {
|
|
cryptsetup close "$mapper_name" 2>/dev/null || true
|
|
losetup -d "$loopdev" 2>/dev/null || true
|
|
error "Mount failed"
|
|
}
|
|
|
|
echo "LOOP_DEVICE=$loopdev" > "/run/sarcophagus-${mapper_name}.env" 2>/dev/null || true
|
|
log "✓ Mounted: $img → $mountpoint"
|
|
}
|
|
|
|
umount_container() {
|
|
local img="$1"
|
|
local mapper_name="${img%.img}"
|
|
mapper_name="${mapper_name##*/}"
|
|
local envfile="/run/sarcophagus-${mapper_name}.env"
|
|
|
|
[[ -b "/dev/mapper/$mapper_name" ]] || error "Mapper not active: $mapper_name"
|
|
|
|
local mountpoint
|
|
mountpoint=$(findmnt -n -o TARGET "/dev/mapper/$mapper_name" 2>/dev/null)
|
|
[[ -n "$mountpoint" ]] || error "Not mounted. Close manually: cryptsetup close $mapper_name"
|
|
|
|
log "→ Unmounting from $mountpoint"
|
|
umount "$mountpoint" || error "Unmount failed (device busy?)"
|
|
|
|
log "→ Closing LUKS container"
|
|
cryptsetup close "$mapper_name" || error "cryptsetup close failed"
|
|
|
|
if [[ -f "$envfile" ]]; then
|
|
local loopdev
|
|
loopdev=$(grep '^LOOP_DEVICE=' "$envfile" | cut -d= -f2)
|
|
[[ -z "$loopdev" || ! "$loopdev" =~ ^/dev/loop ]] || losetup -d "$loopdev" 2>/dev/null
|
|
rm -f "$envfile"
|
|
fi
|
|
|
|
log "✓ Container closed"
|
|
}
|
|
|
|
status_container() {
|
|
local img="$1"
|
|
local mapper_name="${img%.img}"
|
|
mapper_name="${mapper_name##*/}"
|
|
|
|
echo "Container: $img"
|
|
echo "Mapper: $mapper_name"
|
|
echo -n "Active: "
|
|
[[ -b "/dev/mapper/$mapper_name" ]] && echo "yes" || echo "no"
|
|
|
|
if [[ -b "/dev/mapper/$mapper_name" ]]; then
|
|
echo "Mounts:"
|
|
mount | grep "/dev/mapper/$mapper_name" || echo " (none)"
|
|
fi
|
|
|
|
echo "Header:"
|
|
cryptsetup luksDump "$img" 2>/dev/null | grep -E "(Version|Cipher|PBKDF|Memory cost)" || echo " (unreadable)"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# CLI interface
|
|
# -----------------------------------------------------------------------------
|
|
|
|
show_help() {
|
|
cat <<EOF
|
|
${PROGNAME} v${VERSION} — LUKS2 container manager
|
|
|
|
Usage:
|
|
${PROGNAME} create <image.img> [size] # Create container (default: ${DEFAULT_SIZE})
|
|
${PROGNAME} mount <image.img> <mountpoint> # Mount container
|
|
${PROGNAME} umount <image.img> # Unmount container (use image path, NOT mountpoint)
|
|
${PROGNAME} status <image.img> # Show container state
|
|
${PROGNAME} help # This help
|
|
|
|
Examples:
|
|
sudo ${PROGNAME} create vault.img 10G
|
|
sudo ${PROGNAME} mount vault.img /mnt/vault
|
|
sudo ${PROGNAME} umount vault.img
|
|
|
|
Security:
|
|
• LUKS2 with Argon2id (1 GB RAM, 4 threads)
|
|
• AES-XTS 512-bit keys
|
|
• Passwords never logged or exposed in process list
|
|
• No backdoors — forgetting password = permanent data loss
|
|
|
|
Requirements:
|
|
Ubuntu 20.04+ with cryptsetup >= 2.0 (standard installation)
|
|
EOF
|
|
}
|
|
|
|
main() {
|
|
[[ $# -eq 0 ]] && { show_help; exit 1; }
|
|
|
|
local cmd="$1"; shift
|
|
case "$cmd" in
|
|
create)
|
|
[[ $# -ge 1 ]] || error "Usage: $PROGNAME create <image.img> [size]"
|
|
local img="$1" size="${2:-$DEFAULT_SIZE}"
|
|
validate_size "$size"
|
|
validate_path "$img" create
|
|
require_root
|
|
check_dependencies
|
|
luks_format "$img" "$size"
|
|
|
|
local mapper="${img%.img}"; mapper="${mapper##*/}"
|
|
{
|
|
read_password "Password for $img: "
|
|
} | cryptsetup open --type luks "$img" "$mapper" --key-file - || \
|
|
error "Cannot open container for FS creation"
|
|
|
|
create_filesystem "$mapper" "$DEFAULT_FS"
|
|
cryptsetup close "$mapper" || error "Cannot close container"
|
|
log "✓ Ready: $img (${size})"
|
|
;;
|
|
|
|
mount)
|
|
[[ $# -ge 2 ]] || error "Usage: $PROGNAME mount <image.img> <mountpoint>"
|
|
require_root
|
|
check_dependencies
|
|
mount_container "$1" "$2"
|
|
;;
|
|
|
|
umount|unmount)
|
|
[[ $# -ge 1 ]] || error "Usage: $PROGNAME umount <image.img>"
|
|
require_root
|
|
check_dependencies
|
|
umount_container "$1"
|
|
;;
|
|
|
|
status)
|
|
[[ $# -ge 1 ]] || error "Usage: $PROGNAME status <image.img>"
|
|
check_dependencies
|
|
status_container "$1"
|
|
;;
|
|
|
|
help) show_help ;;
|
|
*) error "Unknown command: $cmd" ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|
|
|