enprot 0.5.12

Engyon Protected Text (EPT) — confidentiality processor and capability ledger
# Quickstart cookbook

End-to-end recipes for getting real work done with enprot. Every
recipe is a copy-pasteable shell transcript you can run from a clone
of this repo.

For scenario-focused walkthroughs see the sibling cookbooks:
[`classified-documents.md`](classified-documents.md),
[`collaborative-editing.md`](collaborative-editing.md),
[`supply-chain.md`](supply-chain.md).

Recipes here are grouped by use case:

- [Setup]#setup
- [Single-file workflows]#single-file-workflows
- [Trees and CI]#trees-and-ci
- [Git integration]#git-integration
- [CAS-only flow]#cas-only-flow
- [Deterministic encryption for dedup]#deterministic-encryption-for-dedup
- [Multi-recipient PQ encryption]#multi-recipient-pq-encryption
- [Migrating from SOPS]#migrating-from-sops
- [Locking secrets in a Docker image]#locking-secrets-in-a-docker-image
- [Encrypt-on-commit hook]#encrypt-on-commit-hook
- [From Python]#from-python
- [From Node.js]#from-nodejs

---

## Setup

```sh
brew install botan rnp                       # macOS
brew link --overwrite rnp                    # if it conflicts
cargo install --locked --path . --features vendored-rnp
enprot --version
```

Linux: `./ci/install.sh` builds Botan + librnp from source.

A WORD is the secret-name you bind to a password. Every encrypted
block in a file is tagged with a WORD; only callers who hold the
matching password can decrypt it. Treat WORDs like git branch names:
short, scoped, lowercase (`secret`, `prodcreds`, `agent_007`).

---

## Single-file workflows

### Encrypt one segment, leave the rest readable

```sh
cat > app.conf <<'EOF'
# deployed config
db_host = db.example.com
db_port = 5432

# <( BEGIN SECRET )>
db_password = "hunter2"   # sensitive
# <( END SECRET )>

pool_size = 100
EOF

# Encrypt the SECRET block in place. Same password would have to
# decrypt later.
enprot encrypt -w SECRET=hunter2 app.conf

# app.conf now has:
#   # <( ENCRYPTED SECRET )>
#   # <( DATA 7Z2K... )>
#   # <( END SECRET )>
# Plaintext outside the BEGIN/END block is untouched.

enprot decrypt -w SECRET=hunter2 app.conf    # round-trip
```

### Use a different cipher per call

```sh
enprot encrypt -w SECRET=pw --cipher aes-256-gcm-siv  app.conf
enprot decrypt -w SECRET=pw                          app.conf
```

Supported ciphers: `aes-256-siv` (default), `aes-256-gcm`,
`aes-256-gcm-siv`, plus the `-det` variants for dedup-friendly output.

### Prompt for the password instead of putting it on the CLI

```sh
enprot encrypt -w SECRET app.conf         # no =VALUE → interactive prompt
```

Skips the verification echo when stdin isn't a TTY (CI-friendly).

---

## Trees and CI

### Encrypt every file matching a glob

```sh
enprot encrypt -w SECRET=pw secrets/*.toml secrets/*.yaml
```

enprot processes each file independently. A failure on file N aborts
the run; partial output is left in files 1..N-1.

### Decrypt in CI, never commit plaintext

```yaml
# .github/workflows/test.yml
- uses: engyon/enprot/action@v0.5
  with:
    operation: decrypt
    files: tests/fixtures/secrets.toml
    words: SECRET=${{ secrets.ENPROT_SECRET_WORD }}
- run: pytest                                  # plaintext lives only in CI
```

The plaintext never lands on disk in the repo. After the workflow
finishes, the GitHub Actions runner is destroyed.

### Encrypt-on-publish workflow

```yaml
on:
  push:
    branches: [release/*]
jobs:
  encrypt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: engyon/enprot/action@v0.5
        with:
          operation: encrypt
          files: |
            config/prod.toml
            config/staging.toml
          words: |
            PROD=${{ secrets.ENPROT_PROD_WORD }},
            STAGING=${{ secrets.ENPROT_STAGING_WORD }}
      - uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: "chore: encrypt PROD/STAGING blocks for release"
```

---

## Git integration

### Install the merge driver

```sh
# .gitattributes
*.ept  filter=enprot diff=enprot merge=enprot
*.toml filter=enprot diff=enprot merge=enprot

# .git/config (one-time)
git config filter.enprot.clean    "enprot encrypt-store -w SECRET=%ENPROT_WORD"
git config filter.enprot.smudge   "enprot decrypt       -w SECRET=%ENPROT_WORD"
git config diff.enprot.textconv   "enprot decrypt       -w SECRET=%ENPROT_WORD"
git config merge.enprot.driver    "enprot merge-driver %O %A %B %L %P"
```

The driver emits `CONFLICT` blocks instead of `<<<<<<<` markers, so
the merged file stays valid host-language source. Run `enprot resolve`
to clear conflict blocks.

### Mark a region IMMUTABLE so merges can't change it

```
// <( IMMUTABLE VERSION )>
v1.0.0
// <( END IMMUTABLE )>
```

Any branch that changes the body of an `IMMUTABLE` block will fail
`enprot verify` — useful for pinning audit-relevant metadata.

---

## CAS-only flow

For content you want to *delete* from the file but still reference.

### Strip a secret out of the file, leave a CAS pointer

```sh
# Before: file contains plaintext inside a BEGIN/END block.
enprot store -w SECRET=pw app.conf
# After:  file contains
#   # <( STORED SECRET key=abc123... )>
#   # <( END SECRET )>
# The plaintext is written to .cas/abc123... (named by SHA3-256 of bytes).
# Commit the file freely; the secret lives in .cas/ which is .gitignored.

enprot fetch -w SECRET=pw app.conf          # restore on demand
```

### Share CAS pointers across branches

`.cas/` is content-addressed — if two branches independently store
the same plaintext, they produce the same `key=...` hash. Merging
those branches is a no-op for the STORED block. No conflicts.

---

## Deterministic encryption for dedup

The `-det` cipher variants derive the nonce from the plaintext via
HKDF + HMAC. Same `(password, plaintext)` always produces the same
ciphertext.

```sh
# Two files with the same plaintext in SECRET blocks → identical DATA.
enprot encrypt -w SECRET=pw --cipher aes-256-gcm-siv-det a.toml b.toml

# Git diff sees no change when you encrypt then re-encrypt the same content.
```

Use this for:

- Secret rotation where you want to spot which files actually changed
- CAS-dedup of encrypted blobs
- Reproducible builds

---

## Multi-recipient PQ encryption

```sh
# Generate an ML-KEM keypair for each recipient.
enprot keygen --alg ml-kem --out-priv priv_abe.pem --out-pub pub_abe.pem
enprot keygen --alg ml-kem --out-priv priv_bea.pem --out-pub pub_bea.pem

# Encrypt so that EITHER recipient can decrypt.
enprot encrypt \
  -w SECRET=pw \
  --recipient pub_abe.pem \
  --recipient pub_bea.pem \
  app.conf

# Abe decrypts with her private key.
enprot decrypt -w SECRET=pw --recipient-priv priv_abe.pem app.conf
```

ML-KEM is the NIST FIPS 203 post-quantum KEM. Composite
Ed25519+ML-KEM constructions are also supported.

---

## Migrating from SOPS

If you have a SOPS-encrypted YAML file:

```sh
python3 tools/import-sops.py secrets.sops.yaml -o secrets.ept
```

Each encrypted value in the SOPS file becomes a `BEGIN WORD` block
tagged with the SOPS key path. After import, treat it like any other
EPT file. See [`tools/import-sops.py`](../tools/import-sops.py) for
the full README.

---

## Locking secrets in a Docker image

```dockerfile
FROM ghcr.io/engyon/enprot:latest AS encrypt
WORKDIR /work
COPY config/plaintext/ ./config/
RUN enprot encrypt -w SECRET=$ENPROT_WORD --casdir /cas config/*.toml && \
    rm -rf config/*.toml.bak

FROM app:latest
COPY --from=encrypt /work/config/   /etc/app/config/
COPY --from=encrypt /cas/           /var/lib/enprot/cas/
ENV ENPROT_CASDIR=/var/lib/enprot/cas
```

The final image contains only encrypted segments and CAS pointers.
Plaintext lives only in the build-stage layer, which Docker discards.

---

## Encrypt-on-commit hook

```sh
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/engyon/enprot
    rev: v0.5.11
    hooks:
      - id: enprot-plaintext
        name: no plaintext inside BEGIN blocks
        entry: python3 hooks/pre-commit/enprot_pre_commit.py
        language: system
        types: [text]
```

Fails the commit if a `BEGIN WORD ... END WORD` block contains
plaintext that hasn't been encrypted or CAS-stored yet. Catches the
"committed the raw password" footgun.

---

## From Python

```python
import pyenprot

pyenprot.encrypt(
    "config.toml",
    words={"SECRET": "correct horse battery staple"},
    cipher="aes-256-siv",
    casdir=".cas",
)
```

See [`bindings/python/`](../bindings/python/) for install + full API.

---

## From Node.js

```js
const enprot = require("@engyon/enprot");

enprot.encrypt("config.toml", {
  words: { SECRET: "correct horse battery staple" },
  cipher: "aes-256-siv",
  casdir: ".cas",
});
```

See [`bindings/nodejs/`](../bindings/nodejs/) for install + full API.

---

## See also

- [README]../README.md — overview, install, command reference
- [`classified-documents.md`]classified-documents.md — multi-level classification in one file
- [`collaborative-editing.md`]collaborative-editing.md — multi-author signing
- [`supply-chain.md`]supply-chain.md — provenance manifests
- [CONTRIBUTING.md]../CONTRIBUTING.md — dev setup