SOPS + age + YubiKey

Secure system/data separation with SOPS, age, and YubiKey

A reproducible, hardware-backed method to separate executable system logic from sensitive configuration data, while keeping all secrets encrypted at rest in Git.

Goal

The objective of this architecture is to split a deployed platform into two repositories:

RepositoryPurpose
systemScripts, installers, services, deployment logic, executable code
dataConfiguration files, keys, tokens, secrets, sensitive runtime data

The system repository may remain plain Git. The data repository must be encrypted before commit. This ensures that secrets are never stored in plaintext in version control, while still allowing deterministic deployment on the target system.

Architecture overview

Developer machine Edit files Encrypt data before push system repo Scripts, services, installers No secrets data repo Encrypted .sops files only Config + secrets YubiKey primary Hardware age identity Private key never leaves token YubiKey backup Second hardware recipient Same files remain decryptable Target system Pulls system repo + data repo Decrypts data locally using inserted YubiKey

Repository layout

The recommended layout for the encrypted data repository is:

data/
├── README.md
├── .sops.yaml
│
├── system-core/
│   └── config.conf.sops
│
├── feature-wireguard/
│   └── wg0.conf.sops
│
├── feature-dnscrypt/
│   └── dnscrypt-proxy.toml.sops
│
├── syncblock/
│   ├── codeberg.token.sops
│   └── sync-adlists.conf.sops
│
├── host-hardening-ssh/
│   └── ssh_host_ed25519_key.sops
│
└── bootstrap/
    └── bootstrap-secrets.sops

The naming rule is:

Name the encrypted file after the real deployed file, then append .sops.

Example:

Encrypted repo fileRuntime target path
system-core/config.conf.sops/etc/system/config.conf
syncblock/codeberg.token.sops/etc/syncblock/codeberg.token

Technical encryption model

Encryption is performed with SOPS, using age recipients. The private key material is not file-based. Instead, it is stored inside two separate YubiKeys. Both recipients are added to the same SOPS rule set, so any encrypted file can be decrypted with either hardware token.

A minimal .sops.yaml for this model is:

creation_rules:
  - path_regex: \.sops$
    age:
      - <PRIMARY_YUBIKEY_RECIPIENT>
      - <BACKUP_YUBIKEY_RECIPIENT>

Important notes:

  • path_regex: \.sops$ means every file ending in .sops will be encrypted using the listed recipients.
  • No encrypted_regex is used here. That option is mainly for structured documents such as Kubernetes manifests and is not appropriate for ordinary config files, tokens, or keys.
  • Multiple age recipients create true redundancy: both YubiKeys must be tested against the same encrypted file.

Correct YubiKey setup procedure

This procedure reproduces the working setup that was validated here. It is intentionally plugin-native. Do not mix provisioning methods. Once using age-plugin-yubikey, do not manually provision age keys with ykman piv keys generate or yubico-piv-tool for the actual age identity slots.

Prerequisites on the target system

sudo apt update
sudo apt install -y age sops pcscd pcsc-tools yubikey-manager yubico-piv-tool cargo rustc pkg-config libpcsclite-dev
sudo systemctl enable --now pcscd

Build and install age-plugin-yubikey:

cargo install --locked age-plugin-yubikey
sudo install -m 755 ~/.cargo/bin/age-plugin-yubikey /usr/local/bin/
age-plugin-yubikey --help

After installation, build tools may be removed if desired.

Provision the primary YubiKey

Insert the primary YubiKey and launch the plugin:

sudo age-plugin-yubikey

Use these choices:

  • Select the inserted YubiKey
  • Select a free slot, for example Slot 1
  • Generate a new identity
  • Allow migration away from the default management key when prompted
  • Set a PIN
  • Use PIN policy = NEVER
  • Use Touch policy = NEVER
  • Write the identity file to a root-owned location such as:
    /root/.config/sops/age/system-yubikey-primary.txt

Secure the identity file:

sudo chmod 600 /root/.config/sops/age/system-yubikey-primary.txt

Verify:

sudo age-plugin-yubikey --list

A valid recipient should be listed.

Provision the backup YubiKey

Repeat the exact same procedure with the second YubiKey, but store its identity file separately, for example:

/root/.config/sops/age/system-yubikey-backup.txt

Mandatory cross-decryption test

It is not enough that each YubiKey works independently. Both must decrypt the same encrypted file.

printf 'dual\n' | age \
  -r '<PRIMARY_RECIPIENT>' \
  -r '<BACKUP_RECIPIENT>' \
  -o /tmp/dual.age

sudo age -d -i /root/.config/sops/age/system-yubikey-primary.txt /tmp/dual.age
sudo age -d -i /root/.config/sops/age/system-yubikey-backup.txt /tmp/dual.age

Both commands must return:

dual

Encrypting and decrypting files

Encrypt

sops --encrypt --in-place system-core/config.conf.sops

Decrypt

sops -d system-core/config.conf.sops

SOPS will use the local age identity stubs under /root/.config/sops/age/, which in turn require the corresponding YubiKey to be physically present.

Operational deployment model

The intended flow is:

  1. Edit files on the developer machine
  2. Encrypt sensitive files in the data repository
  3. Push both system and data repositories
  4. On the target system, run deployment
  5. The target system pulls both repositories and decrypts data locally using the inserted YubiKey

This keeps plaintext secrets out of Git while allowing the target system to perform all updates itself.

Rules that must not be violated

  • Do not commit plaintext secrets
  • Do not store YubiKey identity files in Git
  • Do not manually provision age identity slots with ykman or yubico-piv-tool
  • Do not mix provisioning methods after adopting age-plugin-yubikey
  • Do not skip cross-decryption testing

Result

This design yields:

  • Separation of executable code from sensitive data
  • Hardware-backed non-exportable decryption keys
  • Redundant decryption paths via two YubiKeys
  • Safe encrypted storage in Git
  • A fully reproducible setup that can be followed exactly

Architecture visual

                           ┌──────────────────────────────┐
                           │       Developer Machine      │
                           │                              │
                           │  - edits system code         │
                           │  - edits secrets (plaintext) │
                           │  - encrypts via age          │
                           └─────────────┬────────────────┘
                                         │
                ┌────────────────────────┴────────────────────────┐
                │                                                 │
                ▼                                                 ▼
   ┌──────────────────────────────┐               ┌──────────────────────────────┐
   │        system repo           │               │         data repo            │
   │ (plaintext, versioned)       │               │ (encrypted via sops + age)   │
   │                              │               │                              │
   │ - scripts                    │               │ - configs                    │
   │ - installers                 │               │ - secrets                    │
   │ - services                   │               │ - tokens                     │
   │                              │               │                              │
   │ ❗ NO secrets                 │               │ 🔒 encrypted                 │
   └─────────────┬────────────────┘               └─────────────┬────────────────┘
                 │                                              │
                 │                                              │
                 └───────────────┬──────────────────────────────┘
                                 │
                                 ▼
          ┌────────────────────────────────────────────────────────────┐
          │                     Target System                          │
          │                                                            │
          │  deploy-travel-router                                      │
          │  ─────────────────────                                     │
          │  1. pulls system repo (plaintext)                          │
          │  2. pulls data repo (encrypted)                            │
          │  3. decrypts using YubiKey (primary OR backup)             │
          │  4. executes deployment                                    │
          │                                                            │
          └────────────────────────────────────────────────────────────┘