lib.ec.secret_store.TarGpg: Add the write path #147

Merged
Jan Lindemann merged 1 commit from jan/feature/20261017-lib-ec-secret-store-targpg-add-the-write-path into master 2026-10-17 13:21:17 +02:00 AGit
2 changed files with 255 additions and 2 deletions

View file

@ -4,14 +4,16 @@ import errno
import io import io
import os import os
import stat import stat
import tempfile
import time import time
from tarfile import TarFile, TarInfo from tarfile import TarFile, TarInfo
from typing import TYPE_CHECKING, Iterable, override from typing import TYPE_CHECKING, Iterable, override
from ...file_types import FT_DIR from ...file_types import FT_DIR
from ...kv import parse_kv from ...kv import parse_kv, serialize_kv
from ...Result import Result from ...Result import Result
from ...Uri import Uri
from .Base import Base from .Base import Base
from .layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY from .layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY
@ -19,7 +21,7 @@ if TYPE_CHECKING:
from typing import Any from typing import Any
from ...base import StatResult from ...base import StatResult
from ...Uri import Uri from ...file_types import FileType
class TarGpg(Base): class TarGpg(Base):
"""A store of secrets in a GPG-encrypted tarball in the canonical """A store of secrets in a GPG-encrypted tarball in the canonical
@ -40,6 +42,10 @@ class TarGpg(Base):
The tarball is fetched and decrypted once, then cached. Fetching, The tarball is fetched and decrypted once, then cached. Fetching,
decrypting, and filtering happen in a local exec context, so the decrypting, and filtering happen in a local exec context, so the
GPG key and the decrypted material stay on this machine. GPG key and the decrypted material stay on this machine.
A write rebuilds the tarball, signs it, and writes it back to
the tarball location, which must be a local path; the recipient
is deployment information, JW_SECRETS_TARGPG_RECIPIENT.
""" """
def __init__(self, uri: str | Uri, *args: Any, **kwargs: Any) -> None: def __init__(self, uri: str | Uri, *args: Any, **kwargs: Any) -> None:
@ -225,3 +231,128 @@ class TarGpg(Base):
async def _stat(self, path: str, follow_symlinks: bool) -> StatResult: async def _stat(self, path: str, follow_symlinks: bool) -> StatResult:
path = self._unchroot(path) path = self._unchroot(path)
return await self._call_with_fallback(path, self.__tar_stat) return await self._call_with_fallback(path, self.__tar_stat)
async def __tar_write(self, added: dict[str, bytes], removed: set[str]) -> None:
# -- Rebuild the tarball: the existing members minus the
# removed, plus the added; the result is signed and
# written back to the tarball location, which must be a
# local path. A missing tarball is an empty store.
try:
tf = await self.__tar()
except FileNotFoundError:
tf = None
data: dict[str, tuple[TarInfo, bytes]] = {}
if tf is not None:
for name, info in {m.name: m for m in tf.getmembers()}.items():
if name in removed:
continue
f = tf.extractfile(info)
if f is None:
raise Exception(f'Can\'t read store member "{name}"')
data[name] = (info, f.read())
buf = io.BytesIO()
with TarFile(fileobj = buf, mode = 'w') as out:
for name, (info, content) in data.items():
out.addfile(info, io.BytesIO(content))
for name, content in added.items():
info = TarInfo(name)
info.size = len(content)
info.mode = 0o400
out.addfile(info, io.BytesIO(content))
u = Uri(self.__tar_uri)
if u.scheme not in ('', 'local'):
raise Exception(f'TarGpg: cannot write to "{self.__tar_uri}"')
recipient = os.environ.get('JW_SECRETS_TARGPG_RECIPIENT', '')
if not recipient:
raise Exception('TarGpg: JW_SECRETS_TARGPG_RECIPIENT is not set')
fd, tar_tmp = tempfile.mkstemp()
with os.fdopen(fd, 'wb') as f:
f.write(buf.getvalue())
from ..Local import Local
await Local().run(
[
'gpg',
'--batch',
'--yes',
'--quiet',
'--no-tty',
'--trust-model',
'always',
'--recipient',
recipient,
'--encrypt',
'--output',
u.path or self.__tar_uri,
tar_tmp,
],
throw = True,
)
os.unlink(tar_tmp)
self.__tf = None
self.__kv_cache.clear()
async def __put_entry(self, entry: str, content: bytes) -> Result:
# -- Replace the entry: the content is the serialized
# entry, so a key not in it is gone
self.__kv_cache.pop(entry, None)
await self.__tar_write({entry: content}, set())
return Result()
async def __put_key(self, entry: str, key: str, content: bytes) -> Result:
# -- Merge the key into the entry, creating the entry if it
# does not exist yet
self.__kv_cache.pop(entry, None)
try:
kv = await self.__entry_kv(entry)
except FileNotFoundError:
kv = {}
kv[key] = content.decode()
await self.__tar_write({entry: serialize_kv(kv)}, set())
return Result()
async def __put_blob(self, path: str, content: bytes) -> Result:
await self.__tar_write({path: content}, set())
return Result()
@override
async def _put(
self,
path: str,
content: bytes,
wd: str | None,
throw: bool,
verbose: bool | None,
title: str,
owner: str | None,
group: str | None,
mode: str | None,
atomic: bool,
ftype: FileType,
) -> Result:
# -- A write does not take the default fallback
path = self._unchroot(path)
storage_path = self._path_in_storage(path)
if not storage_path:
raise ValueError(f'put: "{path}" is not an entry/key or blob path')
if ftype.is_subtype(FT_SECRET):
return await self.__put_entry(storage_path, content)
if ftype.is_subtype(FT_SECRET_KEY):
entry, sep, key = storage_path.rpartition('/')
if not sep or not key:
raise ValueError(f'put: "{path}" is not of the form path/key')
return await self.__put_key(entry, key, content)
if ftype.is_subtype(FT_SECRET_FILE):
return await self.__put_blob(storage_path, content)
raise ValueError(f'put: ftype {ftype} is not a store content type')
@override
async def _unlink(self, path: str) -> None:
# -- Remove the node's member; the path names the node
path = self._unchroot(path)
storage_path = self._path_in_storage(path)
members = await self.__members()
if storage_path in members:
self.__kv_cache.pop(storage_path, None)
await self.__tar_write({}, {storage_path})
else:
raise FileNotFoundError(path)

View file

@ -7,6 +7,11 @@ import subprocess
import tarfile import tarfile
import tempfile import tempfile
from typing import override
from jw.pkg.lib.distros.suse.Distro import Distro as SuseDistro
from jw.pkg.lib.ec.Local import Local
from jw.pkg.lib.ec.secret_store.KvFile import KvFile
from jw.pkg.lib.ec.secret_store.layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY from jw.pkg.lib.ec.secret_store.layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY
from jw.pkg.lib.ec.secret_store.TarGpg import TarGpg from jw.pkg.lib.ec.secret_store.TarGpg import TarGpg
from jw.pkg.lib.file_types import FT_DIR from jw.pkg.lib.file_types import FT_DIR
@ -225,6 +230,123 @@ async def run_tests() -> None:
with open(dst) as f: with open(dst) as f:
assert f.read() == 'a=2\n' assert f.read() == 'a=2\n'
# -- put(): a key put creates its entry and merges into an
# existing one, an entry put replaces the whole, an
# untyped put below an entry lands a key there, and a
# blob put lands a .jw-secret-file; the tarball is
# amended and re-signed in place
os.environ['JW_SECRETS_TARGPG_RECIPIENT'] = 'test@example.com'
store = FileContext.create('targpg://host.example.com')
await store.open()
await store.put('etc/e.jw-secret/e', b'5', ftype = FT_SECRET_KEY)
assert (await store.get('etc/e.jw-secret/e')).stdout == b'5'
await store.put('etc/e.jw-secret/f', b'6', ftype = FT_SECRET_KEY)
assert set(await store.readdir('etc/e.jw-secret')) == {'e', 'f'}
await store.put('etc/a.jw-secret', b'a=9\n', ftype = FT_SECRET)
assert (await store.get('etc/a.jw-secret')).stdout == b'a=9\n'
assert (await store.get('etc/a.jw-secret/a')).stdout == b'9'
await store.put('etc/a.jw-secret/extra', b'x', ftype = FT_SECRET_KEY)
assert set(await store.readdir('etc/a.jw-secret')) == {'a', 'extra'}
await store.put(
'etc/new.pem.jw-secret-file',
b'-----BEGIN NEW-----\n',
ftype = FT_SECRET_FILE
)
assert (await store.get('etc/new.pem.jw-secret-file')).stdout == \
b'-----BEGIN NEW-----\n'
await store.unlink('etc/b.jw-secret')
assert not await store.file_exists('etc/b.jw-secret')
await store.close()
# -- The tarball holds the amended members, still signed;
# an untouched member keeps its content
data = subprocess.run(
['gpg', '--batch', '--quiet', '--decrypt', enc_path],
check = True,
capture_output = True,
env = env,
).stdout
with tarfile.open(fileobj = io.BytesIO(data)) as tf:
names = {m.name for m in tf.getmembers()}
mem = tf.extractfile('default/etc/a.jw-secret')
assert mem is not None
assert mem.read() == b'a=1\n'
assert 'com/example/host/etc/e.jw-secret' in names
assert 'com/example/host/etc/a.jw-secret' in names
assert 'com/example/host/etc/new.pem.jw-secret-file' in names
assert 'com/example/host/etc/b.jw-secret' not in names
# -- A put that changes nothing is a no-op: the pre-read is
# fallback-aware, and no write happens, so no recipient
# is needed; a different content still writes
del os.environ['JW_SECRETS_TARGPG_RECIPIENT']
store = FileContext.create('targpg://host.example.com')
await store.open()
await store.put('etc/e.jw-secret', b'e=5\nf=6\n', ftype = FT_SECRET)
try:
await store.put('etc/e.jw-secret', b'e=5\nf=7\n', ftype = FT_SECRET)
raise AssertionError('expected Exception')
except Exception as e:
assert 'JW_SECRETS_TARGPG_RECIPIENT' in str(e)
await store.close()
os.environ['JW_SECRETS_TARGPG_RECIPIENT'] = 'test@example.com'
# -- A put creates the tarball if it does not exist yet
new_tar = os.path.join(tmp, 'new.tar.gpg')
os.environ['JW_SECRETS_TARGPG_TAR'] = 'local://' + new_tar
store = FileContext.create('targpg://host.example.com')
await store.open()
await store.put('etc/z.jw-secret', b'z=1\n', ftype = FT_SECRET)
assert (await store.get('etc/z.jw-secret')).stdout == b'z=1\n'
await store.close()
assert os.path.isfile(new_tar)
os.environ['JW_SECRETS_TARGPG_TAR'] = 'local://' + enc_path
# -- A store as a copy destination: the walk asks the source
# which node is an entry; entries land as entry members
# below the destination host's prefix, blobs at their
# config path
host3 = os.path.join(tmp, 'host3')
os.makedirs(os.path.join(host3, 'etc'))
with open(os.path.join(host3, 'etc', 'foo.conf.jw-secret'), 'w') as fh:
fh.write('f1=1\nf2=2\n')
with open(os.path.join(host3, 'etc', 'cert.pem.jw-secret-file'), 'w') as fh:
fh.write('-----BEGIN CERT3-----\n')
class FakeDistro(SuseDistro):
def __init__(self, files: list[str]) -> None:
super().__init__(Local(), id = 'suse')
self.files = files
@override
async def _pkg_files(self, names: list[str] | None) -> list[str]:
return self.files
fake = FakeDistro(
[
f'{host3}/etc/foo.conf.jw-tmpl',
f'{host3}/etc/cert.pem.jw-tmpl',
]
)
src = KvFile(Local(), distro = fake)
base = host3.lstrip('/')
ret = await copy(src, 'targpg://host3.example.com', recursive = True)
assert isinstance(ret, list)
# -- The copy transports the on-host file names unchanged
assert set(ret) == {
f'{base}/etc/foo.conf.jw-secret',
f'{base}/etc/cert.pem.jw-secret-file',
}
tstore = FileContext.create('targpg://host3.example.com')
await tstore.open()
assert set(await tstore.readdir(f'{base}/etc/foo.conf.jw-secret')) == \
{'f1', 'f2'}
assert (await tstore.get(f'{base}/etc/foo.conf.jw-secret/f1')).stdout == \
b'1'
assert (await tstore.get(f'{base}/etc/cert.pem.jw-secret-file')).stdout == \
b'-----BEGIN CERT3-----\n'
await tstore.close()
def main() -> None: def main() -> None:
if not shutil.which('gpg'): if not shutil.which('gpg'):
print('Skipping secrets targpg store tests (gpg required)') print('Skipping secrets targpg store tests (gpg required)')