Metadata-Version: 2.4
Name: libseal
Version: 1.1.2
Summary: Python library that seals a directory into one password-protected .libseal file, then opens, mounts, and imports the modules.
License-Expression: MIT
Keywords: python,encryption,password,directory,aes-256-gcm,scrypt,modules,libseal
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=42
Dynamic: license-file

# libseal

libseal is a Python library that encrypts a directory into one password-protected `.libseal` file. You can open that file back into a folder, or mount it and import the Python modules inside.

Use it when a library should stay encrypted on disk or in an artifact store, and should be decrypted only when your program asks for it. Encryption uses the [`cryptography`](https://cryptography.io/) package, built on OpenSSL. Each sealed file is authenticated, so a wrong password or an edited file is rejected. The key comes from `hashlib.scrypt`, and the packed directory is compressed with `gzip`.

Anyone who holds both the file and the password can read the contents. After a successful open or load, the plaintext is available to your process.

## Requirements

- Python 3.9 or newer
- `cryptography` 42 or newer, installed automatically with this package

## Install

```bash
pip install libseal
```

The distribution provides a `libseal` command and the same operations as a Python API.

## Quick start

`examples/library/greeter.py` defines `hello(name)`. This seals that directory, mounts it beside the caller, and imports `greeter`:

```python
from pathlib import Path
from libseal import load_sealed_sync, seal_sync

root = Path(__file__).resolve().parent
password = "sample-password"

seal_sync(
    source=root / "library",
    to=root / "greeter.libseal",
    password=password,
)

greeter = load_sealed_sync(
    file=root / "greeter.libseal",
    module_path="greeter",
    password=password,
    root=root,
)

print(greeter.hello("Ada"))
```

`examples/sample.py` runs that program and prints `Hello, Ada.`

`source` is the directory to pack. `module_path` is a file inside the mounted library. The `.py` suffix is optional. The install folder is taken from the sealed file name: `greeter.libseal` becomes `modules/greeter/`. Pass `root` to choose the directory that contains `modules`. Leave `root` unset and the files are written inside the libseal package installed for the current Python. A virtual environment uses its own `site-packages`. A source checkout is not used.

The first load decrypts the file and writes that folder. A later load finds the folder already there, imports from it, and does not decrypt again. Pass `force=True` when that folder should be replaced.

The sample password is an example. Use your own.

## What is stored

Sealing walks the source directory and packs regular files. Paths inside the archive use `/` separators, and entries are stored in sorted path order.

- File bytes are stored unchanged.
- Permission bits are stored as `mode & 0o777`. Restoring them is best-effort on filesystems that do not keep Unix modes.
- Empty directories are omitted.
- Symbolic links are omitted and are not followed.
- A path longer than 65,535 UTF-8 bytes, or a file larger than 4,294,967,295 bytes, is rejected.

Sealing the same directory twice produces two different files. Each seal chooses a new salt and a new IV. The password is not written into the file.

Opening extracts into a temporary directory in the destination's parent. The temporary directory replaces the destination only after every file has been written. If decryption or extraction fails, the destination is left as it was and the temporary directory is removed.

## Password

Supply the password in one of these ways. Lookup stops at the first match:

1. The `password` argument, when it is a non-empty string.
2. The `LIBSEAL_PASSWORD` environment variable, when it is a non-empty string.
3. A hidden terminal prompt. This step runs only for the async functions and the `libseal` command.

An empty string does not count, so lookup continues. `seal_sync`, `open_sync`, `mount_sync`, and `load_sealed_sync` do not prompt. Call them with `password`, or set `LIBSEAL_PASSWORD` first. If neither is set, they raise `LibsealError`.

The command never accepts the password as a flag. A value typed at the prompt is not echoed.

```bash
# Windows
set LIBSEAL_PASSWORD=open-sesame

# Unix
export LIBSEAL_PASSWORD=open-sesame
```

If stdin is not a terminal and no password was provided, the prompt raises `LibsealError` instead of reading a visible line.

## Seal and open

```python
from libseal import open_sync, seal_sync

sealed = seal_sync(
    source="./library",
    to="./greeter.libseal",
    password="sample-password",
)
restored = open_sync(
    file="./greeter.libseal",
    into="./restored",
    password="sample-password",
)
```

`seal_sync` returns a dict:

| Key | Meaning |
| --- | --- |
| `to` | Absolute path of the sealed file |
| `bytes` | Size of that file, in bytes |

`open_sync` returns a dict:

| Key | Meaning |
| --- | --- |
| `into` | Absolute path of the restored directory |

`seal_sync` creates missing parent directories of `to` and writes the sealed file by creating a temporary file, then replacing the destination. The sealed file is created with mode `0o600`, subject to the process umask.

`open_sync` creates missing parent directories of `into`. The restored directory is then given mode `0o700` when the filesystem allows it.

### Replacing an existing directory

`force` defaults to `False`.

- If `into` is missing, or exists and is empty, extraction proceeds.
- If `into` already contains any entry and `force` is false, `LibsealError` is raised and the directory is not modified.
- If `force` is true, the existing path is removed after a successful extract and replaced by the restored tree.

```python
open_sync(
    file="./greeter.libseal",
    into="./restored",
    password="sample-password",
    force=True,
)
```

### Key cost

`scrypt` overrides the cost parameters stored in the file:

```python
seal_sync(
    source="./library",
    to="./greeter.libseal",
    password="sample-password",
    scrypt={"N": 16384, "r": 8, "p": 1},
)
```

| Parameter | Default | Allowed values |
| --- | --- | --- |
| `N` | `16384` | Power of two from `1024` through `1048576` |
| `r` | `8` | Integer from `1` through `32` |
| `p` | `1` | Integer from `1` through `16` |

Omitted keys keep the default. Opening reads the cost from the file, so the same `scrypt` dict is not required again. A value outside those bounds raises `LibsealError` before any file is written. Higher `N` uses more memory and CPU. The memory ceiling passed to `hashlib.scrypt` is `256 * N * r`.

`LibsealError` is also raised when `source` is missing or is not a directory, or when `source` or `to` is empty.

## Mount and import

Mounting opens a sealed file into its own folder, so a second library does not overwrite the first.

```text
modules/
  greeter/
    greeter.py
    helper.py
  billing/
    quote.py
```

```python
from libseal import mount_sync, read_module

mounted = mount_sync(
    file="./greeter.libseal",
    password="sample-password",
    root=".",
)

greeter = read_module("greeter", store=mounted["install_dir"])
print(greeter.hello("Ada"))
print(mounted["modules"])
```

`mount_sync` returns a dict:

| Key | Meaning |
| --- | --- |
| `store` | Absolute path of the store directory |
| `name` | Library folder name |
| `install_dir` | Absolute path of `store/name` |
| `modules` | Names found directly inside `install_dir` |

### Mount options

| Argument | Default | Meaning |
| --- | --- | --- |
| `file` | required | Path to the `.libseal` file |
| `password` | `LIBSEAL_PASSWORD`, then a prompt on `mount` | Password for that file |
| `root` | The installed libseal package | Directory that contains the store when `store` is relative |
| `store` | `"modules"` | Store folder name, or an absolute path. An absolute `store` ignores `root` |
| `name` | File name without its extension | Subfolder created inside the store. `greeter.libseal` yields `greeter` |
| `force` | `False` | Leave an install folder that already has files, and skip decryption. `True` replaces that folder |

`name` must be a relative name with no empty, `.`, or `..` segment. An absolute name is rejected.

### Loading a module

`read_module(module_path, *, root=None, store=None, reload=False)` loads one file and returns it.

- A `.py` file is executed and returned as a module object.
- A `.json` file is parsed and returned as the resulting object.
- The suffix may be omitted. Resolution tries the exact file, then `.py`, then `.json`, then `__init__.py` or `index.py` inside a directory.
- Absolute paths and any `.` or `..` segment are rejected.
- The resolved file must stay inside the store. A missing store raises `LibsealError` and tells you to mount a library first.
- `reload=True` executes the file again. Otherwise a later call returns the cached module.

`load_sealed_sync` mounts the file and then calls `read_module` with `reload=True`. Its `module_path` is relative to the installed library, so `"greeter"` means `greeter.py` inside `modules/greeter/`, not the `modules/greeter` directory itself.

```python
from libseal import load_sealed_sync

greeter = load_sealed_sync(
    file="./greeter.libseal",
    module_path="greeter",
    password="sample-password",
    root=".",
)
```

The directory containing a loaded `.py` file is appended to `sys.path`, so that module can import another module sealed next to it. Bytecode is not written beside the mounted sources.

Loaded code runs in the current process. Mount and import libraries you sealed yourself. Nothing is decrypted until `open`, `mount`, or `load_sealed` is called. If that library's install folder already contains files, `mount` and `load_sealed` use those files and do not read the sealed file again. No password is required for that later call. `force=True` decrypts and replaces the folder. `open` still refuses a destination that already has files unless `force` is true.

### Listing

`list_sync()` returns the library names in the store, sorted. `list_sync(name="greeter")` returns the entries directly inside that library.

A listed name is a directory, or a `.py` or `.json` file with the suffix removed. Other files are still restored; they are omitted from this list. A missing directory returns `[]`.

```python
from libseal import list_sync

list_sync(root=".")
list_sync(root=".", name="greeter")
```

### Releasing a cached module

`release_module(module_path, *, root=None, store=None)` drops one module from the libseal cache. It returns `True` when an entry was removed and `False` when that module was not cached or could not be resolved. The files on disk stay. Mount again with `force=True` to replace them. A mount without `force` keeps the files already on disk.

```python
from libseal import release_module

release_module("greeter", store=mounted["install_dir"])
```

## Asynchronous API

`seal`, `open`, `mount`, `load_sealed`, and `list` are coroutines. They take the same arguments as the sync functions. `seal`, `open`, `mount`, and `load_sealed` can prompt for a password. `list` only reads the store.

```python
import asyncio
from libseal import open, seal

asyncio.run(seal(source="./library", to="./greeter.libseal"))
asyncio.run(open(file="./greeter.libseal", into="./restored"))
```

`open` is the async function. `from libseal import open` shadows the builtin. Use `import libseal` or import `open_sync` when you need the builtin in the same scope.

## Command line

```text
libseal seal <directory> -o <file.libseal>
libseal open <file.libseal> -o <directory> [--force]
libseal mount <file.libseal> [--root <dir>] [--store <dir>] [--name <name>] [--force]
```

```bash
set LIBSEAL_PASSWORD=open-sesame
libseal seal ./library -o greeter.libseal
libseal open greeter.libseal -o ./restored
libseal mount greeter.libseal --root .
```

`--output` is the long form of `-o`. `--no-force` selects the default replace behavior. `--help` and `-h` print usage and exit 0. A missing command prints usage and exits 1. An unknown command or option prints the error and exits 1.

`seal` prints the source, the absolute output path, and the file size. `open` prints the source file and the absolute destination. `mount` prints the install path, the library name, and the module names it found. An empty library prints `Modules: (none)`.

`python -m libseal` runs the same command.

## Errors

Expected failures raise `libseal.LibsealError`. The message names the problem. Typical cases:

| Situation | Result |
| --- | --- |
| Wrong password | Authentication fails. The destination is not created or replaced |
| Truncated file, bad magic, or unsupported version | `LibsealError` before extraction |
| File edited after sealing | Authentication fails |
| `open` destination already has files, without `force` | `LibsealError`. Existing files stay in place |
| `mount` or `load_sealed` install folder already has files, without `force` | Existing files stay. The sealed file is not decrypted again |
| Archive path escapes the destination | `LibsealError`. Nothing outside the destination is written |
| `module_path` missing on load | `LibsealError` |
| Module path is absolute or contains `..` | `LibsealError` |

A failed open removes its temporary directory. A sealed file is replaced only after the temporary file has been written.

## File format

A `.libseal` file is a gzip archive encrypted with AES-256-GCM. The 32-byte key is derived with scrypt. Header integers are little-endian.

| Offset | Size | Field |
| --- | --- | --- |
| 0 | 4 | Magic `LSL1` |
| 4 | 1 | Version `1` |
| 5 | 1 | KDF id `1` (scrypt) |
| 6 | 4 | `N` |
| 10 | 4 | `r` |
| 14 | 4 | `p` |
| 18 | 1 | Salt length |
| 19 | salt length | Salt |
| | 12 | IV |
| | 16 | Authentication tag |
| | rest | Ciphertext |

The decrypted plaintext is a gzip stream. After decompression, the archive begins with magic `LSA1`, one version byte, and a 4-byte file count. Each entry is then:

| Size | Field |
| --- | --- |
| 2 | Path length, in bytes |
| path length | UTF-8 path, using `/` |
| 2 | Permission mode |
| 4 | File size |
| file size | File bytes |

Every extracted path has to stay inside the destination. Empty segments, `.`, `..`, absolute paths, and Windows drive paths are rejected.

## API summary

| Name | Kind | Role |
| --- | --- | --- |
| `seal_sync` / `seal` | function / coroutine | Seal a directory |
| `open_sync` / `open` | function / coroutine | Restore a sealed file |
| `mount_sync` / `mount` | function / coroutine | Restore into the module store |
| `load_sealed_sync` / `load_sealed` | function / coroutine | Mount, then import one module |
| `read_module` | function | Import one module from the store |
| `release_module` | function | Drop one module from the cache |
| `list_sync` / `list` | function / coroutine | List libraries or the entries in one library |
| `resolve_store` | function | Resolve `root` and `store` to an absolute path |
| `LibsealError` | exception | Raised for expected failures |
| `DEFAULT_SCRYPT` | mapping | `{"N": 16384, "r": 8, "p": 1}` |
| `DEFAULT_STORE` | str | `"modules"` |
| `ENV_NAME` | str | `"LIBSEAL_PASSWORD"` |
| `PACKAGE_ROOT` | str | Directory of the installed package |

## Development

From a checkout:

```bash
python -m unittest discover -s tests -v
```

## License

MIT. See [LICENSE](LICENSE).
