age

package module
v0.0.0-...-950c6a0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 8, 2023 License: BSD-3-Clause Imports: 17 Imported by: 0

README

About

This is an age fork with internal support for Yubikeys private key generation and storage instead of external plugins such as age-plugin-yubikey or yubage.

The main reasons for this are that I find it annyoing to have external plugins for this specific purpose (negating Go's awesome static binaries feature) and complicates the gopass fork that I want to finish next.

While I understand why it was created, I'm not a fan of the plugin design since external code from PATH is called and there is no authentication between the caller (age) and the plugin, which is kind of weird when exchanging critical crypto material.

I also don't like Rust (essentially a C++ like spaghetti on steroids :-X) and the Go version is incomplete (lacking key generation and not fully compatible with age-plugin-yubikey). And most important, I still have too much free time these days while on funemployment.

It also follows a principle to use as less external code as possible so the UI is not as nice as age-plugin-yubikey (key based selection instead of cursor based selection). The amount of random dependencies that certain Go projects have drives me crazy in a world of increasing supply chain attacks (yes, go mod and vendoring are nice, but less is more for me).

A complete key generator age-yubikeygen is available and is pretty much on par with age-plugin-yubikey features.

One difference versus age-plugin-yubikey usage is that you should initially run setup as age-yubikeygen --setup to make the target Yubikey ready (essentially forcing default PIN changes and migrating the management key to a PIN protected metadata slot). This is to support new and existing Yubikeys you might have.

Please notice that both age-yubikeygen and age-plugin-yubikey use the "retired key management" slots to store their keys and certificates. It's not possible to remove keys/certificates from individual slots (only all slots reset) but slot overwrite is supported.

The key generator utility also supports a full PIV reset (age-yubikeygen --reset) and options to change PIN and PUK. This avoids using ykman for these operations. Please be aware that the PIV reset will clear all the slots, including the often used 9a, 9c, 9d, and 9e in GPG setups.

The age command code was slightly modified to parse the new identity/recipient while also still supporting age-plugin-yubikey.

This fork requires Go 1.20+ because it's already using latest crypto/ecdh package per Go 1.20 release notes.

Both Yubikeys 4 and 5 are supported, although Yubikeys 5 are better because of issues with the caching and PIN reset. For example, the once policy isn't really easy to support with Yubikeys 4 and it's pretty much useless for practical usage purposes, meaning, that you always need to insert PIN when doing operations. This problem doesn't occur with Yubikeys 5.

Tested on macOS x86_64 & ARM64, and Linux x86_64 & ARM64, FreeBSD 13.1 ARM64.

I have no idea if there is a point in trying to merge with upstream age. The reason is that age codebase is quite stable these days and this fork ideas might not make sense there. Its goal is pretty much to fulfill my needs. I'll leave it to Filippo to decide that. All new code follows the original age license.

Have fun,
fG!

Requirements

As mention above, Go 1.20+ is required.

CGO is required because of pcscd dependency from piv-go package. Unfortunately there isn't yet a full PC/SC Go implementation. Maybe that could be a future project.

On Linux PCSC lite middleware is required. The pcscd daemon for runtime and libpcsclite-dev to build.

macOS

The PCSC.framework is included with macOS so no extra dependencies are necessary to install. The CryptoTokenKit framework reimplemented PCSC and maybe could be used but piv-go still links against PCSC.framework and this would require another fork or changes upstream.

Debian or Ubuntu:

sudo apt-get install pcscd libpcsclite-dev

Fedora

sudo yum install pcsc-lite-devel

CentOS

sudo yum install 'dnf-command(config-manager)'
sudo yum config-manager --set-enabled PowerTools
sudo yum install pcsc-lite-devel

FreeBSD

sudo pkg install pcsc-lite

Installation

git clone https://github.com/gdbinit/yage.git
cd yage
make
sudo cp {age,age-keygen,age-yubikeygen} /usr/local/bin

Usage

There is no difference using age, it's just a matter of using the new recipients and identities generated with age-yubikeygen.

The identities files aren't a secret as regular age identity files since they just contain information about the corresponding Yubikey - all private key material was generated and stored inside Yubikeys and can't be (easily) extracted.

To use age-yubikeygen setup must be first executed:

$ age-yubikeygen --setup
🔓 Enter PIN for YubiKey (default is 123456): 

✨ Your YubiKey is using the default PIN. Let's change it!
✨ We'll also set the PUK equal to the PIN.

🔐 The PIN is from 6 to 8 numbers, letters, or symbols. Not just numbers!
❌ Your keys will be lost if the PIN and PUK are locked after 3 incorrect tries.

🔓 Enter current PUK (default is 12345678):  
❓ Choose a new PIN/PUK:  
❓ Repeat the PIN/PUK: 

✨ Your YubiKey is using the default management key.
✨ We'll migrate it to a PIN-protected management key.
... Success!

The new management key is: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

✨ Your Yubikey is ready to be used.

Now a new identity can be generated:

$ age-yubikeygen
a) Slot 1 (Empty)
b) Slot 2 (Empty)
c) Slot 3 (Empty)
d) Slot 4 (Empty)
e) Slot 5 (Empty)
f) Slot 6 (Empty)
g) Slot 7 (Empty)
h) Slot 8 (Empty)
i) Slot 9 (Empty)
j) Slot 10 (Empty)
k) Slot 11 (Empty)
l) Slot 12 (Empty)
m) Slot 13 (Empty)
n) Slot 14 (Empty)
o) Slot 15 (Empty)
p) Slot 16 (Empty)
q) Slot 17 (Empty)
r) Slot 18 (Empty)
s) Slot 19 (Empty)
t) Slot 20 (Empty)
❓ Please choose slot (or press enter to use next free slot): 
❓ Name this identity [leave empty for automatic]:  

❓ Select a PIN policy:
0) Always (A PIN is required for every decryption, if set)
1) Once   (A PIN is required once per session, if set)
2) Never  (A PIN is NOT required to decrypt)
> 2

❓ Select a touch policy:
0) Always (A physical touch is required for every decryption)
1) Cached (A physical touch is required for decryption, and is cached for 15 seconds)
2) Never  (A physical touch is NOT required to decrypt)

> 0

❓ Generate new identity in slot 1? [y/n] 

🎲 Generating key...

🔓 Enter PIN for YubiKey: 
🔏 Generating certificate...
👆 Please touch the Yubikey

📝 File name to write this identity to [age-yubikey-identity-f1d4d923.txt]: 

✅ Done! This YubiKey identity is ready to go.

🔑 Here's your shiny new YubiKey recipient:

age1yubiembed1qtyc0zuw8xced8zzn9rjmvsc0dejerp0aw9yxe8ws7welfk90wkpvhgjhyr

Here are some example things you can do with it:

- Encrypt a file to this identity:
$ cat foo.txt | age -r age1yubiembed1qtyc0zuw8xced8zzn9rjmvsc0dejerp0aw9yxe8ws7welfk90wkpvhgjhyr -o foo.txt.age

- Decrypt a file with this identity:
$ cat foo.txt.age | age -d -i age-yubikey-identity-f1d4d923.txt > foo.txt

- Recreate the identity file:
$ age-yubikeygen -i --serial 5442177 --slot 1 > age-yubikey-identity-f1d4d923.txt

- Recreate the recipient:
$ age-yubikeygen -r --serial 5442177 --slot 1

⚠️  Remember: everything breaks, have a backup plan for when this YubiKey does.

Other age-yubikeygen options

The PIN and PUK can be individually changed using the --change-pin and --change-puk options.

All the PIV slots can be reset using the --reset option. This is a nuclear option and all the certificates and keys will be wiped out, so be careful.

The list of identities stored in the Yubikey can be retrieved using -i or --identity option, while recipients list with -r or --recipient. Use together with --slot to recreate specific identity and recipients.

passage compatibility

yage is a drop-in replacement for age and can be used with passage without modifications (unless yage is not installed as age).

The only change is how identities and recipients are created or added.

For example, assuming there is already an identity created with age-yubikeygen in Yubikey slot number 2, the passage setup after installation should be:

mkdir -p $HOME/.passage/store
chmod -R 700 $HOME/.passage/
age-yubikeygen -i --slot 2 >> $HOME/.passage/identities
age-yubikeygen -r --slot 2 >> $HOME/.passage/store/.age-recipients

There is no init command in passage script so this manually creates the expected folders, and then retrieves the identity and recipient from the Yubikey to the expected passage configuration files.

Multiple recipients can be added to $HOME/.passage/store/.age-recipients such as a backup recipient from another Yubikey and/or a regular age recipient securely stored offline.

Documentation

Overview

Package age implements file encryption according to the age-encryption.org/v1 specification.

For most use cases, use the Encrypt and Decrypt functions with X25519Recipient and X25519Identity. If passphrase encryption is required, use ScryptRecipient and ScryptIdentity. For compatibility with existing SSH keys use the filippo.io/age/agessh package.

age encrypted files are binary and not malleable. For encoding them as text, use the filippo.io/age/armor package.

Key management

age does not have a global keyring. Instead, since age keys are small, textual, and cheap, you are encouraged to generate dedicated keys for each task and application.

Recipient public keys can be passed around as command line flags and in config files, while secret keys should be stored in dedicated files, through secret management systems, or as environment variables.

There is no default path for age keys. Instead, they should be stored at application-specific paths. The CLI supports files where private keys are listed one per line, ignoring empty lines and lines starting with "#". These files can be parsed with ParseIdentities.

When integrating age into a new system, it's recommended that you only support X25519 keys, and not SSH keys. The latter are supported for manual encryption operations. If you need to tie into existing key management infrastructure, you might want to consider implementing your own Recipient and Identity.

Backwards compatibility

Files encrypted with a stable version (not alpha, beta, or release candidate) of age, or with any v1.0.0 beta or release candidate, will decrypt with any later versions of the v1 API. This might change in v2, in which case v1 will be maintained with security fixes for compatibility with older files.

If decrypting an older file poses a security risk, doing so might require an explicit opt-in in the API.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")

Functions

func Decrypt

func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error)

Decrypt decrypts a file encrypted to one or more identities.

It returns a Reader reading the decrypted plaintext of the age file read from src. All identities will be tried until one successfully decrypts the file.

Example
package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"os"

	age "github.com/gdbinit/yage"
)

// DO NOT hardcode the private key. Store it in a secret storage solution,
// on disk if the local machine is trusted, or have the user provide it.
var privateKey string

func main() {
	identity, err := age.ParseX25519Identity(privateKey)
	if err != nil {
		log.Fatalf("Failed to parse private key: %v", err)
	}

	f, err := os.Open("testdata/example.age")
	if err != nil {
		log.Fatalf("Failed to open file: %v", err)
	}

	r, err := age.Decrypt(f, identity)
	if err != nil {
		log.Fatalf("Failed to open encrypted file: %v", err)
	}
	out := &bytes.Buffer{}
	if _, err := io.Copy(out, r); err != nil {
		log.Fatalf("Failed to read encrypted file: %v", err)
	}

	fmt.Printf("File contents: %q\n", out.Bytes())
}
Output:

File contents: "Black lives matter."

func Encrypt

func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error)

Encrypt encrypts a file to one or more recipients.

Writes to the returned WriteCloser are encrypted and written to dst as an age file. Every recipient will be able to decrypt the file.

The caller must call Close on the WriteCloser when done for the last chunk to be encrypted and flushed to dst.

Example
package main

import (
	"bytes"
	"fmt"
	"io"
	"log"

	age "github.com/gdbinit/yage"
)

func main() {
	publicKey := "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm"
	recipient, err := age.ParseX25519Recipient(publicKey)
	if err != nil {
		log.Fatalf("Failed to parse public key %q: %v", publicKey, err)
	}

	out := &bytes.Buffer{}

	w, err := age.Encrypt(out, recipient)
	if err != nil {
		log.Fatalf("Failed to create encrypted file: %v", err)
	}
	if _, err := io.WriteString(w, "Black lives matter."); err != nil {
		log.Fatalf("Failed to write to encrypted file: %v", err)
	}
	if err := w.Close(); err != nil {
		log.Fatalf("Failed to close encrypted file: %v", err)
	}

	fmt.Printf("Encrypted file size: %d\n", out.Len())
}
Output:

Encrypted file size: 219

Types

type Identity

type Identity interface {
	Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
}

An Identity is passed to Decrypt to unwrap an opaque file key from a recipient stanza. It can be for example a secret key like X25519Identity, a plugin, or a custom implementation.

Unwrap must return an error wrapping ErrIncorrectIdentity if none of the recipient stanzas match the identity, any other error will be considered fatal.

Most age API users won't need to interact with this directly, and should instead pass Recipient implementations to Encrypt and Identity implementations to Decrypt.

func ParseIdentities

func ParseIdentities(f io.Reader) ([]Identity, error)

ParseIdentities parses a file with one or more private key encodings, one per line. Empty lines and lines starting with "#" are ignored.

This is the same syntax as the private key files accepted by the CLI, except the CLI also accepts SSH private keys, which are not recommended for the average application.

Currently, all returned values are of type *X25519Identity, but different types might be returned in the future.

Example
package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"os"

	age "github.com/gdbinit/yage"
)

func main() {
	keyFile, err := os.Open("testdata/example_keys.txt")
	if err != nil {
		log.Fatalf("Failed to open private keys file: %v", err)
	}
	identities, err := age.ParseIdentities(keyFile)
	if err != nil {
		log.Fatalf("Failed to parse private key: %v", err)
	}

	f, err := os.Open("testdata/example.age")
	if err != nil {
		log.Fatalf("Failed to open file: %v", err)
	}

	r, err := age.Decrypt(f, identities...)
	if err != nil {
		log.Fatalf("Failed to open encrypted file: %v", err)
	}
	out := &bytes.Buffer{}
	if _, err := io.Copy(out, r); err != nil {
		log.Fatalf("Failed to read encrypted file: %v", err)
	}

	fmt.Printf("File contents: %q\n", out.Bytes())
}
Output:

File contents: "Black lives matter."

type NoIdentityMatchError

type NoIdentityMatchError struct {
	// Errors is a slice of all the errors returned to Decrypt by the Unwrap
	// calls it made. They all wrap ErrIncorrectIdentity.
	Errors []error
}

NoIdentityMatchError is returned by Decrypt when none of the supplied identities match the encrypted file.

func (*NoIdentityMatchError) Error

func (*NoIdentityMatchError) Error() string

type Recipient

type Recipient interface {
	Wrap(fileKey []byte) ([]*Stanza, error)
}

A Recipient is passed to Encrypt to wrap an opaque file key to one or more recipient stanza(s). It can be for example a public key like X25519Recipient, a plugin, or a custom implementation.

Most age API users won't need to interact with this directly, and should instead pass Recipient implementations to Encrypt and Identity implementations to Decrypt.

func ParseRecipients

func ParseRecipients(f io.Reader) ([]Recipient, error)

ParseRecipients parses a file with one or more public key encodings, one per line. Empty lines and lines starting with "#" are ignored.

This is the same syntax as the recipients files accepted by the CLI, except the CLI also accepts SSH recipients, which are not recommended for the average application.

Currently, all returned values are of type *X25519Recipient, but different types might be returned in the future.

type ScryptIdentity

type ScryptIdentity struct {
	// contains filtered or unexported fields
}

ScryptIdentity is a password-based identity.

func NewScryptIdentity

func NewScryptIdentity(password string) (*ScryptIdentity, error)

NewScryptIdentity returns a new ScryptIdentity with the provided password.

func (*ScryptIdentity) SetMaxWorkFactor

func (i *ScryptIdentity) SetMaxWorkFactor(logN int)

SetMaxWorkFactor sets the maximum accepted scrypt work factor to 2^logN. It must be called before Unwrap.

This caps the amount of work that Decrypt might have to do to process received files. If SetMaxWorkFactor is not called, a fairly high default is used, which might not be suitable for systems processing untrusted files.

func (*ScryptIdentity) Unwrap

func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error)

type ScryptRecipient

type ScryptRecipient struct {
	// contains filtered or unexported fields
}

ScryptRecipient is a password-based recipient. Anyone with the password can decrypt the message.

If a ScryptRecipient is used, it must be the only recipient for the file: it can't be mixed with other recipient types and can't be used multiple times for the same file.

Its use is not recommended for automated systems, which should prefer X25519Recipient.

func NewScryptRecipient

func NewScryptRecipient(password string) (*ScryptRecipient, error)

NewScryptRecipient returns a new ScryptRecipient with the provided password.

func (*ScryptRecipient) SetWorkFactor

func (r *ScryptRecipient) SetWorkFactor(logN int)

SetWorkFactor sets the scrypt work factor to 2^logN. It must be called before Wrap.

If SetWorkFactor is not called, a reasonable default is used.

func (*ScryptRecipient) Wrap

func (r *ScryptRecipient) Wrap(fileKey []byte) ([]*Stanza, error)

type Stanza

type Stanza struct {
	Type string
	Args []string
	Body []byte
}

A Stanza is a section of the age header that encapsulates the file key as encrypted to a specific recipient.

Most age API users won't need to interact with this directly, and should instead pass Recipient implementations to Encrypt and Identity implementations to Decrypt.

type X25519Identity

type X25519Identity struct {
	// contains filtered or unexported fields
}

X25519Identity is the standard age private key, which can decrypt messages encrypted to the corresponding X25519Recipient.

func GenerateX25519Identity

func GenerateX25519Identity() (*X25519Identity, error)

GenerateX25519Identity randomly generates a new X25519Identity.

Example
package main

import (
	"fmt"
	"log"

	age "github.com/gdbinit/yage"
)

func main() {
	identity, err := age.GenerateX25519Identity()
	if err != nil {
		log.Fatalf("Failed to generate key pair: %v", err)
	}

	fmt.Printf("Public key: %s...\n", identity.Recipient().String()[:4])
	fmt.Printf("Private key: %s...\n", identity.String()[:16])
}
Output:

Public key: age1...
Private key: AGE-SECRET-KEY-1...

func ParseX25519Identity

func ParseX25519Identity(s string) (*X25519Identity, error)

ParseX25519Identity returns a new X25519Identity from a Bech32 private key encoding with the "AGE-SECRET-KEY-1" prefix.

func (*X25519Identity) Recipient

func (i *X25519Identity) Recipient() *X25519Recipient

Recipient returns the public X25519Recipient value corresponding to i.

func (*X25519Identity) String

func (i *X25519Identity) String() string

String returns the Bech32 private key encoding of i.

func (*X25519Identity) Unwrap

func (i *X25519Identity) Unwrap(stanzas []*Stanza) ([]byte, error)

type X25519Recipient

type X25519Recipient struct {
	// contains filtered or unexported fields
}

X25519Recipient is the standard age public key. Messages encrypted to this recipient can be decrypted with the corresponding X25519Identity.

This recipient is anonymous, in the sense that an attacker can't tell from the message alone if it is encrypted to a certain recipient.

func ParseX25519Recipient

func ParseX25519Recipient(s string) (*X25519Recipient, error)

ParseX25519Recipient returns a new X25519Recipient from a Bech32 public key encoding with the "age1" prefix.

func (*X25519Recipient) String

func (r *X25519Recipient) String() string

String returns the Bech32 public key encoding of r.

func (*X25519Recipient) Wrap

func (r *X25519Recipient) Wrap(fileKey []byte) ([]*Stanza, error)

Directories

Path Synopsis
Package agessh provides age.Identity and age.Recipient implementations of types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for encryption with age-encryption.org/v1.
Package agessh provides age.Identity and age.Recipient implementations of types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for encryption with age-encryption.org/v1.
Package armor provides a strict, streaming implementation of the ASCII armoring format for age files.
Package armor provides a strict, streaming implementation of the ASCII armoring format for age files.
cmd
age
internal
bech32
Package bech32 is a modified version of the reference implementation of BIP173.
Package bech32 is a modified version of the reference implementation of BIP173.
format
Package format implements the age file format.
Package format implements the age file format.
stream
Package stream implements a variant of the STREAM chunked encryption scheme.
Package stream implements a variant of the STREAM chunked encryption scheme.
Package plugin implements the age plugin protocol.
Package plugin implements the age plugin protocol.
Implements yubikey based encryption without calling an external plugin
Implements yubikey based encryption without calling an external plugin

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL