summaryrefslogtreecommitdiffstats
path: root/internal/cryptokit/age.go
blob: 525b1fae31eac1bff9295583e10e03f88c920b59 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package cryptokit

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"strings"

	"filippo.io/age"
	"filippo.io/age/agessh"
	agearmor "filippo.io/age/armor"
)

const maxCryptoMessageBytes = 64 << 20

func parseAgeRecipient(value string) (age.Recipient, error) {
	value = strings.TrimSpace(value)
	if recipient, err := age.ParseX25519Recipient(value); err == nil {
		return recipient, nil
	}
	if recipient, err := agessh.ParseRecipient(value); err == nil {
		return recipient, nil
	}
	return nil, errors.New("unsupported age recipient: expected native X25519 or SSH Ed25519/RSA")
}

func parseAgeIdentity(value string) (age.Identity, error) {
	value = strings.TrimSpace(value)
	if identity, err := age.ParseX25519Identity(value); err == nil {
		return identity, nil
	}
	if identity, err := agessh.ParseIdentity([]byte(value)); err == nil {
		return identity, nil
	}
	return nil, errors.New("unsupported age identity: expected native X25519 or SSH Ed25519/RSA private key")
}

// EncryptAge encrypts to a user-provided native age X25519 recipient or SSH
// Ed25519/RSA recipient and returns ASCII-armored age text.
func EncryptAge(message []byte, recipientKey string) ([]byte, error) {
	recipient, err := parseAgeRecipient(recipientKey)
	if err != nil {
		return nil, fmt.Errorf("parse age recipient: %w", err)
	}
	var output bytes.Buffer
	armored := agearmor.NewWriter(&output)
	writer, err := age.Encrypt(armored, recipient)
	if err != nil {
		_ = armored.Close()
		return nil, fmt.Errorf("create age encryption: %w", err)
	}
	if _, err := writer.Write(message); err != nil {
		_ = writer.Close()
		_ = armored.Close()
		return nil, fmt.Errorf("write age message: %w", err)
	}
	if err := writer.Close(); err != nil {
		_ = armored.Close()
		return nil, fmt.Errorf("close age message: %w", err)
	}
	if err := armored.Close(); err != nil {
		return nil, fmt.Errorf("close age armor: %w", err)
	}
	return output.Bytes(), nil
}

// DecryptAge decrypts an armored or binary age message with a user-provided
// native X25519 identity or an unencrypted SSH Ed25519/RSA private key.
func DecryptAge(message []byte, identityKey string) ([]byte, error) {
	identity, err := parseAgeIdentity(identityKey)
	if err != nil {
		return nil, fmt.Errorf("parse age identity: %w", err)
	}
	var input io.Reader = bytes.NewReader(message)
	if bytes.HasPrefix(bytes.TrimSpace(message), []byte(agearmor.Header)) {
		input = agearmor.NewReader(input)
	}
	reader, err := age.Decrypt(input, identity)
	if err != nil {
		return nil, fmt.Errorf("create age decryption: %w", err)
	}
	plaintext, err := io.ReadAll(io.LimitReader(reader, maxCryptoMessageBytes+1))
	if err != nil {
		return nil, fmt.Errorf("read age message: %w", err)
	}
	if len(plaintext) > maxCryptoMessageBytes {
		return nil, errors.New("decrypted age message exceeds the 64 MiB limit")
	}
	return plaintext, nil
}