Add platform infrastructure configuration
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Direct-exec root helper for one staged A1 transaction.
|
||||
|
||||
_k3slra1h_command() {
|
||||
"$@"
|
||||
}
|
||||
|
||||
_k3slra1h_euid_is_root() {
|
||||
(( EUID == 0 ))
|
||||
}
|
||||
|
||||
_k3slra1h_output_name_is_safe() {
|
||||
local output_name="${1-}"
|
||||
(( $# == 1 )) || return 1
|
||||
[[ "$output_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 1
|
||||
[[ "$output_name" != _k3slra1h_* ]]
|
||||
}
|
||||
|
||||
_k3slra1h_capture_metadata() {
|
||||
local target="${1-}" output_name="${2-}" line=''
|
||||
(( $# == 2 )) || return 1
|
||||
_k3slra1h_output_name_is_safe "$output_name" || return 1
|
||||
case "$output_name" in target|output_name|line|record) return 1 ;; esac
|
||||
_k3slra1h_packet_line metadata "$target" line || return 1
|
||||
printf -v "$output_name" '%s' "$line"
|
||||
}
|
||||
|
||||
_k3slra1h_capture_canonical() {
|
||||
local target="${1-}" output_name="${2-}" line=''
|
||||
(( $# == 2 )) || return 1
|
||||
_k3slra1h_output_name_is_safe "$output_name" || return 1
|
||||
case "$output_name" in target|output_name|line) return 1 ;; esac
|
||||
_k3slra1h_packet_line readlink "$target" line || return 1
|
||||
[[ "$line" == "$target" ]] || return 1
|
||||
printf -v "$output_name" '%s' "$line"
|
||||
}
|
||||
|
||||
_k3slra1h_stderr_marker() {
|
||||
local chunk='' read_rc=0
|
||||
if IFS= read -r -d '' -n 1 chunk; then
|
||||
read_rc=0
|
||||
else
|
||||
read_rc=$?
|
||||
fi
|
||||
if (( read_rc == 0 )); then
|
||||
printf '\0STDERR\0'
|
||||
while IFS= read -r -d '' -n 4096 chunk; do
|
||||
:
|
||||
done
|
||||
else
|
||||
if [[ -n "$chunk" ]]; then
|
||||
printf '\0STDERR\0'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
_k3slra1h_metadata_packet() {
|
||||
local target="${1-}" rc=0
|
||||
(( $# == 1 )) || return 1
|
||||
if _k3slra1h_command /usr/bin/stat --format='%d:%i|%h|%s|%y|%F|%u:%g|%a' -- "$target" 2> >(_k3slra1h_stderr_marker); then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
printf '\0RC=%s\0' "$rc"
|
||||
}
|
||||
|
||||
_k3slra1h_readlink_packet() {
|
||||
local target="${1-}" rc=0
|
||||
(( $# == 1 )) || return 1
|
||||
if _k3slra1h_command /usr/bin/readlink --canonicalize-existing -- "$target" 2> >(_k3slra1h_stderr_marker); then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
printf '\0RC=%s\0' "$rc"
|
||||
}
|
||||
|
||||
_k3slra1h_packet_value() {
|
||||
local producer="${1-}" argument="${2-}" output_name="${3-}"
|
||||
local -a records=()
|
||||
(( $# == 3 )) || return 1
|
||||
_k3slra1h_output_name_is_safe "$output_name" || return 1
|
||||
case "$output_name" in producer|argument|output_name|records) return 1 ;; esac
|
||||
case "$producer" in
|
||||
metadata) mapfile -d '' -t records < <(_k3slra1h_metadata_packet "$argument") ;;
|
||||
readlink) mapfile -d '' -t records < <(_k3slra1h_readlink_packet "$argument") ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
(( ${#records[@]} == 2 )) || return 1
|
||||
[[ "${records[1]}" == RC=0 ]] || return 1
|
||||
printf -v "$output_name" '%s' "${records[0]}"
|
||||
}
|
||||
|
||||
_k3slra1h_packet_line() {
|
||||
local producer="${1-}" argument="${2-}" output_name="${3-}" value=''
|
||||
(( $# == 3 )) || return 1
|
||||
_k3slra1h_output_name_is_safe "$output_name" || return 1
|
||||
case "$output_name" in producer|argument|output_name|value) return 1 ;; esac
|
||||
_k3slra1h_packet_value "$producer" "$argument" value || return 1
|
||||
[[ "$value" == *$'\n' ]] || return 1
|
||||
[[ "$value" != *$'\r'* ]] || return 1
|
||||
value="${value%$'\n'}"
|
||||
[[ -n "$value" ]] || return 1
|
||||
[[ "$value" != *$'\n'* ]] || return 1
|
||||
printf -v "$output_name" '%s' "$value"
|
||||
}
|
||||
|
||||
_k3slra1h_output_names_are_distinct() {
|
||||
local name
|
||||
local -A seen=()
|
||||
(( $# >= 1 )) || return 1
|
||||
for name in "$@"; do
|
||||
_k3slra1h_output_name_is_safe "$name" || return 1
|
||||
[[ "${seen[$name]+set}" != set ]] || return 1
|
||||
printf -v 'seen[$name]' '%s' 1
|
||||
done
|
||||
}
|
||||
|
||||
_k3slra1h_metadata_fields() {
|
||||
local target="${1-}" out_identity="${2-}" out_nlink="${3-}" out_size="${4-}"
|
||||
local out_mtime="${5-}" out_type="${6-}" out_owner="${7-}" out_mode="${8-}"
|
||||
local metadata='' normalized_type=''
|
||||
(( $# == 8 )) || return 1
|
||||
_k3slra1h_output_names_are_distinct "$out_identity" "$out_nlink" "$out_size" "$out_mtime" "$out_type" "$out_owner" "$out_mode" || return 1
|
||||
case "$out_identity|$out_nlink|$out_size|$out_mtime|$out_type|$out_owner|$out_mode" in
|
||||
*'|target|'*|target\|*|*\|target|*'|out_identity|'*|out_identity\|*|*\|out_identity|*'|out_nlink|'*|out_nlink\|*|*\|out_nlink|*'|out_size|'*|out_size\|*|*\|out_size|*'|out_mtime|'*|out_mtime\|*|*\|out_mtime|*'|out_type|'*|out_type\|*|*\|out_type|*'|out_owner|'*|out_owner\|*|*\|out_owner|*'|out_mode|'*|out_mode\|*|*\|out_mode|*'|metadata|'*|metadata\|*|*\|metadata|*'|normalized_type|'*|normalized_type\|*|*\|normalized_type) return 1 ;;
|
||||
esac
|
||||
_k3slra1h_capture_metadata "$target" metadata || return 1
|
||||
[[ "$metadata" =~ ^([0-9]+:[0-9]+)\|([0-9]+)\|([0-9]+)\|([^|]+)\|(directory|regular[[:space:]]file|regular[[:space:]]empty[[:space:]]file|symbolic[[:space:]]link)\|([0-9]+:[0-9]+)\|([0-9]+)$ ]] || return 1
|
||||
normalized_type="${BASH_REMATCH[5]}"
|
||||
if [[ "$normalized_type" == 'regular empty file' ]]; then
|
||||
printf -v normalized_type '%s' 'regular file'
|
||||
fi
|
||||
printf -v "$out_identity" '%s' "${BASH_REMATCH[1]}"
|
||||
printf -v "$out_nlink" '%s' "${BASH_REMATCH[2]}"
|
||||
printf -v "$out_size" '%s' "${BASH_REMATCH[3]}"
|
||||
printf -v "$out_mtime" '%s' "${BASH_REMATCH[4]}"
|
||||
printf -v "$out_type" '%s' "$normalized_type"
|
||||
printf -v "$out_owner" '%s' "${BASH_REMATCH[6]}"
|
||||
printf -v "$out_mode" '%s' "${BASH_REMATCH[7]}"
|
||||
}
|
||||
|
||||
_k3slra1h_regular_root_file_is_safe() {
|
||||
local target="${1-}" expected_device="${2-}" canonical=''
|
||||
local identity='' nlink='' size='' mtime='' type='' owner='' mode=''
|
||||
(( $# == 2 )) || return 1
|
||||
_k3slra1h_capture_canonical "$target" canonical || return 1
|
||||
[[ "$canonical" == "$target" ]] || return 1
|
||||
_k3slra1h_metadata_fields "$target" identity nlink size mtime type owner mode || return 1
|
||||
[[ "${identity%%:*}" == "$expected_device" ]] || return 1
|
||||
[[ "$nlink" == 1 ]] || return 1
|
||||
[[ "$type" == 'regular file' ]] || return 1
|
||||
[[ "$owner" == 0:0 ]] || return 1
|
||||
[[ "$mode" == 600 ]]
|
||||
}
|
||||
|
||||
_k3slra1h_root_directory_is_safe() {
|
||||
local target="${1-}" expected_device="${2-}" canonical=''
|
||||
local identity='' nlink='' size='' mtime='' type='' owner='' mode=''
|
||||
(( $# == 2 )) || return 1
|
||||
_k3slra1h_capture_canonical "$target" canonical || return 1
|
||||
[[ "$canonical" == "$target" ]] || return 1
|
||||
_k3slra1h_metadata_fields "$target" identity nlink size mtime type owner mode || return 1
|
||||
[[ "${identity%%:*}" == "$expected_device" ]] || return 1
|
||||
[[ "$type" == directory ]] || return 1
|
||||
[[ "$owner" == 0:0 ]] || return 1
|
||||
[[ "$mode" == 700 ]]
|
||||
}
|
||||
|
||||
_k3slra1h_staged_root_is_safe() {
|
||||
local capture_root="${1-}" canonical='' device=''
|
||||
local identity='' nlink='' size='' mtime='' type='' owner='' mode=''
|
||||
(( $# == 1 )) || return 1
|
||||
[[ "$capture_root" == /* ]] || return 1
|
||||
[[ "$capture_root" != */ ]] || return 1
|
||||
[[ "$capture_root" != *//* ]] || return 1
|
||||
_k3slra1h_capture_canonical "$capture_root" canonical || return 1
|
||||
[[ "$canonical" == "$capture_root" ]] || return 1
|
||||
_k3slra1h_metadata_fields "$capture_root" identity nlink size mtime type owner mode || return 1
|
||||
[[ "$type" == directory ]] || return 1
|
||||
[[ "$owner" == 0:0 ]] || return 1
|
||||
[[ "$mode" == 700 ]] || return 1
|
||||
device="${identity%%:*}"
|
||||
[[ "$device" =~ ^[0-9]+$ ]] || return 1
|
||||
_k3slra1h_root_directory_is_safe "${capture_root}/lib" "$device" || return 1
|
||||
_k3slra1h_root_directory_is_safe "${capture_root}/libexec" "$device" || return 1
|
||||
_k3slra1h_root_directory_is_safe "${capture_root}/contract" "$device" || return 1
|
||||
_k3slra1h_regular_root_file_is_safe "${capture_root}/lib/k3s-local-recovery.sh" "$device" || return 1
|
||||
_k3slra1h_regular_root_file_is_safe "${capture_root}/lib/k3s-local-recovery-a1.sh" "$device" || return 1
|
||||
_k3slra1h_regular_root_file_is_safe "${capture_root}/contract/local-recovery.env" "$device" || return 1
|
||||
_k3slra1h_regular_root_file_is_safe "${capture_root}/libexec/k3s-local-recovery-a1-check.sh" "$device"
|
||||
}
|
||||
|
||||
_k3slra1h_load_staged_dependencies() {
|
||||
local capture_root="${1-}" shared_library='' a1_library='' contract_file=''
|
||||
(( $# == 1 )) || return 1
|
||||
_k3slra1h_staged_root_is_safe "$capture_root" || return 1
|
||||
shared_library="${capture_root}/lib/k3s-local-recovery.sh"
|
||||
a1_library="${capture_root}/lib/k3s-local-recovery-a1.sh"
|
||||
contract_file="${capture_root}/contract/local-recovery.env"
|
||||
source "$shared_library" || return 1
|
||||
source "$a1_library" || return 1
|
||||
_k3slr_load_contract "$contract_file" || return 1
|
||||
[[ "${K3SLR_MAPPING_NAME-}" == "$K3SLRA1_MAIN_MAPPING_NAME" ]] || return 1
|
||||
[[ "${K3SLR_PROOF_MAPPING_NAME-}" == k3s-recovery-proof ]]
|
||||
}
|
||||
|
||||
_k3slra1h_check_main_impl() {
|
||||
local mount_root="${1-}" role="${2-}" capture_root="${3-}"
|
||||
(( $# == 3 )) || return 1
|
||||
_k3slra1h_euid_is_root || return 1
|
||||
[[ "$-" != *x* ]] || return 1
|
||||
case "$role" in
|
||||
original-baseline) [[ "$mount_root" == "${capture_root}/original-baseline-mount" ]] || return 1 ;;
|
||||
proof) [[ "$mount_root" == "${capture_root}/proof-mount" ]] || return 1 ;;
|
||||
original-final) [[ "$mount_root" == "${capture_root}/original-final-mount" ]] || return 1 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
_k3slra1h_load_staged_dependencies "$capture_root" || return 1
|
||||
_k3slra1_verify_payload_root "$mount_root" "$role" "$capture_root"
|
||||
}
|
||||
|
||||
k3slra1_check_main() {
|
||||
_k3slra1h_check_main_impl "$@" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
_k3slra1h_direct_signal_rc=0
|
||||
_k3slra1h_direct_rc=0
|
||||
trap '_k3slra1h_direct_signal_rc=130; if (( _k3slra1h_direct_rc == 0 )); then _k3slra1h_direct_rc=130; fi' INT
|
||||
trap '_k3slra1h_direct_signal_rc=143; if (( _k3slra1h_direct_rc == 0 )); then _k3slra1h_direct_rc=143; fi' TERM
|
||||
if k3slra1_check_main "$@"; then
|
||||
_k3slra1h_direct_rc=0
|
||||
else
|
||||
_k3slra1h_direct_rc=$?
|
||||
fi
|
||||
if (( _k3slra1h_direct_signal_rc != 0 && _k3slra1h_direct_rc == 0 )); then
|
||||
_k3slra1h_direct_rc=$_k3slra1h_direct_signal_rc
|
||||
fi
|
||||
exit "$_k3slra1h_direct_rc"
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,947 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import ctypes
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
from typing import Callable, Sequence
|
||||
|
||||
|
||||
KEEPASSXC_CLI = "/usr/bin/keepassxc-cli"
|
||||
PYTHON3 = "/usr/bin/python3"
|
||||
SOCKET_NAME = "vault.sock"
|
||||
BASELINE_NAME = "baseline.kdbx"
|
||||
CANDIDATE_NAME = "candidate.kdbx"
|
||||
ENTRY_GROUP = "/Platform/Observability/Slack"
|
||||
ENTRY_TITLE = "Alertmanager webhook - desktop-infra-전체"
|
||||
ENTRY_PATH = ENTRY_GROUP + "/" + ENTRY_TITLE
|
||||
ENTRY_USERNAME = "desktop-infra"
|
||||
ENTRY_URL = ""
|
||||
MAX_WEBHOOK_BYTES = 4096
|
||||
MAX_INPUT_BYTES = 4096
|
||||
MAX_PROTOCOL_BYTES = 4096
|
||||
MAX_CHILD_OUTPUT_BYTES = 64 * 1024
|
||||
MAX_CHILD_STDERR_BYTES = 8192
|
||||
DEFAULT_ACCEPT_TIMEOUT = 900.0
|
||||
DEFAULT_IO_TIMEOUT = 15.0
|
||||
DEFAULT_CHILD_TIMEOUT = 30.0
|
||||
WEBHOOK_RE = re.compile(
|
||||
rb"https://hooks[.]slack[.]com/services/"
|
||||
rb"[A-Za-z0-9_-]+/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+"
|
||||
)
|
||||
|
||||
|
||||
class VaultContractError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class VaultProtocolError(VaultContractError):
|
||||
pass
|
||||
|
||||
|
||||
class VaultSignal(BaseException):
|
||||
def __init__(self, signum: int) -> None:
|
||||
super().__init__(signum)
|
||||
self.signum = signum
|
||||
|
||||
|
||||
HANDLED_SIGNALS = (signal.SIGHUP, signal.SIGINT, signal.SIGTERM)
|
||||
|
||||
|
||||
def install_process_signal_handlers() -> None:
|
||||
def raise_first_signal(signum: int, _frame: object) -> None:
|
||||
for handled in HANDLED_SIGNALS:
|
||||
signal.signal(handled, signal.SIG_IGN)
|
||||
raise VaultSignal(signum)
|
||||
|
||||
for signum in HANDLED_SIGNALS:
|
||||
signal.signal(signum, raise_first_signal)
|
||||
|
||||
|
||||
class KeepassResult:
|
||||
def __init__(self, returncode: int, stdout: bytes, stderr: bytes) -> None:
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def wipe_mutable_buffer(buffer: bytearray) -> None:
|
||||
for index in range(len(buffer)):
|
||||
buffer[index] = 0
|
||||
|
||||
|
||||
def _regular_metadata(fd: int, *, allow_unlinked: bool = False) -> tuple[int, ...]:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise VaultContractError("expected regular file")
|
||||
if info.st_uid != os.getuid():
|
||||
raise VaultContractError("file owner mismatch")
|
||||
if stat.S_IMODE(info.st_mode) != 0o600:
|
||||
raise VaultContractError("file mode mismatch")
|
||||
if info.st_nlink != 1 and not (allow_unlinked and info.st_nlink == 0):
|
||||
raise VaultContractError("file link count mismatch")
|
||||
return (
|
||||
info.st_dev,
|
||||
info.st_ino,
|
||||
info.st_uid,
|
||||
stat.S_IMODE(info.st_mode),
|
||||
info.st_nlink,
|
||||
info.st_size,
|
||||
)
|
||||
|
||||
|
||||
class RetainedWebhook:
|
||||
def __init__(self, fd: int, metadata: tuple[int, ...], content: bytearray) -> None:
|
||||
self._fd = fd
|
||||
self._metadata = metadata
|
||||
self._content = bytearray(content)
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def fd(self) -> int:
|
||||
if self._closed:
|
||||
raise VaultContractError("webhook descriptor is closed")
|
||||
return self._fd
|
||||
|
||||
def read_bytes(self) -> bytes:
|
||||
if self._closed:
|
||||
raise VaultContractError("webhook descriptor is closed")
|
||||
before = _regular_metadata(self._fd)
|
||||
if before != self._metadata:
|
||||
raise VaultContractError("webhook metadata drift")
|
||||
collected = bytearray()
|
||||
confirmation = bytearray()
|
||||
try:
|
||||
for target in (collected, confirmation):
|
||||
os.lseek(self._fd, 0, os.SEEK_SET)
|
||||
while len(target) <= MAX_WEBHOOK_BYTES:
|
||||
chunk = os.read(
|
||||
self._fd,
|
||||
min(1024, MAX_WEBHOOK_BYTES + 1 - len(target)),
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
target.extend(chunk)
|
||||
after = _regular_metadata(self._fd)
|
||||
if after != before:
|
||||
raise VaultContractError("webhook metadata drift")
|
||||
if len(collected) > MAX_WEBHOOK_BYTES or len(confirmation) > MAX_WEBHOOK_BYTES:
|
||||
raise VaultContractError("webhook content is oversized")
|
||||
if not hmac.compare_digest(bytes(collected), bytes(confirmation)) or not hmac.compare_digest(
|
||||
bytes(confirmation), self._content
|
||||
):
|
||||
raise VaultContractError("webhook content drift")
|
||||
return bytes(collected)
|
||||
finally:
|
||||
wipe_mutable_buffer(collected)
|
||||
wipe_mutable_buffer(confirmation)
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._closed:
|
||||
try:
|
||||
os.close(self._fd)
|
||||
finally:
|
||||
wipe_mutable_buffer(self._content)
|
||||
self._closed = True
|
||||
|
||||
|
||||
def _validate_webhook_content(content: bytes) -> None:
|
||||
if not content or len(content) > MAX_WEBHOOK_BYTES:
|
||||
raise VaultContractError("invalid webhook content")
|
||||
if b"\r" in content or b"\n" in content or b"\0" in content:
|
||||
raise VaultContractError("invalid webhook content")
|
||||
if content.strip() != content:
|
||||
raise VaultContractError("invalid webhook content")
|
||||
if WEBHOOK_RE.fullmatch(content) is None:
|
||||
raise VaultContractError("invalid webhook content")
|
||||
|
||||
|
||||
def open_validated_webhook(path: str) -> RetainedWebhook:
|
||||
if not os.path.isabs(path):
|
||||
raise VaultContractError("absolute webhook path required")
|
||||
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
fd = os.open(path, flags)
|
||||
try:
|
||||
before = _regular_metadata(fd)
|
||||
content_buffer = bytearray()
|
||||
confirmation_buffer = bytearray()
|
||||
try:
|
||||
for target in (content_buffer, confirmation_buffer):
|
||||
if _regular_metadata(fd) != before:
|
||||
raise VaultContractError("webhook metadata drift")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
while len(target) <= MAX_WEBHOOK_BYTES:
|
||||
chunk = os.read(
|
||||
fd,
|
||||
min(1024, MAX_WEBHOOK_BYTES + 1 - len(target)),
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
target.extend(chunk)
|
||||
if _regular_metadata(fd) != before:
|
||||
raise VaultContractError("webhook metadata drift")
|
||||
after = _regular_metadata(fd)
|
||||
if before != after:
|
||||
raise VaultContractError("webhook metadata drift")
|
||||
if not hmac.compare_digest(
|
||||
bytes(content_buffer), bytes(confirmation_buffer)
|
||||
):
|
||||
raise VaultContractError("webhook content drift")
|
||||
_validate_webhook_content(confirmation_buffer)
|
||||
return RetainedWebhook(fd, before, confirmation_buffer)
|
||||
finally:
|
||||
wipe_mutable_buffer(content_buffer)
|
||||
wipe_mutable_buffer(confirmation_buffer)
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def _read_line_fd(fd: int, limit: int) -> bytearray:
|
||||
value = bytearray()
|
||||
try:
|
||||
while len(value) <= limit:
|
||||
chunk = os.read(fd, 1)
|
||||
if not chunk:
|
||||
raise VaultContractError("operator input ended early")
|
||||
if chunk == b"\n":
|
||||
return value
|
||||
if chunk in {b"\r", b"\0"}:
|
||||
raise VaultContractError("invalid operator input")
|
||||
value.extend(chunk)
|
||||
raise VaultContractError("operator input is oversized")
|
||||
except BaseException:
|
||||
wipe_mutable_buffer(value)
|
||||
raise
|
||||
|
||||
|
||||
def _validate_app_name(raw: bytes) -> str:
|
||||
try:
|
||||
app = raw.decode("utf-8", "strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise VaultContractError("invalid app name") from exc
|
||||
if not 1 <= len(app) <= 80 or app.strip() != app:
|
||||
raise VaultContractError("invalid app name")
|
||||
if any(character in ";=\\\r\n" or not character.isprintable() for character in app):
|
||||
raise VaultContractError("invalid app name")
|
||||
return app
|
||||
|
||||
|
||||
def read_operator_secrets(
|
||||
*, input_fd: int | None = None, output_fd: int | None = None
|
||||
) -> tuple[str, bytearray]:
|
||||
owned_fd = False
|
||||
if input_fd is None:
|
||||
input_fd = os.open("/dev/tty", os.O_RDWR | os.O_CLOEXEC)
|
||||
output_fd = input_fd
|
||||
owned_fd = True
|
||||
if output_fd is None:
|
||||
raise VaultContractError("operator output descriptor required")
|
||||
master = bytearray()
|
||||
old_termios = None
|
||||
try:
|
||||
os.write(output_fd, b"Slack app name: ")
|
||||
raw_app = _read_line_fd(input_fd, MAX_INPUT_BYTES)
|
||||
try:
|
||||
app = _validate_app_name(bytes(raw_app))
|
||||
finally:
|
||||
wipe_mutable_buffer(raw_app)
|
||||
if os.isatty(input_fd):
|
||||
previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, HANDLED_SIGNALS)
|
||||
try:
|
||||
old_termios = termios.tcgetattr(input_fd)
|
||||
new_termios = list(old_termios)
|
||||
new_termios[3] &= ~termios.ECHO
|
||||
termios.tcsetattr(input_fd, termios.TCSAFLUSH, new_termios)
|
||||
os.write(output_fd, b"KeePassXC master password: ")
|
||||
finally:
|
||||
signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
|
||||
else:
|
||||
os.write(output_fd, b"KeePassXC master password: ")
|
||||
master = _read_line_fd(input_fd, MAX_INPUT_BYTES)
|
||||
if not master:
|
||||
raise VaultContractError("empty master password")
|
||||
if old_termios is not None:
|
||||
previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, HANDLED_SIGNALS)
|
||||
try:
|
||||
termios.tcsetattr(input_fd, termios.TCSAFLUSH, old_termios)
|
||||
old_termios = None
|
||||
os.write(output_fd, b"\n")
|
||||
finally:
|
||||
signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
|
||||
return app, master
|
||||
except BaseException:
|
||||
wipe_mutable_buffer(master)
|
||||
raise
|
||||
finally:
|
||||
if old_termios is not None:
|
||||
previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, HANDLED_SIGNALS)
|
||||
try:
|
||||
termios.tcsetattr(input_fd, termios.TCSAFLUSH, old_termios)
|
||||
os.write(output_fd, b"\n")
|
||||
finally:
|
||||
signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
|
||||
if owned_fd:
|
||||
os.close(input_fd)
|
||||
|
||||
|
||||
def _validate_child_stderr(stderr: bytes) -> None:
|
||||
if len(stderr) > MAX_CHILD_STDERR_BYTES or b"\0" in stderr or b"\r" in stderr:
|
||||
raise VaultContractError("invalid KeePass diagnostic")
|
||||
try:
|
||||
text = stderr.decode("utf-8", "strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise VaultContractError("invalid KeePass diagnostic") from exc
|
||||
if any(character not in "\t\n" and not character.isprintable() for character in text):
|
||||
raise VaultContractError("invalid KeePass diagnostic")
|
||||
|
||||
|
||||
def run_keepass(
|
||||
arguments: Sequence[str],
|
||||
master_buffer: bytearray,
|
||||
private_home: str,
|
||||
*,
|
||||
webhook: RetainedWebhook | None = None,
|
||||
timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
check: bool = True,
|
||||
) -> KeepassResult:
|
||||
if not arguments or any(not isinstance(value, str) or "\0" in value for value in arguments):
|
||||
raise VaultContractError("invalid KeePass arguments")
|
||||
if not os.path.isabs(private_home):
|
||||
raise VaultContractError("private home must be absolute")
|
||||
child_input = bytearray(master_buffer)
|
||||
child_input.extend(b"\n")
|
||||
if webhook is not None:
|
||||
protected = bytearray(webhook.read_bytes())
|
||||
try:
|
||||
child_input.extend(protected)
|
||||
child_input.extend(b"\n")
|
||||
finally:
|
||||
wipe_mutable_buffer(protected)
|
||||
environment = {
|
||||
"HOME": os.devnull,
|
||||
"XDG_CONFIG_HOME": os.devnull,
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
expected_parent_pid = os.getpid()
|
||||
def prepare_child() -> None:
|
||||
os.umask(0o077)
|
||||
arm_parent_death_signal(expected_parent_pid)
|
||||
process = subprocess.Popen(
|
||||
[KEEPASSXC_CLI, *arguments],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=environment,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
preexec_fn=prepare_child,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = process.communicate(bytes(child_input), timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
try:
|
||||
os.killpg(process.pid, 15)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
process.communicate(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.communicate()
|
||||
raise VaultContractError("KeePass child timeout") from exc
|
||||
if len(stdout) > MAX_CHILD_OUTPUT_BYTES:
|
||||
raise VaultContractError("KeePass output is oversized")
|
||||
_validate_child_stderr(stderr)
|
||||
result = KeepassResult(process.returncode, stdout, stderr)
|
||||
if check and process.returncode != 0:
|
||||
raise VaultContractError("KeePass command failed")
|
||||
return result
|
||||
finally:
|
||||
wipe_mutable_buffer(child_input)
|
||||
|
||||
|
||||
def arm_parent_death_signal(expected_parent_pid: int) -> None:
|
||||
"""Ensure a vault death cannot orphan a secret-bearing KeePass child."""
|
||||
if expected_parent_pid <= 1:
|
||||
raise VaultContractError("invalid expected parent PID")
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
prctl = libc.prctl
|
||||
rc = prctl(1, 9, 0, 0, 0) # PR_SET_PDEATHSIG, SIGKILL
|
||||
if rc != 0:
|
||||
err = ctypes.get_errno()
|
||||
raise OSError(err, os.strerror(err))
|
||||
if os.getppid() != expected_parent_pid:
|
||||
os.kill(os.getpid(), 9)
|
||||
|
||||
|
||||
def expected_notes(app_name: str) -> str:
|
||||
_validate_app_name(app_name.encode("utf-8"))
|
||||
return (
|
||||
"channel=desktop-infra-전체;"
|
||||
f"app={app_name};"
|
||||
"recovery=revoke-and-reissue-in-slack"
|
||||
)
|
||||
|
||||
|
||||
def _entry_count(
|
||||
database: str,
|
||||
master: bytearray,
|
||||
private_home: str,
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
) -> tuple[int, set[str]]:
|
||||
result = run_keepass(
|
||||
["ls", "-q", "-R", "-f", database], master, private_home,
|
||||
check=False, timeout=child_timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise VaultContractError("KeePass database unlock failed")
|
||||
try:
|
||||
text = result.stdout.decode("utf-8", "strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise VaultContractError("invalid KeePass listing") from exc
|
||||
lines = text.splitlines()
|
||||
exact = ENTRY_PATH.lstrip("/")
|
||||
return sum(line == exact for line in lines), set(lines)
|
||||
|
||||
|
||||
def _shown_attributes(
|
||||
database: str,
|
||||
master: bytearray,
|
||||
private_home: str,
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
) -> tuple[str, str, str, str]:
|
||||
result = run_keepass(
|
||||
[
|
||||
"show", "-q", "-a", "Title", "-a", "UserName", "-a", "URL",
|
||||
"-a", "Notes", database, ENTRY_PATH,
|
||||
],
|
||||
master,
|
||||
private_home,
|
||||
timeout=child_timeout,
|
||||
)
|
||||
try:
|
||||
text = result.stdout.decode("utf-8", "strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise VaultContractError("invalid KeePass attributes") from exc
|
||||
lines = text.split("\n")
|
||||
if len(lines) != 5 or lines[-1] != "" or any("\r" in line for line in lines[:-1]):
|
||||
raise VaultContractError("invalid KeePass attribute framing")
|
||||
return lines[0], lines[1], lines[2], lines[3]
|
||||
|
||||
|
||||
def protected_value_matches(producer_rc: int, shown: bytes, expected: bytes) -> bool:
|
||||
comparison = bytearray(expected)
|
||||
comparison.extend(b"\n")
|
||||
try:
|
||||
return producer_rc == 0 and hmac.compare_digest(shown, bytes(comparison))
|
||||
finally:
|
||||
wipe_mutable_buffer(comparison)
|
||||
|
||||
|
||||
def _password_matches(
|
||||
database: str,
|
||||
master: bytearray,
|
||||
webhook: RetainedWebhook,
|
||||
private_home: str,
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
) -> bool:
|
||||
result = run_keepass(
|
||||
["show", "-q", "-s", "-a", "Password", database, ENTRY_PATH],
|
||||
master,
|
||||
private_home,
|
||||
check=False,
|
||||
timeout=child_timeout,
|
||||
)
|
||||
expected = bytearray(webhook.read_bytes())
|
||||
shown = bytearray(result.stdout)
|
||||
try:
|
||||
return protected_value_matches(result.returncode, bytes(shown), bytes(expected))
|
||||
finally:
|
||||
wipe_mutable_buffer(expected)
|
||||
wipe_mutable_buffer(shown)
|
||||
|
||||
|
||||
def classify_database(
|
||||
database: str,
|
||||
master_buffer: bytearray,
|
||||
webhook: RetainedWebhook,
|
||||
app_name: str,
|
||||
private_home: str,
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
) -> str:
|
||||
try:
|
||||
count, _ = _entry_count(database, master_buffer, private_home, child_timeout)
|
||||
if count == 0:
|
||||
return "absent"
|
||||
if count > 1:
|
||||
return "ambiguous"
|
||||
expected = (ENTRY_TITLE, ENTRY_USERNAME, ENTRY_URL, expected_notes(app_name))
|
||||
if _shown_attributes(database, master_buffer, private_home, child_timeout) != expected:
|
||||
return "mismatch"
|
||||
if not _password_matches(
|
||||
database, master_buffer, webhook, private_home, child_timeout
|
||||
):
|
||||
return "mismatch"
|
||||
return "exact-noop"
|
||||
except (OSError, VaultContractError):
|
||||
return "failed"
|
||||
|
||||
|
||||
def _copy_private_database(source: str, destination: str) -> None:
|
||||
source_fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
destination_fd = -1
|
||||
try:
|
||||
_regular_metadata(source_fd)
|
||||
destination_fd = os.open(
|
||||
destination,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
0o600,
|
||||
)
|
||||
while True:
|
||||
chunk = os.read(source_fd, 1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
view = memoryview(chunk)
|
||||
while view:
|
||||
written = os.write(destination_fd, view)
|
||||
view = view[written:]
|
||||
os.fsync(destination_fd)
|
||||
_regular_metadata(destination_fd)
|
||||
finally:
|
||||
if destination_fd >= 0:
|
||||
os.close(destination_fd)
|
||||
os.close(source_fd)
|
||||
|
||||
|
||||
def _ensure_groups(
|
||||
database: str,
|
||||
master: bytearray,
|
||||
private_home: str,
|
||||
existing_lines: set[str],
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
) -> None:
|
||||
for group in ("/Platform", "/Platform/Observability", ENTRY_GROUP):
|
||||
flattened = group.lstrip("/") + "/"
|
||||
if flattened not in existing_lines:
|
||||
run_keepass(
|
||||
["mkdir", "-q", database, group], master, private_home,
|
||||
timeout=child_timeout,
|
||||
)
|
||||
existing_lines.add(flattened)
|
||||
|
||||
|
||||
def build_candidate(
|
||||
baseline: str,
|
||||
candidate: str,
|
||||
mode: str,
|
||||
master_buffer: bytearray,
|
||||
webhook: RetainedWebhook,
|
||||
app_name: str,
|
||||
private_home: str,
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
) -> None:
|
||||
if mode not in {"ADD", "UPDATE"}:
|
||||
raise VaultContractError("invalid candidate mode")
|
||||
classification = classify_database(
|
||||
baseline, master_buffer, webhook, app_name, private_home, child_timeout
|
||||
)
|
||||
if (mode == "ADD" and classification != "absent") or (
|
||||
mode == "UPDATE" and classification != "mismatch"
|
||||
):
|
||||
raise VaultContractError("candidate mode does not match classification")
|
||||
_copy_private_database(baseline, candidate)
|
||||
try:
|
||||
_, existing_lines = _entry_count(
|
||||
candidate, master_buffer, private_home, child_timeout
|
||||
)
|
||||
notes = expected_notes(app_name)
|
||||
if mode == "ADD":
|
||||
_ensure_groups(
|
||||
candidate, master_buffer, private_home, existing_lines, child_timeout
|
||||
)
|
||||
arguments = [
|
||||
"add", "-q", "-p", "-u", ENTRY_USERNAME, "--url", ENTRY_URL,
|
||||
"--notes", notes, candidate, ENTRY_PATH,
|
||||
]
|
||||
else:
|
||||
arguments = [
|
||||
"edit", "-q", "-p", "-t", ENTRY_TITLE, "-u", ENTRY_USERNAME,
|
||||
"--url", ENTRY_URL, "--notes", notes, candidate, ENTRY_PATH,
|
||||
]
|
||||
run_keepass(
|
||||
arguments, master_buffer, private_home, webhook=webhook,
|
||||
timeout=child_timeout,
|
||||
)
|
||||
if not verify_database(
|
||||
candidate, master_buffer, webhook, app_name, private_home, child_timeout
|
||||
):
|
||||
raise VaultContractError("candidate verification failed")
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def verify_database(
|
||||
database: str,
|
||||
master_buffer: bytearray,
|
||||
webhook: RetainedWebhook,
|
||||
app_name: str,
|
||||
private_home: str,
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
) -> bool:
|
||||
return classify_database(
|
||||
database, master_buffer, webhook, app_name, private_home, child_timeout
|
||||
) == "exact-noop"
|
||||
|
||||
|
||||
def _attest_runtime_root(runtime_root: str) -> str:
|
||||
if not os.path.isabs(runtime_root):
|
||||
raise VaultContractError("absolute runtime root required")
|
||||
canonical = os.path.realpath(runtime_root)
|
||||
if canonical != os.path.abspath(runtime_root):
|
||||
raise VaultContractError("runtime root must be canonical")
|
||||
expected_parent = f"/run/user/{os.getuid()}"
|
||||
if os.path.commonpath((canonical, expected_parent)) != expected_parent or canonical == expected_parent:
|
||||
raise VaultContractError("runtime root is outside the private runtime")
|
||||
fd = os.open(canonical, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
|
||||
raise VaultContractError("runtime root metadata mismatch")
|
||||
finally:
|
||||
os.close(fd)
|
||||
mount_type = None
|
||||
best_length = -1
|
||||
with open("/proc/self/mountinfo", "r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
left, right = line.rstrip("\n").split(" - ", 1)
|
||||
mount_point = left.split()[4].replace("\\040", " ")
|
||||
if canonical == mount_point or canonical.startswith(mount_point.rstrip("/") + "/"):
|
||||
if len(mount_point) > best_length:
|
||||
mount_type = right.split()[0]
|
||||
best_length = len(mount_point)
|
||||
if mount_type != "tmpfs":
|
||||
raise VaultContractError("runtime root is not on tmpfs")
|
||||
return canonical
|
||||
|
||||
|
||||
def _retained_runtime_path(runtime_fd: int) -> str:
|
||||
info = os.fstat(runtime_fd)
|
||||
if not stat.S_ISDIR(info.st_mode):
|
||||
raise VaultContractError("retained runtime is not a directory")
|
||||
proc_path = f"/proc/{os.getpid()}/fd/{runtime_fd}"
|
||||
link_info = os.lstat(proc_path)
|
||||
if not stat.S_ISLNK(link_info.st_mode):
|
||||
raise VaultContractError("retained runtime proc anchor is unavailable")
|
||||
resolved = os.stat(proc_path)
|
||||
if (resolved.st_dev, resolved.st_ino) != (info.st_dev, info.st_ino):
|
||||
raise VaultContractError("retained runtime proc anchor mismatch")
|
||||
return proc_path
|
||||
|
||||
|
||||
def _open_snapshot_below_runtime(
|
||||
path: str, runtime_root: str, runtime_fd: int
|
||||
) -> int:
|
||||
if not os.path.isabs(path) or os.path.normpath(path) != path:
|
||||
raise VaultProtocolError("snapshot must be canonical")
|
||||
relative = os.path.relpath(path, runtime_root)
|
||||
parts = relative.split(os.sep)
|
||||
if not parts or any(
|
||||
not part or part in {".", ".."} or os.sep in part for part in parts
|
||||
):
|
||||
raise VaultProtocolError("snapshot is outside runtime root")
|
||||
current_fd = os.dup(runtime_fd)
|
||||
try:
|
||||
for part in parts[:-1]:
|
||||
next_fd = os.open(
|
||||
part,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
dir_fd=current_fd,
|
||||
)
|
||||
os.close(current_fd)
|
||||
current_fd = next_fd
|
||||
directory_info = os.fstat(current_fd)
|
||||
if (
|
||||
directory_info.st_uid != os.getuid()
|
||||
or stat.S_IMODE(directory_info.st_mode) != 0o700
|
||||
):
|
||||
raise VaultProtocolError("snapshot directory metadata mismatch")
|
||||
return os.open(
|
||||
parts[-1],
|
||||
os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
dir_fd=current_fd,
|
||||
)
|
||||
finally:
|
||||
os.close(current_fd)
|
||||
|
||||
|
||||
def _attest_snapshot(
|
||||
path: str, runtime_root: str, runtime_fd: int | None = None
|
||||
) -> None:
|
||||
if runtime_fd is not None:
|
||||
fd = _open_attested_snapshot(path, runtime_root, runtime_fd)
|
||||
try:
|
||||
return
|
||||
finally:
|
||||
os.close(fd)
|
||||
if not os.path.isabs(path) or os.path.realpath(path) != os.path.abspath(path):
|
||||
raise VaultProtocolError("snapshot must be canonical")
|
||||
if os.path.commonpath((path, runtime_root)) != runtime_root or path == runtime_root:
|
||||
raise VaultProtocolError("snapshot is outside runtime root")
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
try:
|
||||
_regular_metadata(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _open_attested_snapshot(path: str, runtime_root: str, runtime_fd: int) -> int:
|
||||
fd = _open_snapshot_below_runtime(path, runtime_root, runtime_fd)
|
||||
try:
|
||||
_regular_metadata(fd)
|
||||
return fd
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def read_protocol_line(connection: socket.socket, *, timeout: float) -> str:
|
||||
connection.settimeout(timeout)
|
||||
data = bytearray()
|
||||
try:
|
||||
while len(data) <= MAX_PROTOCOL_BYTES:
|
||||
chunk = connection.recv(MAX_PROTOCOL_BYTES + 2 - len(data))
|
||||
if not chunk:
|
||||
raise VaultProtocolError("partial protocol EOF")
|
||||
data.extend(chunk)
|
||||
if b"\n" in data:
|
||||
break
|
||||
if len(data) > MAX_PROTOCOL_BYTES + 1:
|
||||
raise VaultProtocolError("protocol request is oversized")
|
||||
if not data.endswith(b"\n") or data.count(b"\n") != 1:
|
||||
raise VaultProtocolError("invalid protocol framing")
|
||||
line = data[:-1]
|
||||
if b"\r" in line or b"\0" in line:
|
||||
raise VaultProtocolError("invalid protocol bytes")
|
||||
try:
|
||||
return line.decode("utf-8", "strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise VaultProtocolError("invalid protocol UTF-8") from exc
|
||||
finally:
|
||||
wipe_mutable_buffer(data)
|
||||
|
||||
|
||||
def parse_protocol_command(line: str) -> tuple[str, str | None]:
|
||||
if line in {"PREPARE", "VERIFY_CANDIDATE", "SHUTDOWN"}:
|
||||
return line, None
|
||||
if line in {"BUILD ADD", "BUILD UPDATE"}:
|
||||
return "BUILD", line.split(" ", 1)[1]
|
||||
prefix = "VERIFY_COMMITTED "
|
||||
if line.startswith(prefix):
|
||||
path = line[len(prefix):]
|
||||
if not path or " " in path or not os.path.isabs(path):
|
||||
raise VaultProtocolError("invalid committed snapshot command")
|
||||
return "VERIFY_COMMITTED", path
|
||||
raise VaultProtocolError("unknown protocol command")
|
||||
|
||||
|
||||
def peer_uid(connection: socket.socket) -> int:
|
||||
credentials = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||
_, uid, _ = struct.unpack("3i", credentials)
|
||||
return uid
|
||||
|
||||
|
||||
def _write_reply(connection: socket.socket, reply: str, timeout: float) -> None:
|
||||
payload = reply.encode("ascii") + b"\n"
|
||||
connection.settimeout(timeout)
|
||||
connection.sendall(payload)
|
||||
|
||||
|
||||
def serve_private_socket(
|
||||
runtime_root: str,
|
||||
webhook_file: str,
|
||||
*,
|
||||
input_fd: int | None = None,
|
||||
output_fd: int | None = None,
|
||||
accept_timeout: float = DEFAULT_ACCEPT_TIMEOUT,
|
||||
io_timeout: float = DEFAULT_IO_TIMEOUT,
|
||||
child_timeout: float = DEFAULT_CHILD_TIMEOUT,
|
||||
wipe_hook: Callable[[bytearray], None] | None = None,
|
||||
) -> None:
|
||||
if child_timeout <= 0:
|
||||
raise VaultContractError("invalid child timeout")
|
||||
runtime = ""
|
||||
runtime_fd = -1
|
||||
runtime_access = ""
|
||||
webhook = None
|
||||
master = bytearray()
|
||||
listener = None
|
||||
socket_identity = None
|
||||
try:
|
||||
runtime = _attest_runtime_root(runtime_root)
|
||||
runtime_fd = os.open(
|
||||
runtime, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
)
|
||||
runtime_access = _retained_runtime_path(runtime_fd)
|
||||
webhook = open_validated_webhook(webhook_file)
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
socket_path = os.path.join(runtime_access, SOCKET_NAME)
|
||||
private_home = os.devnull
|
||||
baseline = os.path.join(runtime_access, BASELINE_NAME)
|
||||
candidate = os.path.join(runtime_access, CANDIDATE_NAME)
|
||||
app_name, master = read_operator_secrets(input_fd=input_fd, output_fd=output_fd)
|
||||
previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, HANDLED_SIGNALS)
|
||||
try:
|
||||
listener.bind(socket_path)
|
||||
os.chmod(SOCKET_NAME, 0o600, dir_fd=runtime_fd, follow_symlinks=False)
|
||||
socket_info = os.stat(
|
||||
SOCKET_NAME, dir_fd=runtime_fd, follow_symlinks=False
|
||||
)
|
||||
socket_identity = (socket_info.st_dev, socket_info.st_ino)
|
||||
listener.listen(1)
|
||||
finally:
|
||||
signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
|
||||
listener.settimeout(accept_timeout)
|
||||
connection, _ = listener.accept()
|
||||
listener.close()
|
||||
if peer_uid(connection) != os.getuid():
|
||||
connection.close()
|
||||
raise VaultContractError("socket peer UID mismatch")
|
||||
state = "initial"
|
||||
classification = None
|
||||
with connection:
|
||||
while True:
|
||||
try:
|
||||
command, argument = parse_protocol_command(
|
||||
read_protocol_line(connection, timeout=io_timeout)
|
||||
)
|
||||
if command == "PREPARE":
|
||||
if state != "initial":
|
||||
raise VaultProtocolError("PREPARE out of state")
|
||||
classification = classify_database(
|
||||
baseline, master, webhook, app_name, private_home,
|
||||
child_timeout,
|
||||
)
|
||||
state = "prepared"
|
||||
_write_reply(connection, classification, io_timeout)
|
||||
elif command == "BUILD":
|
||||
if state != "prepared" or (
|
||||
argument == "ADD" and classification != "absent"
|
||||
) or (
|
||||
argument == "UPDATE" and classification != "mismatch"
|
||||
):
|
||||
raise VaultProtocolError("BUILD out of state")
|
||||
try:
|
||||
build_candidate(
|
||||
baseline, candidate, argument or "", master,
|
||||
webhook, app_name, private_home, child_timeout,
|
||||
)
|
||||
except (OSError, VaultContractError):
|
||||
_write_reply(connection, "failed", io_timeout)
|
||||
continue
|
||||
state = "candidate-ready"
|
||||
_write_reply(connection, "candidate-ready", io_timeout)
|
||||
elif command == "VERIFY_CANDIDATE":
|
||||
if state != "candidate-ready":
|
||||
raise VaultProtocolError("VERIFY_CANDIDATE out of state")
|
||||
verified = verify_database(
|
||||
candidate, master, webhook, app_name, private_home,
|
||||
child_timeout,
|
||||
)
|
||||
if verified:
|
||||
state = "candidate-verified"
|
||||
_write_reply(connection, "verified" if verified else "failed", io_timeout)
|
||||
elif command == "VERIFY_COMMITTED":
|
||||
if state != "candidate-verified" or argument is None:
|
||||
raise VaultProtocolError("VERIFY_COMMITTED out of state")
|
||||
snapshot_fd = _open_attested_snapshot(
|
||||
argument, runtime, runtime_fd
|
||||
)
|
||||
try:
|
||||
verified = verify_database(
|
||||
f"/proc/{os.getpid()}/fd/{snapshot_fd}",
|
||||
master,
|
||||
webhook,
|
||||
app_name,
|
||||
private_home,
|
||||
child_timeout,
|
||||
)
|
||||
finally:
|
||||
os.close(snapshot_fd)
|
||||
_write_reply(connection, "verified" if verified else "failed", io_timeout)
|
||||
elif command == "SHUTDOWN":
|
||||
_write_reply(connection, "stopped", io_timeout)
|
||||
break
|
||||
except VaultProtocolError:
|
||||
try:
|
||||
_write_reply(connection, "failed", io_timeout)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
if listener is not None:
|
||||
listener.close()
|
||||
finally:
|
||||
try:
|
||||
if webhook is not None:
|
||||
webhook.close()
|
||||
finally:
|
||||
try:
|
||||
wipe_mutable_buffer(master)
|
||||
if wipe_hook is not None:
|
||||
wipe_hook(master)
|
||||
finally:
|
||||
try:
|
||||
if socket_identity is not None:
|
||||
try:
|
||||
current = os.stat(
|
||||
SOCKET_NAME, dir_fd=runtime_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
if (current.st_dev, current.st_ino) == socket_identity:
|
||||
os.unlink(SOCKET_NAME, dir_fd=runtime_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
finally:
|
||||
if runtime_fd >= 0:
|
||||
os.close(runtime_fd)
|
||||
|
||||
|
||||
def _main(argv: Sequence[str]) -> int:
|
||||
if len(argv) != 5 or argv[0] != "--serve" or argv[1] != "--runtime-root" or argv[3] != "--webhook-file":
|
||||
return 2
|
||||
runtime_root, webhook_file = argv[2], argv[4]
|
||||
if not os.path.isabs(runtime_root) or not os.path.isabs(webhook_file):
|
||||
return 2
|
||||
install_process_signal_handlers()
|
||||
try:
|
||||
serve_private_socket(runtime_root, webhook_file)
|
||||
return 0
|
||||
except VaultSignal as exc:
|
||||
return 128 + exc.signum
|
||||
except (OSError, VaultContractError):
|
||||
os.write(2, b"vault helper failed\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user